27 Commits

Author SHA1 Message Date
Nora 33d5283667 docs: auth section + env vars 2026-06-22 15:22:07 -06:00
Nora 83b80535be feat(docker): CYCLONE_ADMIN_* env vars for backend 2026-06-22 15:21:55 -06:00
Nora f91d7b3b46 feat(auth): disable write affordances for viewer role 2026-06-22 15:20:05 -06:00
Nora b8b679b108 feat(auth): sidebar shows current user + logout button 2026-06-22 15:17:35 -06:00
Nora 337020cca1 feat(auth): wire AuthProvider + RequireAuth into app shell 2026-06-22 15:16:52 -06:00
Nora 319ac5f586 feat(auth): RequireAuth route guard 2026-06-22 15:12:43 -06:00
Nora 3e8d52ba08 feat(auth): /login page 2026-06-22 15:12:31 -06:00
Nora 56249402fb feat(auth): RoleGate component 2026-06-22 15:12:05 -06:00
Nora 89bd07bca7 feat(auth): AuthProvider context + useAuth hook 2026-06-22 15:11:02 -06:00
Nora f1f2eee69e feat(auth): fetch wrapper with 401 redirect + authApi 2026-06-22 15:09:49 -06:00
Nora 5cdfc05b41 feat(types): add User type 2026-06-22 15:09:10 -06:00
Nora 064909e1cd feat(auth): CLI users subcommand 2026-06-22 15:01:35 -06:00
Nora a0d3448c0c feat(audit): record user_id on log events 2026-06-22 14:58:01 -06:00
Nora 0f7ec133d7 test(auth): login rate limit 2026-06-22 14:53:16 -06:00
Nora c4d2d2b5bf feat(auth): bootstrap first admin from env vars 2026-06-22 14:52:51 -06:00
Nora 63ae0d2905 feat(auth): gate existing endpoints with matrix_gate dependency 2026-06-22 14:46:56 -06:00
Nora 23c1bb6b34 feat(auth): get_current_user + login/logout/me + admin user management 2026-06-22 14:36:57 -06:00
Nora b988c4c518 feat(auth): sessions module + permissions matrix + rate limiter
SQLite drops tzinfo on DateTime roundtrip — normalize on read/write
so callers see tz-aware datetimes.
2026-06-22 14:33:20 -06:00
Nora b5f10c780b test(auth): tighten duplicate-username test + add update_password test 2026-06-22 14:24:55 -06:00
Nora 4e8e03363f feat(auth): users module with bcrypt hashing + CRUD 2026-06-22 14:19:29 -06:00
Nora 982ac127b3 feat(migration): 0010 users + sessions, 0011 audit_log.user_id 2026-06-22 14:14:47 -06:00
Nora 129fb2308d feat(db): add User and Session models 2026-06-22 14:13:30 -06:00
Nora 517f9e23e6 feat(deps): add passlib[bcrypt] for password hashing
passlib 1.7.4 + bcrypt >= 4.1 are incompatible (passlib probes
bcrypt.__about__ which 4.x removed). Pin bcrypt<4.1.
2026-06-22 14:12:14 -06:00
Nora 0677e4fd65 wip: dashboard wiring (real backend) + Docker compose for production 2026-06-22 14:07:31 -06:00
Nora 4a382c0b16 docs(plan): auth implementation plan — 29 tasks, ~40 files 2026-06-22 14:05:39 -06:00
Nora 54a361551e docs(spec): auth (admin/user/viewer) design — sessions, RBAC, login flow 2026-06-22 13:57:10 -06:00
Tyler 63b2870f6e docs: surface SP9-SP13 in README + endpoint reference
The README only documented up to SP8 even though SP9-SP13 (multi-payer
+ multi-NPI + SFTP stub, 277CA + Payer-Rejected lane, tamper-evident
audit log, optional SQLCipher encryption, paramiko SFTP wire-up) are
all merged into main.

This brings the README back in line with the codebase:

- Inbox section: bump lane count from four to five, add the
  payer_rejected lane (and its monotonic stamping contract).
- Add a Multi-Payer, Multi-NPI & Clearhouse section (SP9): the
  providers / payers / payer_configs / clearhouse tables, the
  config/payers.yaml loader, the new config + clearhouse endpoints.
- Add a 277CA Claim Acknowledgment section (SP10): parse-277ca +
  277ca-acks, the Payer-Rejected stamp semantics.
- Add a Tamper-Evident Audit Log section (SP11): hash-chained
  audit_log, the verify endpoint.
- Add an Encryption at Rest section (SP12): Keychain-stored
  SQLCipher key, transparent fallback to plain SQLite.
- Add an SFTP Wire-Up (paramiko) section (SP13): the production
  submit path and the file-naming template.
- Persistence section: link to the Encryption at Rest section.
- Roadmap: bump shipped sub-projects to 2-13, add a one-line
  pointer to the completeness review for the honest industry-gap
  accounting.
- Project layout: add api_helpers, db_crypto, audit_log, the new
  inbox_* modules, providers/payers/secrets, the new test files,
  and the config/ + docs/reviews/ + docs/superpowers/specs/ tree.
- backend/README: refresh the endpoint table to include the
  parse-999/parse-ta1/parse-277ca/eligibility/clearhouse/admin
  surface; point at the root README for the full route list.
2026-06-21 00:10:50 -06:00
66 changed files with 9159 additions and 512 deletions
+54
View File
@@ -0,0 +1,54 @@
# Repo-root .dockerignore — applied to any Dockerfile whose build context
# is the repo root (frontend/Dockerfile, backend/Dockerfile via
# docker-compose). Keeps the build context small and deterministic.
.git/
.gitignore
.gitattributes
# Editor + OS junk
.DS_Store
.idea/
.vscode/
*.swp
*.swo
*~
Thumbs.db
# Node — never used by the backend image, and the frontend image installs
# its own clean copy via `npm ci` rather than carrying the host's cache.
node_modules
dist
.vite
.cache
.eslintcache
coverage
# Python — same logic: the backend image installs its own deps via pip.
.venv
venv
__pycache__
**/__pycache__
*.pyc
*.pyo
.pytest_cache
.mypy_cache
.ruff_cache
.coverage
htmlcov
*.egg-info/
build/
# Docs + tests + sample data — none are needed at runtime in either image.
# (config/ is included because the backend loads config/payers.yaml from it.)
docs/
backend/tests/
**/*.test.ts
**/*.test.tsx
**/vitest.config.ts
**/tsconfig.tsbuildinfo
# Docker artifacts themselves
Dockerfile*
docker-compose*.yml
.dockerignore
+17 -4
View File
@@ -1,7 +1,20 @@
# Cyclone — environment configuration
# Copy this file to `.env.local` and fill in values for your environment.
# Base URL for the Python (FastAPI) backend that powers the Upload page and
# the real /api/parse-837 + /api/parse-835 endpoints. Leave empty to keep
# the in-memory sample data store and disable real EDI parsing.
VITE_API_BASE_URL=http://localhost:8000
# Required on first boot. Cyclone refuses to start without these unless
# at least one user already exists (e.g. seeded via `python -m cyclone users create`).
# Min 12 chars for password.
CYCLONE_ADMIN_USERNAME=admin
CYCLONE_ADMIN_PASSWORD=change-me-to-a-strong-password-min-12-chars
# Base URL for the Python (FastAPI) backend. Leave empty for the
# Docker deployment (nginx proxies /api/* to backend on compose network).
VITE_API_BASE_URL=
# Optional. Set to 1 if you're behind an HTTPS reverse proxy and want
# the session cookie to include the Secure flag.
# CYCLONE_BEHIND_HTTPS=1
# Optional. Set to 1 to disable auth entirely (DEV ONLY). When set,
# the backend auto-grants admin access without checking credentials.
# CYCLONE_AUTH_DISABLED=0
+398 -18
View File
@@ -65,6 +65,49 @@ npm run build
npm test
```
## Authentication
Cyclone ships with username/password authentication and three predefined roles.
**Roles:**
| Role | Can read | Can write (upload, parse, reconcile) | Can manage users |
| -------- | -------- | ------------------------------------ | ---------------- |
| `viewer` | ✅ | ❌ | ❌ |
| `user` | ✅ | ✅ | ❌ |
| `admin` | ✅ | ✅ | ✅ |
**Bootstrap.** On first start, set `CYCLONE_ADMIN_USERNAME` and
`CYCLONE_ADMIN_PASSWORD` (min 12 chars) in your environment. Cyclone creates the
first admin automatically. On subsequent starts these env vars are ignored, so
rotating the bootstrap password doesn't affect an already-seeded admin — use the
CLI below to reset it. When running via `docker compose`, both vars are
required: compose refuses to start with a clear error if either is missing.
**CLI.** Manage users from the command line:
```
python -m cyclone users create alice --role user --password 'hunter2hunter2'
python -m cyclone users list
python -m cyclone users disable alice
python -m cyclone users reset-password alice
python -m cyclone users set-role alice --role admin
```
**Login.** Browse to `http://localhost:5173` (dev) or `http://localhost:8081`
(Docker), sign in on the `/login` page, and you'll be redirected to the
dashboard. Sessions are stored server-side in SQLite with a 24-hour sliding
expiry — every authenticated request refreshes the TTL, so an active user
never gets logged out.
**Dev escape hatch.** Set `CYCLONE_AUTH_DISABLED=1` to bypass auth entirely
(the backend auto-grants admin on every request). **NEVER set this in
production** — it's a single env-var trip from wide-open to the public
internet. The Docker compose file does not honor this flag.
See `docs/superpowers/specs/2026-06-22-cyclone-auth-design.md` for the full
design.
## Live updates
The Claims, Remittances, and Activity pages stay current without
@@ -130,10 +173,13 @@ backoff schedule on error is `1s → 2s → 4s → 8s → 16s → 30s` capped.
## Inbox
`/inbox` is the working surface. Four lanes, dark by default (Ticker Tape
`/inbox` is the working surface. Five lanes, dark by default (Ticker Tape
aesthetic):
- **Rejected** — claims whose 999 set-level response was R or E. Re-submit in bulk.
- **Payer-rejected** — claims whose 277CA STC category is A4, A6, or A7 (the payer
accepted the file but denied the claim). Stamped at 277CA ingest time and never
overwritten by a looser later 277CA.
- **Candidates** — remits whose CLP-claim-id didn't match exactly; each one shows its top scored claim. One-click manual match or dismiss.
- **Unmatched** — claims still waiting for a remit, and remits with no candidates above the threshold.
- **Done today** — terminal state transitions in the last 24 hours.
@@ -148,7 +194,7 @@ parses and rejects claims, the inbox reflects the new
| Method | Path | Notes |
| ------ | --------------------------------------------- | ---------------------------------------------------------------- |
| GET | `/api/inbox/lanes` | All four lanes in one call. |
| GET | `/api/inbox/lanes` | All five lanes in one call. |
| POST | `/api/inbox/candidates/{remit_id}/match` | Manual match. `409` if the claim state moved out from under us. |
| POST | `/api/inbox/candidates/dismiss` | `{pairs: [{claim_id, remit_id}]}`. Session-scoped. |
| POST | `/api/inbox/rejected/resubmit` | `{claim_ids: [...]}`. `200` with `conflicts` for non-rejected. |
@@ -255,11 +301,139 @@ drawer surfaces a per-line "no 837 line matched" note.
Tiers: **strong** (≥75, full opacity, Match enabled), **weak**
(5074, dimmed), **hidden** (<50, not surfaced).
## Multi-Payer, Multi-NPI & Clearhouse
The payer and provider identity that used to live as a single hard-coded
`PayerConfig` dict in the backend is now data, not code. Three new tables
plus a YAML file drive the entire configuration:
- **`providers` table** — one row per billing-provider NPI (Montrose
`1881068062`, Delta `1851446637`, Salida `1467507269`). All three share
the same `TOC, Inc.` legal name, tax ID `721587149`, and taxonomy
`251E00000X`. Outbound 837 files pick the right `BillingProvider` by
NPI; `claim.party.npi` is now a foreign key into `providers`.
- **`payers` table** — one row per payer (`CO_TXIX`, …) with its
receiver identity (NM1*40 / ISA08 / GS03).
- **`payer_configs` join table** — one row per `(payer_id, transaction_type)`
pair. 837P and 835 can carry different `BHT06`, SBR defaults, and
allowed status codes per payer.
- **`clearhouse` single-row config** — dzinesco's identity: TPID
`11525703`, submitter name, MT-clock file-naming block, SFTP block.
- **`config/payers.yaml`** — the on-disk source for everything above,
schema-validated at boot against a Pydantic model. A typo or missing
field fails the boot with a precise error. The original in-code
`PAYER_FACTORIES` dict is kept as a fallback for ad-hoc testing.
### Config + clearhouse endpoints
| Method | Path | Notes |
| ------ | --------------------------------------------- | ---------------------------------------------------------------- |
| GET | `/api/clearhouse` | The `clearhouse` singleton (name, TPID, file/SFTP blocks). |
| POST | `/api/clearhouse/submit` | Push a batch of generated 837 files via SFTP (see SFTP section). |
| GET | `/api/config/providers` | All providers. |
| GET | `/api/config/providers/{npi}` | One provider. |
| GET | `/api/config/payers` | All payers. |
| GET | `/api/config/payers/{payer_id}/configs` | All `(payer_id, transaction_type)` configs for one payer. |
| POST | `/api/admin/reload-config` | Re-read `config/payers.yaml` and refresh the in-process cache. |
## 277CA Claim Acknowledgment
A 277CA (`005010X214`) is the per-claim acknowledgment CMS and
Colorado Medicaid rely on: the file was syntactically valid *and* each
named claim was accepted, pended, or rejected by the payer at the claim
level. It is distinct from a 999 (file-level) and a TA1 (envelope-level).
Cyclone ingests 277CA files the same way it ingests 999 / 835 — drop the
file on the Upload page or `POST /api/parse-277ca` — and stamps every
claim whose `STC` category is `A4`, `A6`, or `A7` with a non-null
`payer_rejected_at` + `payer_rejected_reason` + originating 277CA row id.
| Method | Path | Notes |
| ------ | -------------------------- | ---------------------------------------------------------------- |
| POST | `/api/parse-277ca` | Upload a 277CA, persist the parsed status rows. |
| GET | `/api/277ca-acks` | List 277CA acks (filterable by date / payer). |
| GET | `/api/277ca-acks/{id}` | One 277CA ack with its per-claim status rows + regenerated text. |
The `payer_rejected` stamp is **monotonic**: a later 277CA with a looser
status set cannot clear a previous rejection. The Payer-Rejected inbox
lane surfaces every claim with a non-null `payer_rejected_at` — it is
distinct from the 999 `Rejected` lane (envelope reject) and they can
both be true for the same claim.
## Tamper-Evident Audit Log
The `audit_log` table is the canonical record of every state transition
the system has ever observed — claim lifecycle, reconciliation
decisions, config reloads, SFTP submissions, 277CA rejects. SP11 made
it tamper-evident: every row carries a SHA-256 hash of
`(prev_hash || row_payload)`, forming a chain back to a genesis row.
Any `INSERT`, `UPDATE`, or `DELETE` that breaks the chain is detectable
in a single walk.
| Method | Path | Notes |
| ------ | --------------------------------- | -------------------------------------------------------------- |
| GET | `/api/admin/audit-log` | Paginated audit log (filterable by event type / actor / date). |
| GET | `/api/admin/audit-log/verify` | Walk the chain; return the first broken link, or `{ok: true}`. |
`verify_chain` is the integrity check that backs the audit promise —
it is intentionally cheap (one indexed walk) and intentionally
side-effect-free so a scheduler can run it on a cron and alert on any
non-`{ok: true}` result. Chain verification is **not** access-gated
beyond the same `127.0.0.1` bind the rest of the API uses; for a
hostile multi-operator deployment, wrap the route in your reverse proxy.
## Encryption at Rest
When the macOS Keychain carries an entry at service `cyclone`, account
`cyclone.db.key`, and the optional `sqlcipher3` Python package is
installed, the SQLite file at `~/.local/share/cyclone/cyclone.db` is
opened with SQLCipher (AES-256). The key is read from the Keychain
once at process start, applied via a SQLAlchemy `connect` event so
every connection — including migrations and tests — gets the same
`PRAGMA key`. The key is never written to disk or to a Python global.
When the Keychain entry is missing **or** `sqlcipher3` is not
installed, the DB falls back to plain SQLite. The intent is a graceful
default for developers and CI; the production posture is that every
operator has created the Keychain entry on first run. See
[docs/reference/co-medicaid.md §Keychain setup](docs/reference/co-medicaid.md)
for the one-time setup recipe and the HIPAA Security Rule §164.312(a)(2)(iv)
mapping.
## SFTP Wire-Up (paramiko)
The `clearhouse.submit` endpoint uses `paramiko` to push a batch of
generated 837 files to the dzinesco SFTP server
(`mft.gainwelltechnologies.com:22`, path
`/CO XIX/PROD/coxix_prod_11525703/FromHPE`). The SFTP credential is
fetched from the macOS Keychain at call time — never read from YAML,
never logged, never written to disk. The wire-up honors the file-naming
template stored in the `clearhouse` config:
```
outbound: {tpid}-{tx}-{ts_mt}-1of1.{ext} e.g. 11525703-837P-20260620181814559-1of1.txt
inbound: TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
```
where `{ts_mt}` is a 17-digit `yyyymmddhhmmssSSS` Mountain Time stamp.
Inbound filenames are routed by `<FileType>` and `<OrigTx>` to the
matching parser (`999`, `TA1`, `271`, `277`, `277CA`, `835`).
The `SftpClient` interface is the same one the SP9 stub used — the swap
was a one-file change (`sftp_paramiko.py` replacing `sftp_stub.py`).
`paramiko` is an optional dependency; the stub remains the default when
the `paramiko` extras aren't installed so the test suite stays green on
Linux dev boxes.
## Persistence
Parsed batches, claims, remittances, matches, and activity events are
stored in a SQLite file at `~/.local/share/cyclone/cyclone.db` by
default. The directory is auto-created on first run.
Parsed batches, claims, remittances, matches, 277CA rejections,
hash-chained audit log entries, and SFTP submission history are stored
in a SQLite file at `~/.local/share/cyclone/cyclone.db` by default.
The directory is auto-created on first run. The DB is optionally
encrypted with SQLCipher — see
[Encryption at Rest](#encryption-at-rest) for the Keychain-driven
setup.
To use a different location, set `CYCLONE_DB_URL`:
@@ -285,11 +459,23 @@ backup API).
├── backend/
│ ├── src/cyclone/
│ │ ├── api.py # FastAPI app, GET + parse routes, /api/{resource}/stream
│ │ ├── api_helpers.py # NDJSON / content-negotiation / live-tail helpers
│ │ ├── pubsub.py # in-process EventBus (drop-oldest, per-kind fan-out)
│ │ ├── store.py # InMemoryStore, mappers, publish-on-write
│ │ ├── store.py # CycloneStore, mappers, publish-on-write
│ │ ├── db.py # SQLAlchemy engine, session factory, ORM models
│ │ ├── db_migrate.py # PRAGMA user_version migration runner
│ │ ├── db_crypto.py # optional SQLCipher encryption at rest (SP12)
│ │ ├── audit_log.py # tamper-evident hash-chained audit_log (SP11)
│ │ ├── inbox_lanes.py # rejected / payer_rejected / candidates / unmatched / done_today
│ │ ├── inbox_state.py # 999 envelope reject → claim state transitions
│ │ ├── inbox_state_277ca.py # 277CA STC A4/A6/A7 → payer_rejected stamp (SP10)
│ │ ├── providers.py # multi-NPI provider lookups (SP9)
│ │ ├── payers.py # payer / payer_config lookups (SP9)
│ │ ├── secrets.py # macOS Keychain-backed secret fetcher
│ │ ├── reconcile.py # pure-function 835→claim match + line-level match
│ │ ├── __main__.py # `python -m cyclone serve`
│ │ ├── cli.py # click CLI
│ │ └── parsers/ # X12 tokenizer, models, validator, writers
│ │ └── parsers/ # X12 tokenizer, models, validator, writers, 277CA, 999, TA1, 270, 271
│ └── tests/
│ ├── fixtures/ # co_medicaid_*.txt, minimal_*.txt
│ ├── test_api.py # parse-837/835 round-trip
@@ -298,29 +484,71 @@ backup API).
│ ├── test_api_streaming.py
│ ├── test_api_stream_live.py # 3 live-tail endpoints + disconnect cleanup
│ ├── test_pubsub.py # EventBus + subscribe/unsubscribe
── test_api_parse_persists.py
── test_api_parse_persists.py
│ ├── test_db.py / test_db_crypto.py / test_db_migrate.py
│ ├── test_audit_log.py
│ ├── test_inbox_lanes.py / test_inbox_state.py
│ ├── test_apply_277ca_rejections.py
│ ├── test_sftp_stub.py / test_sftp_paramiko.py
│ └── test_providers_seed.py / test_payer_config_loading.py
├── src/ # React + Vite + TypeScript UI
│ ├── components/
│ │ ├── ui/ # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, …
│ │ └── TailStatusPill.tsx # live-tail status badge + reconnect button
│ ├── pages/ # Claims, Remittances, Providers, Activity, Upload
│ ├── pages/ # Claims, Remittances, Providers, Acks, Activity, Upload, Inbox, …
│ ├── hooks/ # useBatches, useClaims, useRemittances, useProviders, useActivity, useParse
│ │ # + useTailStream, useMergedTail (live tail)
│ ├── lib/ # api.ts (6 GET + parse837/parse835/health), format.ts, utils.ts
│ ├── lib/ # api.ts, format.ts, utils.ts
│ │ # + tail-stream.ts (NDJSON parser)
│ ├── store/ # zustand sample-data + parsed-batches store
│ │ # + tail-store.ts (FIFO-capped live tail slices)
│ └── types/ # shared TS types
├── config/
│ └── payers.yaml # YAML-driven payer + clearhouse config (SP9)
├── docs/
│ ├── reference/ # condensed 837P/835/X12/CO Medicaid notes
── superpowers/plans/ # implementation plan
│ ├── reference/ # condensed 837P/835/X12/CO Medicaid notes (incl. Keychain setup)
── reviews/ # post-SP completeness reviews
│ ├── superpowers/plans/ # implementation plans
│ └── superpowers/specs/ # design specs (incl. SP9-SP13)
├── tailwind.config.js # shimmer, scan, row-flash keyframes
└── package.json
```
## Roadmap
Sub-projects 2 through 8 are **shipped**. Next up:
Sub-projects 2 through 13 are **shipped**. See the [completeness
review](docs/reviews/2026-06-20-cyclone-completeness-review.md) for
the honest gap analysis against the industry definition of a HIPAA
clearinghouse — the short version is that the local-only,
single-operator, single-payer design contract is honored, and the
items that would be needed to expand that contract (AS2/AS4, SNIP 17,
HITRUST, 276/277 status, 278 referrals, COB) are intentionally out of
scope.
Shipped sub-projects (most recent first):
- **Sub-project 13 (shipped) — SFTP wire-up.** `paramiko`-backed
`SftpClient` replaces the SP9 stub. The clearhouse.submit endpoint
actually pushes to
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
SFTP credentials are read from the macOS Keychain at call time.
- **Sub-project 12 (shipped) — Encryption at rest.** Optional
SQLCipher AES-256 encryption of the SQLite file, with the key
fetched from the macOS Keychain. Falls back to plain SQLite when
the Keychain entry is missing or `sqlcipher3` isn't installed.
- **Sub-project 11 (shipped) — Tamper-evident audit log.** Every
`audit_log` row carries a SHA-256 hash chained to the previous row;
a single walk via `GET /api/admin/audit-log/verify` detects any
break.
- **Sub-project 10 (shipped) — 277CA + Payer-Rejected lane.** Inbound
277CA parser + a new Payer-Rejected inbox lane distinct from the
999-envelope Rejected lane. The rejection stamp is monotonic.
- **Sub-project 9 (shipped) — Multi-payer, multi-NPI, SFTP stub.** The
in-code `PAYER_FACTORIES` dict is replaced by a `config/payers.yaml`
+ 3 new DB tables (`providers`, `payers`, `payer_configs`) +
`clearhouse` singleton. Added a `POST /api/clearhouse/submit` stub
that writes to a local `staging_dir` — swapped for real `paramiko`
in SP13.
- **Sub-project 8 (shipped) — Outbound 837P serializer.** Closes the
resubmit loop: rejected claims can be regenerated back to an X12 837P
@@ -390,8 +618,9 @@ Sub-projects 2 through 8 are **shipped**. Next up:
back-off ladder on errors is `1s → 2s → 4s → 8s → 16s → 30s`
capped. See the "Live updates" section below for details.
- **Sub-project 6 (shipped) — Inbox workflow automation.**
- **Ticker-Tape inbox (`/inbox`):** four lanes ordered by urgency —
**Rejected** (claims whose 999 rejected them), **Candidates**
- **Ticker-Tape inbox (`/inbox`):** five lanes ordered by urgency —
**Rejected** (claims whose 999 rejected them), **Payer-rejected**
(claims whose 277CA denied them — added in SP10), **Candidates**
(remits that didn't auto-match a claim), **Unmatched** (claims
still waiting for a remit), and **Done today** (terminal
transitions in the last 24 hours). The page subscribes to the
@@ -428,6 +657,10 @@ Sub-projects 2 through 8 are **shipped**. Next up:
- `GET /api/acks` — list ACKs.
- `GET /api/acks/{id}` — ACK detail, including the regenerated
`raw_999_text`.
- `POST /api/parse-ta1` — parse an inbound TA1 envelope ACK and persist it.
- `GET /api/ta1-acks` — list TA1 acks.
- `GET /api/ta1-acks/{id}` — TA1 ack detail (envelope control segments
+ the parser's accept/reject verdict).
- `POST /api/eligibility/request` — build a 270 from JSON.
- `POST /api/eligibility/parse-271` — ingest a 271 and return parsed
coverage benefits.
@@ -459,9 +692,9 @@ ACKs and lets you download the regenerated 999 text.
### SP6 endpoints (inbox)
- `GET /api/inbox/lanes` — all four lanes (rejected, candidates,
unmatched, done_today) in a single round-trip, with row-level
scoring and matched-remit context.
- `GET /api/inbox/lanes` — all five lanes (rejected, payer_rejected,
candidates, unmatched, done_today) in a single round-trip, with
row-level scoring and matched-remit context.
- `POST /api/inbox/candidates/{remit_id}/match` — manual match of a
candidate remit to one of its scored claims; `409` if the claim
state moved out from under us.
@@ -510,6 +743,153 @@ ACKs and lets you download the regenerated 999 text.
Download** button in the Inbox rejected-lane BulkBar (N>1 modal
prompt).
### SP9 endpoints (multi-payer, multi-NPI, SFTP stub)
- `GET /api/clearhouse` — the `clearhouse` singleton (name, TPID,
file-naming block, SFTP block).
- `POST /api/clearhouse/submit` — push a batch of generated 837 files.
The SP9 implementation writes to a local `staging_dir`; the SP13
swap replaces the write with a real `paramiko` SFTP push without
changing the route shape.
- `GET /api/config/providers` and `GET /api/config/providers/{npi}` —
list / fetch providers from the new `providers` table.
- `GET /api/config/payers` and
`GET /api/config/payers/{payer_id}/configs` — list payers; for a
given payer, return the per-transaction-type `payer_configs` rows.
- `POST /api/admin/reload-config` — re-read `config/payers.yaml` and
refresh the in-process cache without a server restart.
### SP10 endpoints (277CA + Payer-Rejected lane)
- `POST /api/parse-277ca` — upload a 277CA file; persist the parsed
`ClaimStatus` rows and stamp the matching claims with
`payer_rejected_at` (monotonic, never overwritten by `NULL`).
- `GET /api/277ca-acks` — list 277CA acks.
- `GET /api/277ca-acks/{id}` — one 277CA ack with its per-claim
`ClaimStatus` rows + regenerated text.
- `GET /api/inbox/lanes` — the response now also carries a
`payer_rejected` lane populated from
`Claim.payer_rejected_at IS NOT NULL`.
### SP11 endpoints (tamper-evident audit log)
- `GET /api/admin/audit-log` — paginated audit log. Each row carries
`(id, prev_hash, row_hash, event_type, actor, payload_json,
created_at)` where `row_hash = sha256(prev_hash || canonical_json(payload))`.
- `GET /api/admin/audit-log/verify` — walk the chain in insertion
order; return `{ok: true}` or the first `{id, expected, got}`
mismatch. The walk is O(n) with one indexed lookup per row.
### SP12 (encryption at rest — no new routes)
SP12 introduces no API routes. The `cyclone.db.key` Keychain entry +
optional `sqlcipher3` dependency enable AES-256 encryption transparently
on the next connection. See [Encryption at Rest](#encryption-at-rest)
and [docs/reference/co-medicaid.md](docs/reference/co-medicaid.md) for
the one-time setup recipe.
### SP13 endpoints (paramiko SFTP)
- `POST /api/clearhouse/submit` — same endpoint as SP9; the
implementation is now a real `paramiko` `SftpClient.write` to
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
SFTP credentials are fetched from the macOS Keychain at call time.
## Docker
A two-service `docker-compose.yml` is provided for operators who want
a single `docker compose up` instead of running the backend and
frontend in two terminals. Both services are configured to restart
automatically on crash, and the SQLite database lives on a named
volume that survives `docker compose down` (only `down -v` wipes it).
```bash
docker compose up -d --build # build + start both services in the background
docker compose ps # confirm both containers are Up (healthy)
docker compose logs -f # tail both logs
open http://127.0.0.1:8081 # the SPA, served by nginx
```
### What's where
| Service | Image | Published port | Healthcheck | Persistent state |
| ---------- | ---------------------- | ----------------------------- | ---------------------- | ------------------------- |
| `frontend` | `cyclone-frontend:local` | `0.0.0.0:8081 → 80` (LAN-reachable by default; tighten via `CYCLONE_BIND_ADDRESS=127.0.0.1`) | `GET /healthz` | none |
| `backend` | `cyclone-backend:local` | not published (see note) | `GET /api/health` | `cyclone-data` named volume at `/data` |
The frontend's nginx reverse-proxies `/api/*` to the backend over the
compose network, so the SPA talks to `http://same-origin/api/...` and
the Vite dev-server config (`VITE_API_BASE_URL=`) doesn't apply — the
build is invoked with an empty `VITE_API_BASE_URL` and the API calls
are relative.
### Persistence
The backend writes its SQLite file to `/data/cyclone.db` inside the
container, which is backed by the named volume `cyclone-data`. Killing
and restarting the container preserves the DB; rebooting the host
preserves the DB; only `docker compose down -v` removes it. The
container's `HEALTHCHECK` and `restart: unless-stopped` policy mean a
crash is recovered within ~15 seconds (next healthcheck interval)
without operator intervention.
To back up the live database:
```bash
docker compose exec backend sqlite3 /data/cyclone.db ".backup /data/backup.db"
docker cp cyclone-backend:/data/backup.db ./cyclone-backup-$(date +%F).db
```
(SQLite's online backup API — safe to run while the backend is
serving traffic.)
### Crashing the backend on purpose
To confirm the auto-restart wiring:
```bash
docker compose kill -s SIGKILL backend # hard-kill the backend
docker compose ps # backend should restart within seconds
docker compose logs --tail=20 backend # see the uvicorn startup banner again
```
Data is unaffected: the volume survives `kill` and the new process
re-opens the same DB file.
### Talking to the backend directly
The backend port is **not** published by default (everything goes
through the frontend's nginx). To expose it for `curl` debugging,
uncomment the `ports:` block under `backend:` in `docker-compose.yml`
and restart:
```bash
curl http://127.0.0.1:8000/api/health # {"status":"ok","version":"..."}
```
If 8081 clashes with something else on the host, run with a different
published port:
```bash
CYCLONE_WEB_PORT=9000 docker compose up -d
open http://127.0.0.1:9000
```
To reach the UI from another machine on your LAN, open
`http://<host-lan-ip>:8081` — the default bind is `0.0.0.0`. To
re-tighten to loopback-only (matching the standalone install's
local-only posture):
```bash
CYCLONE_BIND_ADDRESS=127.0.0.1 docker compose up -d
```
### Payer config
`config/payers.yaml` is copied into the backend image at build time.
Edit the YAML, rebuild, and either bounce the container or `POST
/api/admin/reload-config` to pick up changes without a rebuild.
## License
No license file yet; this is internal-use software. Add a `LICENSE` file
+24
View File
@@ -0,0 +1,24 @@
# Exclude everything not needed at runtime from the backend image.
# This file is read when backend/ is used as the build context. The
# docker-compose build uses the repo root as context and references
# `backend/...` paths explicitly, so most of these exclusions are belt-
# and-suspenders for any future `docker build backend/` invocation.
**/__pycache__
**/*.pyc
**/*.pyo
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/.coverage
**/.tox
**/.venv
**/venv
**/node_modules
tests/
docs/
.coverage*
htmlcov/
*.egg-info/
build/
dist/
+64
View File
@@ -0,0 +1,64 @@
# syntax=docker/dockerfile:1.7
# Cyclone backend — FastAPI on uvicorn.
#
# Image layout:
# /app repo root (copied by docker-compose)
# /app/backend/src python package source
# /app/config config/payers.yaml lives here
# /data persistent SQLite volume mountpoint
#
# Build context for this Dockerfile is the repo root (../) when invoked
# from docker-compose, so paths below are relative to repo root.
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# curl is used by the HEALTHCHECK. build-essential is not required — the
# backend is pure-python wheels on linux/amd64 + linux/arm64.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Non-root user. UID 10001 is unlikely to collide with the host UID.
RUN groupadd --system --gid 10001 cyclone \
&& useradd --system --uid 10001 --gid cyclone --home /app --shell /usr/sbin/nologin cyclone
WORKDIR /app
# Source + YAML config. Tests + fixtures + prodfiles are intentionally
# excluded from the runtime image (see backend/.dockerignore).
COPY backend/src /app/backend/src
COPY config /app/config
# Install deps last so source changes don't bust the pip cache layer.
# `pip install -e .` needs the `src/cyclone` package directory to exist
# at install time (setuptools runs egg-info during the editable install),
# so the source COPY above must come first.
COPY backend/pyproject.toml backend/uv.lock* /app/backend/
RUN pip install --no-cache-dir -e /app/backend
# Persistent volume mountpoint. SQLite needs the directory to exist and
# to be writable by the cyclone user; Docker creates the volume but the
# directory inside the image must be pre-created with the right owner.
RUN mkdir -p /data && chown -R cyclone:cyclone /data
USER cyclone
ENV CYCLONE_HOST=0.0.0.0 \
CYCLONE_PORT=8000 \
CYCLONE_RELOAD=0 \
CYCLONE_DB_URL=sqlite:////data/cyclone.db \
PYTHONPATH=/app/backend/src
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD curl --fail --silent http://127.0.0.1:8000/api/health || exit 1
# `python -m cyclone serve` honors CYCLONE_HOST/CYCLONE_PORT/CYCLONE_RELOAD
# from the env above. PYTHONPATH puts the editable package on the path.
CMD ["python", "-m", "cyclone", "serve"]
+19 -5
View File
@@ -78,11 +78,25 @@ python -m cyclone serve
### Endpoints
| Method | Path | Purpose |
| ------ | ---------------- | -------------------------------------------------- |
| GET | `/api/health` | Liveness probe: `{"status": "ok", "version": ...}` |
| POST | `/api/parse-837` | Upload an X12 837P file, get parsed claims back |
| POST | `/api/parse-835` | Upload an X12 835 ERA file, get parsed payouts back |
| Method | Path | Purpose |
| ------ | --------------------- | ------------------------------------------------------------------ |
| GET | `/api/health` | Liveness probe: `{"status": "ok", "version": ...}` |
| POST | `/api/parse-837` | Upload an X12 837P file, get parsed claims back |
| POST | `/api/parse-835` | Upload an X12 835 ERA file, get parsed payouts back |
| POST | `/api/parse-999` | Upload an inbound 999 ACK and persist it |
| POST | `/api/parse-ta1` | Upload an inbound TA1 envelope ACK and persist it |
| POST | `/api/parse-277ca` | Upload a 277CA claim acknowledgment and stamp payer_rejected claims |
| POST | `/api/eligibility/request` | Build a 270 from JSON (subscriber / provider / payer) |
| POST | `/api/eligibility/parse-271` | Ingest a 271 and return structured `coverage_benefits` |
| GET | `/api/clearhouse` | The `clearhouse` singleton (SP9) |
| POST | `/api/clearhouse/submit` | Push a batch of generated 837 files via SFTP (SP9 stub, SP13 real) |
| POST | `/api/admin/reload-config` | Re-read `config/payers.yaml` and refresh the in-process cache |
| GET | `/api/admin/audit-log` | Paginated tamper-evident audit log (SP11) |
| GET | `/api/admin/audit-log/verify` | Walk the audit_log hash chain (SP11) |
The full surface — claim / remittance / batch / inbox / stream
endpoints, config lookups, and the 270/271 builder — is enumerated in
the root [README](../README.md#multi-payer-multi-npi--clearhouse).
`POST /api/parse-837` accepts `multipart/form-data` with a single `file`
field. Optional query parameters:
+4
View File
@@ -16,6 +16,10 @@ dependencies = [
"sqlalchemy>=2.0,<3",
"pyyaml>=6.0,<7",
"keyring>=25.0,<26",
# passlib 1.7.4 + bcrypt >= 4.1 are incompatible (passlib probes bcrypt.__about__
# which 4.x removed). Pin bcrypt < 4.1.
"passlib[bcrypt]>=1.7.4",
"bcrypt<4.1",
]
[project.optional-dependencies]
+13 -2
View File
@@ -1,10 +1,12 @@
"""Entry point for ``python -m cyclone``.
* ``python -m cyclone`` (no args) — Click CLI (``cli.main``)
* ``python -m cyclone serve`` — start the FastAPI app on 127.0.0.1:8000
* ``python -m cyclone serve`` — start the FastAPI app
Honors the env vars:
* ``CYCLONE_HOST`` (default ``127.0.0.1`` — set to ``0.0.0.0`` in containers
so uvicorn accepts traffic from outside the container's loopback)
* ``CYCLONE_PORT`` (default ``8000``)
* ``CYCLONE_RELOAD`` (default ``0``; set to ``1`` to enable uvicorn reload)
"""
@@ -16,13 +18,22 @@ import sys
def main() -> None:
# Always run first-admin bootstrap before any other entry path.
# Must happen before ``serve`` (uvicorn) AND before the Click CLI
# dispatch — otherwise `python -m cyclone users create ...` on a
# fresh DB would race with the bootstrap's check, and the API
# could come up with zero users.
from cyclone.auth import bootstrap
bootstrap.run()
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
host = os.environ.get("CYCLONE_HOST", "127.0.0.1")
port = os.environ.get("CYCLONE_PORT", "8000")
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
sys.argv = [
sys.argv[0],
"cyclone.api:app",
"--host", "127.0.0.1",
"--host", host,
"--port", port,
]
if reload:
+118 -46
View File
@@ -26,16 +26,32 @@ from datetime import datetime, timezone
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi import Depends, FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import ValidationError
from cyclone import __version__, db
from cyclone.auth.deps import matrix_gate
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.audit_log import AuditEvent, append_event, verify_chain
def _actor_user_id(request: Request) -> int | None:
"""Return the acting user's id from ``request.state.user``, or None.
``get_current_user``/``matrix_gate`` populate ``request.state.user``
for both the authenticated path and the AUTH_DISABLED escape hatch.
Returns None when the state hasn't been set (e.g. background jobs
or unit tests that bypass auth). Used to stamp ``user_id`` onto
audit events without crashing the request.
"""
user = getattr(request.state, "user", None)
if user is None:
return None
return getattr(user, "id", None)
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
from cyclone.parsers.models_270 import (
@@ -131,6 +147,16 @@ app.add_middleware(
)
@app.exception_handler(HTTPException)
async def _http_exc_handler(request, exc: HTTPException):
code = exc.detail if isinstance(exc.detail, str) else "error"
return JSONResponse(
status_code=exc.status_code,
content={"error": code, "detail": str(exc.detail)},
headers=exc.headers,
)
def _resolve_payer(name: str) -> PayerConfig:
if name not in PAYER_FACTORIES:
raise HTTPException(
@@ -245,7 +271,7 @@ def health() -> dict[str, str]:
return {"status": "ok", "version": __version__}
@app.post("/api/parse-837")
@app.post("/api/parse-837", dependencies=[Depends(matrix_gate)])
async def parse_837(
request: Request,
file: UploadFile = File(...),
@@ -438,7 +464,7 @@ def _reconciliation_summary_for_batch(batch_id: str) -> dict:
}
@app.post("/api/parse-835")
@app.post("/api/parse-835", dependencies=[Depends(matrix_gate)])
async def parse_835_endpoint(
request: Request,
file: UploadFile = File(...),
@@ -582,7 +608,7 @@ def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
@app.post("/api/parse-999")
@app.post("/api/parse-999", dependencies=[Depends(matrix_gate)])
async def parse_999_endpoint(
request: Request,
file: UploadFile = File(...),
@@ -675,6 +701,7 @@ async def parse_999_endpoint(
entity_id=cid,
payload={"source_batch_id": synthetic_id, "ack_id": row.id},
actor="999-parser",
user_id=_actor_user_id(request),
))
audit_s.commit()
@@ -708,7 +735,7 @@ def _ta1_synthetic_source_batch_id(interchange_control_number: str) -> str:
return f"TA1-{(interchange_control_number or '').strip() or '000000001'}"
@app.post("/api/parse-ta1")
@app.post("/api/parse-ta1", dependencies=[Depends(matrix_gate)])
async def parse_ta1_endpoint(
file: UploadFile = File(...),
) -> Any:
@@ -787,7 +814,7 @@ async def parse_ta1_endpoint(
})
@app.get("/api/ta1-acks")
@app.get("/api/ta1-acks", dependencies=[Depends(matrix_gate)])
def list_ta1_acks_endpoint(
limit: int = Query(100, ge=1, le=1000),
) -> Any:
@@ -805,7 +832,7 @@ def list_ta1_acks_endpoint(
}
@app.get("/api/ta1-acks/{ack_id}")
@app.get("/api/ta1-acks/{ack_id}", dependencies=[Depends(matrix_gate)])
def get_ta1_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted TA1 ACK row with its parsed detail."""
row = store.get_ta1_ack(ack_id)
@@ -851,7 +878,7 @@ def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
@app.post("/api/parse-277ca")
@app.post("/api/parse-277ca", dependencies=[Depends(matrix_gate)])
async def parse_277ca_endpoint(
request: Request,
file: UploadFile = File(...),
@@ -945,6 +972,7 @@ async def parse_277ca_endpoint(
"277ca_id": row.id,
},
actor="277ca-parser",
user_id=_actor_user_id(request),
))
audit_s.commit()
if apply_result.orphans:
@@ -970,7 +998,7 @@ async def parse_277ca_endpoint(
})
@app.get("/api/277ca-acks")
@app.get("/api/277ca-acks", dependencies=[Depends(matrix_gate)])
def list_277ca_acks_endpoint(
limit: int = Query(100, ge=1, le=1000),
) -> Any:
@@ -980,7 +1008,7 @@ def list_277ca_acks_endpoint(
return {"total": len(rows), "items": items}
@app.get("/api/277ca-acks/{ack_id}")
@app.get("/api/277ca-acks/{ack_id}", dependencies=[Depends(matrix_gate)])
def get_277ca_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted 277CA ACK row with its parsed detail."""
row = store.get_277ca_ack(ack_id)
@@ -1044,7 +1072,7 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
# --------------------------------------------------------------------------- #
@app.get("/api/inbox/lanes")
@app.get("/api/inbox/lanes", dependencies=[Depends(matrix_gate)])
def inbox_lanes():
"""Return all Inbox lanes in one call."""
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
@@ -1062,7 +1090,7 @@ def inbox_lanes():
}
@app.post("/api/inbox/candidates/{remit_id}/match")
@app.post("/api/inbox/candidates/{remit_id}/match", dependencies=[Depends(matrix_gate)])
def inbox_match_candidate(remit_id: str, body: dict):
"""Manually link a remit to a claim."""
claim_id = body.get("claim_id")
@@ -1091,7 +1119,7 @@ def inbox_match_candidate(remit_id: str, body: dict):
return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}
@app.post("/api/inbox/candidates/dismiss")
@app.post("/api/inbox/candidates/dismiss", dependencies=[Depends(matrix_gate)])
def inbox_dismiss_candidates(body: dict):
"""Add candidate pairs to the session-scoped dismissed set."""
pairs = body.get("pairs") or []
@@ -1105,7 +1133,7 @@ def inbox_dismiss_candidates(body: dict):
return {"ok": True, "dismissed_count": len(pairs)}
@app.post("/api/inbox/rejected/resubmit")
@app.post("/api/inbox/rejected/resubmit", dependencies=[Depends(matrix_gate)])
def inbox_resubmit_rejected(
request: Request,
body: dict,
@@ -1199,7 +1227,7 @@ def inbox_resubmit_rejected(
)
@app.get("/api/inbox/export.csv")
@app.get("/api/inbox/export.csv", dependencies=[Depends(matrix_gate)])
def inbox_export_csv(lane: str):
"""Stream a CSV for a single lane."""
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
@@ -1252,7 +1280,7 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int:
return 0
@app.get("/api/batches")
@app.get("/api/batches", dependencies=[Depends(matrix_gate)])
def list_batches(
request: Request,
limit: int = Query(100, ge=1, le=1000),
@@ -1286,7 +1314,7 @@ def list_batches(
}
@app.get("/api/batches/{batch_id}")
@app.get("/api/batches/{batch_id}", dependencies=[Depends(matrix_gate)])
def get_batch(batch_id: str) -> Any:
rec = store.get(batch_id)
if rec is None:
@@ -1297,7 +1325,7 @@ def get_batch(batch_id: str) -> Any:
return json.loads(rec.result.model_dump_json())
@app.get("/api/claims")
@app.get("/api/claims", dependencies=[Depends(matrix_gate)])
def list_claims(
request: Request,
batch_id: str | None = Query(None),
@@ -1408,7 +1436,7 @@ async def _tail_events(
bus.unsubscribe(queue, kinds)
@app.get("/api/claims/stream")
@app.get("/api/claims/stream", dependencies=[Depends(matrix_gate)])
async def claims_stream(
request: Request,
status: str | None = Query(None),
@@ -1455,7 +1483,7 @@ async def claims_stream(
return StreamingResponse(gen(), media_type="application/x-ndjson")
@app.get("/api/claims/{claim_id}")
@app.get("/api/claims/{claim_id}", dependencies=[Depends(matrix_gate)])
def get_claim_detail_endpoint(claim_id: str) -> dict:
"""Return one claim with full drawer context (SP4).
@@ -1480,7 +1508,7 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
return body
@app.get("/api/claims/{claim_id}/serialize-837")
@app.get("/api/claims/{claim_id}/serialize-837", dependencies=[Depends(matrix_gate)])
def serialize_claim_as_837(claim_id: str):
"""Return the claim as a regenerated X12 837P file (SP8).
@@ -1532,7 +1560,7 @@ def serialize_claim_as_837(claim_id: str):
)
@app.get("/api/claims/{claim_id}/line-reconciliation")
@app.get("/api/claims/{claim_id}/line-reconciliation", dependencies=[Depends(matrix_gate)])
def get_claim_line_reconciliation(claim_id: str) -> dict:
"""Per-line reconciliation view for the ClaimDrawer tab.
@@ -1729,7 +1757,7 @@ def _svc_to_dict(svc) -> dict:
}
@app.get("/api/reconciliation/unmatched")
@app.get("/api/reconciliation/unmatched", dependencies=[Depends(matrix_gate)])
def get_reconciliation_unmatched() -> dict:
"""Return unmatched Claims (left) and unmatched Remittances (right).
@@ -1746,7 +1774,7 @@ def get_reconciliation_unmatched() -> dict:
# --------------------------------------------------------------------------- #
@app.get("/api/batch-diff")
@app.get("/api/batch-diff", dependencies=[Depends(matrix_gate)])
def get_batch_diff(
a: str | None = Query(None),
b: str | None = Query(None),
@@ -1793,7 +1821,7 @@ def get_batch_diff(
return diff_batches_to_wire(a_rec, b_rec)
@app.post("/api/reconciliation/match")
@app.post("/api/reconciliation/match", dependencies=[Depends(matrix_gate)])
def post_reconciliation_match(body: dict) -> dict:
"""Manually pair a Claim with a Remittance (operator override).
@@ -1838,7 +1866,7 @@ def post_reconciliation_match(body: dict) -> dict:
)
@app.post("/api/reconciliation/unmatch")
@app.post("/api/reconciliation/unmatch", dependencies=[Depends(matrix_gate)])
def post_reconciliation_unmatch(body: dict) -> dict:
"""Remove the current match for a Claim; reset Claim to submitted.
@@ -1870,7 +1898,7 @@ def post_reconciliation_unmatch(body: dict) -> dict:
)
@app.get("/api/remittances")
@app.get("/api/remittances", dependencies=[Depends(matrix_gate)])
def list_remittances(
request: Request,
batch_id: str | None = Query(None),
@@ -1909,7 +1937,7 @@ def list_remittances(
}
@app.get("/api/remittances/stream")
@app.get("/api/remittances/stream", dependencies=[Depends(matrix_gate)])
async def remittances_stream(
request: Request,
payer: str | None = Query(None),
@@ -1948,7 +1976,7 @@ async def remittances_stream(
return StreamingResponse(gen(), media_type="application/x-ndjson")
@app.get("/api/remittances/{remittance_id}")
@app.get("/api/remittances/{remittance_id}", dependencies=[Depends(matrix_gate)])
def get_remittance(remittance_id: str) -> dict:
"""Return one remittance with its labeled CAS ``adjustments`` array.
@@ -1965,7 +1993,7 @@ def get_remittance(remittance_id: str) -> dict:
return body
@app.get("/api/providers")
@app.get("/api/providers", dependencies=[Depends(matrix_gate)])
def list_providers(
request: Request,
npi: str | None = Query(None),
@@ -1995,7 +2023,7 @@ def list_providers(
}
@app.get("/api/activity")
@app.get("/api/activity", dependencies=[Depends(matrix_gate)])
def list_activity(
request: Request,
kind: str | None = Query(None),
@@ -2022,7 +2050,36 @@ def list_activity(
}
@app.get("/api/activity/stream")
# --------------------------------------------------------------------------- #
# Dashboard summary (SP10) #
# --------------------------------------------------------------------------- #
@app.get("/api/dashboard/summary", dependencies=[Depends(matrix_gate)])
def dashboard_summary(
months: int = Query(6, ge=1, le=24),
top_providers: int = Query(4, ge=1, le=20),
denials: int = Query(5, ge=0, le=50),
) -> Any:
"""Aggregated KPIs + 6-month sparkline + top providers + recent denials.
One round-trip replaces the four hook calls the Dashboard SPA
previously made (claims/providers/activity/denials). Aggregation is
server-side so totals stay accurate past the 1000-row pagination
cap on ``/api/claims``.
Bucketing is UTC — the underlying ``submissionDate`` is the batch's
parsed_at, stored as UTC ISO. Time-zone-stable across deploys; may
differ from the previous local-time bucketing for non-UTC viewers.
"""
return store.get_dashboard_summary(
months=months,
top_providers=top_providers,
denials=denials,
)
@app.get("/api/activity/stream", dependencies=[Depends(matrix_gate)])
async def activity_stream(
request: Request,
kind: str | None = Query(None),
@@ -2086,7 +2143,7 @@ def _ack_to_ui(row) -> dict:
}
@app.get("/api/acks")
@app.get("/api/acks", dependencies=[Depends(matrix_gate)])
def list_acks_endpoint(
request: Request,
limit: int = Query(100, ge=1, le=1000),
@@ -2110,7 +2167,7 @@ def list_acks_endpoint(
}
@app.get("/api/acks/{ack_id}")
@app.get("/api/acks/{ack_id}", dependencies=[Depends(matrix_gate)])
def get_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted ACK row with its parsed detail.
@@ -2250,7 +2307,7 @@ def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
return result, service_type_code
@app.post("/api/eligibility/request")
@app.post("/api/eligibility/request", dependencies=[Depends(matrix_gate)])
def post_eligibility_request(body: dict) -> Any:
"""Build a 270 eligibility inquiry from a small JSON body.
@@ -2276,7 +2333,7 @@ def post_eligibility_request(body: dict) -> Any:
}
@app.post("/api/eligibility/parse-271")
@app.post("/api/eligibility/parse-271", dependencies=[Depends(matrix_gate)])
async def post_eligibility_parse_271(
file: UploadFile = File(...),
) -> Any:
@@ -2336,7 +2393,7 @@ async def post_eligibility_parse_271(
# ---------------------------------------------------------------------------
@app.get("/api/clearhouse")
@app.get("/api/clearhouse", dependencies=[Depends(matrix_gate)])
def get_clearhouse():
"""Return the singleton clearhouse config (dzinesco's identity, SFTP block, filename block)."""
ch = store.get_clearhouse()
@@ -2345,8 +2402,8 @@ def get_clearhouse():
return json.loads(ch.model_dump_json())
@app.post("/api/clearhouse/submit")
def submit_to_clearhouse(body: dict):
@app.post("/api/clearhouse/submit", dependencies=[Depends(matrix_gate)])
def submit_to_clearhouse(request: Request, body: dict):
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}``
@@ -2401,6 +2458,7 @@ def submit_to_clearhouse(body: dict):
"stub": ch.sftp_block.stub,
},
actor="clearhouse-submit",
user_id=_actor_user_id(request),
))
audit_s.commit()
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
@@ -2445,7 +2503,7 @@ def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
)
@app.get("/api/config/providers")
@app.get("/api/config/providers", dependencies=[Depends(matrix_gate)])
def list_configured_providers(is_active: bool | None = Query(default=True)):
"""List the configured provider rows (3 NPIs for SP9)."""
return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]
@@ -2456,7 +2514,7 @@ def list_configured_providers(is_active: bool | None = Query(default=True)):
# --------------------------------------------------------------------------- #
@app.get("/api/admin/audit-log")
@app.get("/api/admin/audit-log", dependencies=[Depends(matrix_gate)])
def list_audit_log_endpoint(
entity_type: str | None = Query(default=None),
entity_id: str | None = Query(default=None),
@@ -2498,7 +2556,7 @@ def list_audit_log_endpoint(
}
@app.get("/api/admin/audit-log/verify")
@app.get("/api/admin/audit-log/verify", dependencies=[Depends(matrix_gate)])
def verify_audit_log_endpoint() -> Any:
"""Walk the audit-log chain and verify every row's hash.
@@ -2517,7 +2575,7 @@ def verify_audit_log_endpoint() -> Any:
}
@app.get("/api/config/providers/{npi}")
@app.get("/api/config/providers/{npi}", dependencies=[Depends(matrix_gate)])
def get_configured_provider(npi: str):
p = store.get_provider(npi)
if p is None:
@@ -2525,12 +2583,12 @@ def get_configured_provider(npi: str):
return json.loads(p.model_dump_json())
@app.get("/api/config/payers")
@app.get("/api/config/payers", dependencies=[Depends(matrix_gate)])
def list_configured_payers(is_active: bool | None = Query(default=True)):
return [json.loads(p.model_dump_json()) for p in store.list_payers(is_active=is_active)]
@app.get("/api/config/payers/{payer_id}/configs")
@app.get("/api/config/payers/{payer_id}/configs", dependencies=[Depends(matrix_gate)])
def list_payer_configs(payer_id: str):
"""List all (transaction_type, config_json) blocks for a payer."""
from cyclone import payers as payer_loader
@@ -2547,7 +2605,7 @@ def list_payer_configs(payer_id: str):
return configs
@app.post("/api/admin/reload-config")
@app.post("/api/admin/reload-config", dependencies=[Depends(matrix_gate)])
def reload_config():
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
from cyclone import payers as payer_loader
@@ -2558,4 +2616,18 @@ def reload_config():
return {"ok": True, "loaded": len(configs), "errors": []}
# --------------------------------------------------------------------------- #
# Auth routers (login/logout/me + admin user management) #
# --------------------------------------------------------------------------- #
from cyclone.auth.routes import router as auth_router
from cyclone.auth.admin import router as admin_users_router
app.include_router(auth_router)
app.include_router(admin_users_router)
__all__ = ["app"]
+8
View File
@@ -103,6 +103,12 @@ class AuditEvent:
computed hash. Payload must be JSON-serializable; the audit_log
module handles the encoding so callers don't need to think about
canonical form.
``user_id`` is the authenticated actor for this event, when known
(e.g. a parse-999 call made by user 7). It's stored on the row but
is NOT part of the hash chain — the chain hashes only the fields
that existed pre-SP-auth so verify_chain stays compatible with
pre-auth rows.
"""
event_type: str
@@ -111,6 +117,7 @@ class AuditEvent:
payload: dict[str, Any] = field(default_factory=dict)
actor: str = "system"
created_at: datetime | None = None
user_id: int | None = None
def append_event(
@@ -155,6 +162,7 @@ def append_event(
created_at=created_at,
prev_hash=prev_hash,
hash=GENESIS_PREV_HASH, # placeholder; updated below
user_id=event.user_id,
)
session.add(row)
session.flush() # populate row.id
+1
View File
@@ -0,0 +1 @@
"""Auth module — users, sessions, permissions, routes, admin, rate_limit."""
+95
View File
@@ -0,0 +1,95 @@
"""Admin-only user management: GET/POST/PATCH/DELETE /api/admin/users."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from cyclone.auth import users
from cyclone.auth.deps import get_current_user
from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal, User
router = APIRouter(prefix="/api/admin/users", tags=["admin"])
def _require_admin(user: dict = Depends(get_current_user)) -> dict:
if user.get("role") != Role.ADMIN.value:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
def _validate_role(role: str) -> None:
valid = {Role.ADMIN.value, Role.USER.value, Role.VIEWER.value}
if role not in valid:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"role must be one of {sorted(valid)}",
)
@router.get("")
def list_users(_admin=Depends(_require_admin)):
with SessionLocal()() as db:
all_users = db.query(User).all()
return [users.to_public(u) for u in all_users]
@router.post("", status_code=status.HTTP_201_CREATED)
def create_user(body: dict, _admin=Depends(_require_admin)):
username = (body.get("username") or "").strip()
password = body.get("password") or ""
role = body.get("role") or ""
if not username or len(username) < 3:
raise HTTPException(status_code=422, detail="username must be at least 3 chars")
if len(password) < 12:
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
_validate_role(role)
with SessionLocal()() as db:
if users.get_by_username(db, username) is not None:
raise HTTPException(status_code=409, detail="username already exists")
u = users.create(db, username=username, password=password, role=role)
return users.to_public(u)
@router.patch("/{user_id}")
def patch_user(user_id: int, body: dict, admin=Depends(_require_admin)):
me = admin
if me.get("id") == user_id and body.get("role") and body["role"] != Role.ADMIN.value:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="cannot_demote_self",
)
with SessionLocal()() as db:
if body.get("role") is not None:
_validate_role(body["role"])
users.update_role(db, user_id, body["role"])
if body.get("password") is not None:
if len(body["password"]) < 12:
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
users.update_password(db, user_id, body["password"])
if body.get("disabled") is True:
users.disable(db, user_id)
u = users.get(db, user_id)
if u is None:
raise HTTPException(status_code=404, detail="user not found")
return users.to_public(u)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, admin=Depends(_require_admin)):
me = admin
if me.get("id") == user_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="cannot_delete_self",
)
with SessionLocal()() as db:
u = users.get(db, user_id)
if u is None:
raise HTTPException(status_code=404, detail="user not found")
users.disable(db, user_id)
return None
+70
View File
@@ -0,0 +1,70 @@
"""First-admin bootstrap: create the initial admin from env vars if no users exist.
Called from ``python -m cyclone`` before either ``cli.main()`` or
``uvicorn`` so users exist by the time the API serves requests.
Precedence:
1. ``CYCLONE_AUTH_DISABLED=1`` — dev escape hatch. Flip the
``cyclone.auth.deps.AUTH_DISABLED`` flag so the API returns a
synthetic admin user without checking credentials. Never raises.
2. Users table non-empty — no-op.
3. ``CYCLONE_ADMIN_USERNAME`` + ``CYCLONE_ADMIN_PASSWORD`` env vars set
(password >= 12 chars) — create the admin and print confirmation.
4. Otherwise — raise ``RuntimeError`` with a remediation hint that
points operators at ``python -m cyclone users create``.
"""
from __future__ import annotations
import os
from sqlalchemy import select
from cyclone.auth import users
from cyclone.auth.deps import AUTH_DISABLED
from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal, User
def run() -> None:
"""Bootstrap the first admin user, or no-op.
See module docstring for behavior. Idempotent: safe to call on
every startup — it short-circuits as soon as the users table is
non-empty.
"""
if os.environ.get("CYCLONE_AUTH_DISABLED") == "1":
# Dev escape hatch — skip bootstrap entirely and tell the API
# to also short-circuit auth checks.
import cyclone.auth.deps as _deps
_deps.AUTH_DISABLED = True
return
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
with SessionLocal()() as db:
existing = db.execute(select(User)).scalars().first()
if existing is not None:
return # users exist — nothing to bootstrap
if not username or not password:
raise RuntimeError(
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
"CYCLONE_ADMIN_PASSWORD env vars (min 12 chars), or run "
"`python -m cyclone users create <username> --role admin`."
)
if len(password) < 12:
raise RuntimeError(
"CYCLONE_ADMIN_PASSWORD must be at least 12 characters."
)
users.create(
db,
username=username,
password=password,
role=Role.ADMIN.value,
)
print(f"[cyclone] bootstrap admin user '{username}' created")
+183
View File
@@ -0,0 +1,183 @@
"""CLI subcommand: ``python -m cyclone users ...``.
Click-based to match the existing parse-837 / parse-835 convention in
``cyclone.cli``. Provides operator-side user management without going
through the admin HTTP API.
Subcommands
-----------
- ``users create USERNAME --role {admin,user,viewer} [--password PW]``
Create a user. If ``--password`` is omitted, prompts (with
confirmation) on the controlling terminal.
- ``users list`` — tab-separated ``id / username / role / state``.
- ``users disable USERNAME`` — set ``disabled_at`` to now.
- ``users reset-password USERNAME [--password PW]`` — replace the hash.
- ``users set-role USERNAME --role {admin,user,viewer}`` — change role.
Exit codes
----------
* 0 — success
* 1 — validation error (unknown username, duplicate username)
* 2 — usage error (missing arg, bad role, short password, unknown
subcommand). Click itself uses 2 for usage errors so the conventional
shell tools (``set -e``, etc.) recognize them.
Passwords shorter than 12 chars are rejected everywhere they appear.
"""
from __future__ import annotations
import getpass
import sys
import click
from cyclone.auth import users
from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal
ROLE_CHOICES = [Role.ADMIN.value, Role.USER.value, Role.VIEWER.value]
MIN_PASSWORD_LEN = 12
def _prompt_password(label: str) -> str:
pw = getpass.getpass(f"{label}: ")
if not pw:
click.echo("Password required.", err=True)
sys.exit(2)
return pw
def _validate_password(pw: str) -> None:
if len(pw) < MIN_PASSWORD_LEN:
click.echo(
f"Password must be at least {MIN_PASSWORD_LEN} characters.",
err=True,
)
sys.exit(2)
# --------------------------------------------------------------------------- #
# Group
# --------------------------------------------------------------------------- #
@click.group(name="users")
def users_cli() -> None:
"""Manage Cyclone users from the command line."""
# --------------------------------------------------------------------------- #
# create
# --------------------------------------------------------------------------- #
@users_cli.command("create")
@click.argument("username")
@click.option(
"--role",
required=True,
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
help="Role to grant the new user.",
)
@click.option(
"--password",
default=None,
help=f"Password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
)
def create_user(username: str, role: str, password: str | None) -> None:
"""Create a new user with the given USERNAME and ROLE."""
pw = password or _prompt_password("Password")
_validate_password(pw)
with SessionLocal()() as db:
if users.get_by_username(db, username) is not None:
click.echo(f"User '{username}' already exists.", err=True)
sys.exit(1)
u = users.create(db, username=username, password=pw, role=role)
click.echo(f"Created user '{u.username}' with role '{u.role}'.")
# --------------------------------------------------------------------------- #
# list
# --------------------------------------------------------------------------- #
@users_cli.command("list")
def list_users() -> None:
"""List all users as id / username / role / state."""
from cyclone.db import User
with SessionLocal()() as db:
for u in db.query(User).order_by(User.id.asc()).all():
state = "disabled" if u.disabled_at else "active"
click.echo(f"{u.id}\t{u.username}\t{u.role}\t{state}")
# --------------------------------------------------------------------------- #
# disable
# --------------------------------------------------------------------------- #
@users_cli.command("disable")
@click.argument("username")
def disable_user(username: str) -> None:
"""Disable USERNAME (sets disabled_at to now)."""
with SessionLocal()() as db:
u = users.get_by_username(db, username)
if u is None:
click.echo(f"No such user: {username}", err=True)
sys.exit(1)
users.disable(db, u.id)
click.echo(f"Disabled '{username}'.")
# --------------------------------------------------------------------------- #
# reset-password
# --------------------------------------------------------------------------- #
@users_cli.command("reset-password")
@click.argument("username")
@click.option(
"--password",
default=None,
help=f"New password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
)
def reset_password(username: str, password: str | None) -> None:
"""Replace USERNAME's password."""
pw = password or _prompt_password("New password")
_validate_password(pw)
with SessionLocal()() as db:
u = users.get_by_username(db, username)
if u is None:
click.echo(f"No such user: {username}", err=True)
sys.exit(1)
users.update_password(db, u.id, pw)
click.echo(f"Password reset for '{username}'.")
# --------------------------------------------------------------------------- #
# set-role
# --------------------------------------------------------------------------- #
@users_cli.command("set-role")
@click.argument("username")
@click.option(
"--role",
required=True,
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
help="Role to grant.",
)
def set_role(username: str, role: str) -> None:
"""Change USERNAME's role."""
with SessionLocal()() as db:
u = users.get_by_username(db, username)
if u is None:
click.echo(f"No such user: {username}", err=True)
sys.exit(1)
users.update_role(db, u.id, role)
click.echo(f"Role for '{username}' set to '{role}'.")
+141
View File
@@ -0,0 +1,141 @@
"""FastAPI dependencies for auth."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session as DbSession
from cyclone.auth import sessions, users
from cyclone.auth.permissions import Role, allowed_roles
from cyclone.db import SessionLocal
def _db():
db = SessionLocal()()
try:
yield db
finally:
db.close()
DbSessionDep = Annotated[DbSession, Depends(_db)]
AUTH_DISABLED = False
async def get_current_user(
request: Request,
db: DbSessionDep,
) -> dict:
"""Return the public User shape. Raises 401 if session is missing/expired.
When AUTH_DISABLED is True (dev escape hatch), returns a synthetic admin
user without checking credentials.
"""
if AUTH_DISABLED:
return {
"id": 0,
"username": "dev",
"role": Role.ADMIN.value,
"createdAt": None,
"disabledAt": None,
}
sid = request.cookies.get("cyclone_session")
if not sid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="session_expired",
)
sess = sessions.get_valid(db, sid)
if sess is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="session_expired",
)
user = users.get(db, sess.user_id)
if user is None or user.disabled_at is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="account_disabled",
)
# Sliding expiry: refresh both DB and cookie.
sessions.touch(db, sid)
request.state.user = user
request.state.session_id = sid
return users.to_public(user)
def require_role(*allowed: Role):
"""Dependency factory: gate the endpoint to specific roles.
Falls back to PERMISSIONS matrix lookup if no explicit roles given.
"""
async def _dep(
request: Request,
user: dict = Depends(get_current_user),
) -> dict:
user_role = user.get("role")
if allowed:
if user_role not in {r.value for r in allowed}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
# Otherwise consult the matrix.
method = request.method
path = request.url.path
roles = allowed_roles(method, path)
if roles is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
if user_role not in {r.value for r in roles}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
return _dep
async def matrix_gate(
request: Request,
user: dict = Depends(get_current_user),
) -> dict:
"""App-wide gate: requires auth, then enforces the PERMISSIONS matrix.
Behavior:
* AUTH_DISABLED short-circuits (synthetic admin, no role check).
* No session cookie → 401 from get_current_user.
* Endpoint not in the matrix → 403 (fail-closed).
* User role not allowed for (method, path) → 403.
* Empty allowed-roles set (e.g. /api/healthz) → public, no role check.
Used as the single ``dependencies=[Depends(matrix_gate)]`` on every
authenticated route in ``cyclone.api``. Centralizing the gate here
means the matrix is the source of truth — no need to wire per-route
``require_role(...)`` calls.
"""
if AUTH_DISABLED:
return user
method = request.method
path = request.url.path
roles = allowed_roles(method, path)
if roles is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
user_role = user.get("role")
if user_role not in {r.value for r in roles}:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="forbidden",
)
return user
+73
View File
@@ -0,0 +1,73 @@
"""Role enum + PERMISSIONS matrix."""
from __future__ import annotations
from enum import Enum
class Role(str, Enum):
ADMIN = "admin"
USER = "user"
VIEWER = "viewer"
ALL_ROLES = {Role.ADMIN, Role.USER, Role.VIEWER}
WRITE_ROLES = {Role.ADMIN, Role.USER}
ADMIN_ONLY = {Role.ADMIN}
# (method, path-prefix) → allowed roles.
# Endpoints not in this matrix default to DENY (fail-closed).
PERMISSIONS: dict[tuple[str, str], set[Role]] = {
# Public paths.
("GET", "/api/healthz"): set(),
("POST", "/api/auth/login"): set(),
# Auth surface.
("POST", "/api/auth/logout"): ALL_ROLES,
("GET", "/api/auth/me"): ALL_ROLES,
# Admin-only user management.
("GET", "/api/admin/users"): ADMIN_ONLY,
("POST", "/api/admin/users"): ADMIN_ONLY,
("PATCH", "/api/admin/users"): ADMIN_ONLY,
("DELETE", "/api/admin/users"): ADMIN_ONLY,
# Read endpoints (all authenticated roles).
("GET", "/api/claims"): ALL_ROLES,
("GET", "/api/remittances"): ALL_ROLES,
("GET", "/api/providers"): ALL_ROLES,
("GET", "/api/batches"): ALL_ROLES,
("GET", "/api/dashboard/summary"): ALL_ROLES,
("GET", "/api/activity"): ALL_ROLES,
("GET", "/api/inbox/lanes"): ALL_ROLES,
("GET", "/api/reconcile"): ALL_ROLES,
("GET", "/api/audit-log"): ADMIN_ONLY,
# Write endpoints (admin + user, no viewer).
("POST", "/api/parse-837"): WRITE_ROLES,
("POST", "/api/parse-835"): WRITE_ROLES,
("POST", "/api/inbox"): WRITE_ROLES,
("POST", "/api/reconcile"): WRITE_ROLES,
("POST", "/api/resubmit"): WRITE_ROLES,
("POST", "/api/acks"): WRITE_ROLES,
# CSV export — read-only.
("GET", "/api/export.csv"): ALL_ROLES,
}
def allowed_roles(method: str, path: str) -> set[Role] | None:
"""Return the set of roles allowed to call (method, path), or None if denied.
Uses longest-prefix match on path; falls back to DENY (None) if no entry matches.
"""
candidates = [
(len(prefix), roles)
for (m, prefix), roles in PERMISSIONS.items()
if m == method and (path == prefix or path.startswith(prefix.rstrip("/") + "/"))
]
if not candidates:
return None
candidates.sort(key=lambda x: -x[0])
return candidates[0][1]
+34
View File
@@ -0,0 +1,34 @@
"""Per-username login rate limiter (in-memory, per-process)."""
from __future__ import annotations
import time
from threading import Lock
WINDOW_SECONDS = 300
MAX_FAILS = 5
_FAILS: dict[str, list[float]] = {}
_LOCK = Lock()
def check(username: str) -> int:
"""Return retry-after seconds, or 0 if allowed."""
now = time.monotonic()
with _LOCK:
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
_FAILS[username] = fails
if len(fails) >= MAX_FAILS:
return int(WINDOW_SECONDS - (now - fails[0]))
return 0
def record_failure(username: str) -> None:
now = time.monotonic()
with _LOCK:
_FAILS.setdefault(username, []).append(now)
def reset(username: str) -> None:
with _LOCK:
_FAILS.pop(username, None)
+97
View File
@@ -0,0 +1,97 @@
"""/api/auth/login, /api/auth/logout, /api/auth/me."""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from cyclone.auth import rate_limit, sessions, users
from cyclone.auth.deps import get_current_user
from cyclone.db import SessionLocal
router = APIRouter(prefix="/api/auth", tags=["auth"])
COOKIE_NAME = "cyclone_session"
COOKIE_MAX_AGE = 86400 # 24h
def _is_https(request: Request) -> bool:
if request.url.scheme == "https":
return True
return os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"
def _set_cookie(response: Response, sid: str, request: Request) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=sid,
max_age=COOKIE_MAX_AGE,
path="/api",
httponly=True,
samesite="lax",
secure=_is_https(request),
)
def _clear_cookie(response: Response) -> None:
response.delete_cookie(key=COOKIE_NAME, path="/api")
@router.post("/login")
def login(body: dict, request: Request, response: Response):
username = (body.get("username") or "").strip()
password = body.get("password") or ""
if not username or not password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="username and password are required",
)
retry_after = rate_limit.check(username)
if retry_after > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="rate_limited",
headers={"Retry-After": str(retry_after)},
)
with SessionLocal()() as db:
user = users.get_by_username(db, username)
if user is None or not users.verify_password(password, user.password_hash):
rate_limit.record_failure(username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid_credentials",
)
if user.disabled_at is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="account_disabled",
)
sid, _ = sessions.create(db, user_id=user.id)
rate_limit.reset(username)
public = users.to_public(user)
_set_cookie(response, sid, request)
return public
@router.post("/logout")
def logout(
request: Request,
response: Response,
_user: dict = Depends(get_current_user),
):
sid = request.cookies.get(COOKIE_NAME)
if sid:
with SessionLocal()() as db:
sessions.delete(db, sid)
_clear_cookie(response)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/me")
def me(user: dict = Depends(get_current_user)):
return user
+60
View File
@@ -0,0 +1,60 @@
"""Session create/validate/expire/touch."""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from cyclone.db import Session
SESSION_LIFETIME = timedelta(hours=24)
def create(db, *, user_id: int) -> tuple[str, Session]:
sid = secrets.token_urlsafe(32)
now = datetime.now(timezone.utc)
expires_at = now + SESSION_LIFETIME
sess = Session(
id=sid,
user_id=user_id,
expires_at=expires_at,
created_at=now,
)
db.add(sess)
db.commit()
db.refresh(sess)
# SQLite strips tzinfo on roundtrip; restore it so callers don't have to.
sess.expires_at = expires_at
return sid, sess
def get_valid(db, sid: str) -> Session | None:
sess = db.execute(
select(Session).where(Session.id == sid)
).scalar_one_or_none()
if sess is None:
return None
# SQLite drops tzinfo on roundtrip; normalize to UTC before comparing.
if sess.expires_at.tzinfo is None:
sess.expires_at = sess.expires_at.replace(tzinfo=timezone.utc)
if sess.expires_at <= datetime.now(timezone.utc):
return None
return sess
def delete(db, sid: str) -> None:
sess = db.get(Session, sid)
if sess is None:
return
db.delete(sess)
db.commit()
def touch(db, sid: str) -> None:
sess = db.get(Session, sid)
if sess is None:
return
sess.expires_at = datetime.now(timezone.utc) + SESSION_LIFETIME
db.commit()
+79
View File
@@ -0,0 +1,79 @@
"""User CRUD + bcrypt password hashing."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from passlib.hash import bcrypt
from sqlalchemy import select
from cyclone.db import User
def hash_password(plaintext: str) -> str:
return bcrypt.hash(plaintext)
def verify_password(plaintext: str, hashed: str) -> bool:
try:
return bcrypt.verify(plaintext, hashed)
except (ValueError, TypeError):
return False
def create(db, *, username: str, password: str, role: str) -> User:
user = User(
username=username,
password_hash=hash_password(password),
role=role,
created_at=datetime.now(timezone.utc),
)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_by_username(db, username: str) -> User | None:
return db.execute(
select(User).where(User.username == username)
).scalar_one_or_none()
def get(db, user_id: int) -> User | None:
return db.get(User, user_id)
def disable(db, user_id: int) -> None:
user = db.get(User, user_id)
if user is None:
return
user.disabled_at = datetime.now(timezone.utc)
db.commit()
def update_role(db, user_id: int, role: str) -> None:
user = db.get(User, user_id)
if user is None:
return
user.role = role
db.commit()
def update_password(db, user_id: int, new_password: str) -> None:
user = db.get(User, user_id)
if user is None:
return
user.password_hash = hash_password(new_password)
db.commit()
def to_public(user: User) -> dict[str, Any]:
return {
"id": user.id,
"username": user.username,
"role": user.role,
"createdAt": user.created_at.isoformat() if user.created_at else None,
"disabledAt": user.disabled_at.isoformat() if user.disabled_at else None,
}
+6
View File
@@ -45,6 +45,12 @@ def main() -> None:
"""Cyclone EDI suite — X12 parser."""
# Register the auth users subgroup. Imported here (not at module top) to
# avoid pulling passlib / bcrypt at CLI parse-only import time.
from cyclone.auth.cli import users_cli # noqa: E402
main.add_command(users_cli)
@main.command("parse-837")
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
+30
View File
@@ -30,6 +30,7 @@ from sqlalchemy import (
Numeric,
String,
Text,
func,
text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
@@ -620,6 +621,11 @@ class AuditLog(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
hash: Mapped[str] = mapped_column(String(64), nullable=False)
# SP-auth: which authenticated user performed this action. Nullable
# so existing (pre-auth) rows and system-initiated events stay valid.
# NOT part of the hash chain — verify_chain must continue to work on
# legacy rows that pre-date this column.
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
__table_args__ = (
Index("idx_audit_log_entity", "entity_type", "entity_id"),
@@ -705,3 +711,27 @@ class ClearhouseORM(Base):
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
class User(Base):
"""Auth user (admin / user / viewer)."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Session(Base):
"""Server-side auth session (HttpOnly cookie holds the id)."""
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -0,0 +1,31 @@
-- version: 10
-- Auth (SP-auth): users + sessions tables.
--
-- `users` holds the local credential store: bcrypt-hashed password,
-- role enum ('admin' | 'user' | 'viewer'), and a soft-delete column
-- (disabled_at) so admins can revoke access without losing history.
--
-- `sessions` holds the server-side session rows; the browser only
-- carries an opaque token cookie (cyclone_session) that points here.
-- expires_at index lets us cheaply reap stale sessions.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
disabled_at TEXT
);
CREATE INDEX idx_users_username ON users(username);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
@@ -0,0 +1,10 @@
-- version: 11
-- Auth (SP-auth): record the acting user_id on every audit_log entry.
--
-- Backwards-compatible: existing rows get NULL user_id (they were
-- written by the pre-auth `system` actor). Going forward, the FastAPI
-- get_current_user dependency injects the id into every audit log call.
ALTER TABLE audit_log ADD COLUMN user_id INTEGER;
CREATE INDEX idx_audit_log_user_id ON audit_log(user_id);
+143
View File
@@ -1688,6 +1688,149 @@ class CycloneStore:
for r in rows
]
def get_dashboard_summary(
self,
*,
months: int = 6,
top_providers: int = 4,
denials: int = 5,
) -> dict[str, Any]:
"""Aggregate the data the Dashboard needs in one round-trip.
Returns KPIs (claim/billed/received/denial totals), monthly
buckets sized to the trailing ``months`` window (oldest →
newest, UTC), the top N providers by claim count, and the most
recent denied claims. Mirrors the shape consumed by the
Dashboard SPA — see ``get_dashboard_summary``'s frontend hook.
Performance: reuses the in-memory ``iter_claims`` and
``distinct_providers`` paths, both of which already scan the
full table. At current scale that's fine; a SQL ``GROUP BY``
aggregation is the next step if claim volume grows.
"""
from collections import defaultdict
# All UI-shaped claim dicts. Sorted by submissionDate ascending
# so the running-AR walk below is in chronological order.
all_claims = self.iter_claims(
sort="submissionDate", order="asc", limit=1_000_000, offset=0,
)
now = datetime.now(timezone.utc)
# Build the trailing-month window: [now - months, now), bucketed
# in UTC. The frontend sparkline wants chronological order
# (oldest first), which is what `range(months)` walking backwards
# from the current month produces.
bucket_keys: list[tuple[int, int]] = []
bucket_labels: dict[tuple[int, int], str] = {}
for i in range(months - 1, -1, -1):
y = now.year
m = now.month - i
while m <= 0:
m += 12
y -= 1
key = (y, m)
bucket_keys.append(key)
# Use the first day of the month to render a short label.
bucket_labels[key] = datetime(y, m, 1, tzinfo=timezone.utc).strftime("%b")
# Aggregate per month.
per_month: dict[tuple[int, int], dict[str, float]] = {
k: {"count": 0, "billed": 0.0, "received": 0.0, "denied": 0}
for k in bucket_keys
}
claim_count = len(all_claims)
billed_total = 0.0
received_total = 0.0
denied_count = 0
pending_count = 0
recent_denials: list[dict] = []
for c in all_claims:
billed = float(c.get("billedAmount") or 0)
received = float(c.get("receivedAmount") or 0)
status = (c.get("status") or "").lower()
billed_total += billed
received_total += received
if status == "denied":
denied_count += 1
# Collect newest-first for the recent-denials list. Slice
# after the sort below.
recent_denials.append(c)
if status in ("submitted", "pending"):
pending_count += 1
sub = c.get("submissionDate") or ""
try:
dt = datetime.fromisoformat(sub.replace("Z", "+00:00"))
except (TypeError, ValueError):
continue
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
key = (dt.astimezone(timezone.utc).year, dt.astimezone(timezone.utc).month)
if key in per_month:
bucket = per_month[key]
bucket["count"] += 1
bucket["billed"] += billed
bucket["received"] += received
if status == "denied":
bucket["denied"] += 1
# Build the monthly list with running AR (cumulative billed -
# cumulative received, clamped at 0 — matches the existing
# Dashboard math). Keys are camelCase to match the rest of the
# iter_claims / distinct_providers surface (see /api/claims
# payload shape).
running_ar = 0.0
monthly: list[dict[str, Any]] = []
for key in bucket_keys:
agg = per_month[key]
count = int(agg["count"])
billed = float(agg["billed"])
received = float(agg["received"])
denied = int(agg["denied"])
running_ar = max(0.0, running_ar + billed - received)
monthly.append({
"month": f"{key[0]:04d}-{key[1]:02d}",
"label": bucket_labels[key],
"count": count,
"billed": billed,
"received": received,
"denied": denied,
"ar": running_ar,
"denialRate": (denied / count * 100.0) if count else 0.0,
})
# Recent denials: newest first, capped.
recent_denials.sort(
key=lambda c: c.get("submissionDate") or "",
reverse=True,
)
recent_denials = recent_denials[:denials]
# Top providers by claim count, descending.
all_providers = self.distinct_providers()
all_providers.sort(key=lambda p: p.get("claimCount") or 0, reverse=True)
top = all_providers[:top_providers]
return {
"kpis": {
"claimCount": claim_count,
"billedTotal": billed_total,
"receivedTotal": received_total,
"outstandingAr": billed_total - received_total,
"deniedCount": denied_count,
"pendingCount": pending_count,
"denialRate": (denied_count / claim_count * 100.0) if claim_count else 0.0,
},
"monthly": monthly,
"topProviders": top,
"recentDenials": recent_denials,
"providerCount": len(all_providers),
"asOf": now.isoformat().replace("+00:00", "Z"),
}
# -- 999 ACKs (SP3 P3 T13) -------------------------------------------
def add_ack(
+26 -1
View File
@@ -10,26 +10,51 @@ requires the engine to be initialized before any DB access, whereas the
old in-memory store did not. Adding the init here keeps existing test
modules (test_api.py, test_api_835.py, test_api_gets.py,
test_api_parse_persists.py) working unchanged.
Auth posture: ``CYCLONE_AUTH_DISABLED`` defaults to ``"1"`` here so the
existing test suite (700+ tests that don't log in) continues to pass
unmodified. Tests in ``test_auth_*`` modules explicitly re-enable auth
so they exercise the real ``get_current_user`` / ``matrix_gate`` paths.
"""
from __future__ import annotations
import os
# Must be set before ``cyclone.api`` is imported so ``get_current_user``
# / ``matrix_gate`` see the env var. The module-level ``AUTH_DISABLED``
# flag in ``cyclone.auth.deps`` is flipped per-test below.
os.environ.setdefault("CYCLONE_AUTH_DISABLED", "1")
import pytest
@pytest.fixture(autouse=True)
def _auto_init_db(tmp_path, monkeypatch):
def _auto_init_db(tmp_path, monkeypatch, request):
"""Point CYCLONE_DB_URL at a per-test SQLite file and init the schema.
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
does not invoke the FastAPI lifespan handler unless used as a context
manager. The bus is reset between tests so subscribers don't leak.
Auth posture is set per-test from the test module's name: legacy
tests (any module not starting with ``test_auth``) run with
``AUTH_DISABLED=True`` so they hit the API without logging in;
``test_auth_*`` modules run with ``AUTH_DISABLED=False`` so they
exercise the real ``get_current_user`` / ``matrix_gate`` paths.
"""
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
from cyclone import db
from cyclone.api import app
from cyclone.auth import deps as _auth_deps
from cyclone.pubsub import EventBus
test_module = request.node.module.__name__
if test_module.startswith("test_auth"):
_auth_deps.AUTH_DISABLED = False
else:
_auth_deps.AUTH_DISABLED = True
db._reset_for_tests()
db.init_db()
app.state.event_bus = EventBus()
+230
View File
@@ -0,0 +1,230 @@
"""Tests for ``GET /api/dashboard/summary`` (SP10 — Dashboard aggregates)."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.store import store as global_store
FIXTURE_837 = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
@pytest.fixture(autouse=True)
def clear_store():
with global_store._lock:
global_store._batches.clear()
yield
with global_store._lock:
global_store._batches.clear()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture
def seeded_store():
"""One parsed 837P fixture batch."""
text = FIXTURE_837.read_text()
client = TestClient(app)
client.post(
"/api/parse-837",
files={"file": ("x.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
return client
JSON = {"Accept": "application/json"}
# --------------------------------------------------------------------------- #
# Empty state
# --------------------------------------------------------------------------- #
def test_dashboard_summary_empty_when_no_parses(client: TestClient):
resp = client.get("/api/dashboard/summary", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert body["kpis"] == {
"claimCount": 0,
"billedTotal": 0.0,
"receivedTotal": 0.0,
"outstandingAr": 0.0,
"deniedCount": 0,
"pendingCount": 0,
"denialRate": 0.0,
}
# Default window is 6 months; with no claims every bucket is empty
# but still present so the sparkline renders zeros consistently.
assert len(body["monthly"]) == 6
for bucket in body["monthly"]:
assert bucket["count"] == 0
assert bucket["billed"] == 0.0
assert bucket["received"] == 0.0
assert bucket["denied"] == 0
assert bucket["ar"] == 0.0
assert bucket["denialRate"] == 0.0
assert body["topProviders"] == []
assert body["recentDenials"] == []
assert body["providerCount"] == 0
# asOf is a UTC ISO string.
assert body["asOf"].endswith("Z")
# --------------------------------------------------------------------------- #
# Populated state — uses the 2-claim co_medicaid fixture
# --------------------------------------------------------------------------- #
def test_dashboard_summary_after_one_parse(seeded_store):
resp = seeded_store.get("/api/dashboard/summary", headers=JSON)
assert resp.status_code == 200
body = resp.json()
# The fixture has 2 claims, both submitted, billed $85.40 + $155.76.
kpis = body["kpis"]
assert kpis["claimCount"] == 2
assert kpis["billedTotal"] == pytest.approx(85.40 + 155.76)
assert kpis["receivedTotal"] == 0.0
assert kpis["outstandingAr"] == pytest.approx(85.40 + 155.76)
assert kpis["deniedCount"] == 0
assert kpis["pendingCount"] == 2
assert kpis["denialRate"] == 0.0
# Default monthly window is 6 months; all claims are in the current
# month (parsed today) so the other 5 buckets are zeros.
assert len(body["monthly"]) == 6
current = body["monthly"][-1]
assert current["count"] == 2
assert current["billed"] == pytest.approx(85.40 + 155.76)
assert current["denied"] == 0
# The earlier buckets should be empty (count=0, billed=0).
for bucket in body["monthly"][:-1]:
assert bucket["count"] == 0
assert bucket["billed"] == 0.0
# One distinct provider (TOC, Inc. — NPI 1881068062).
assert body["providerCount"] == 1
assert len(body["topProviders"]) == 1
assert body["topProviders"][0]["npi"] == "1881068062"
assert body["topProviders"][0]["claimCount"] == 2
# No denials in this fixture.
assert body["recentDenials"] == []
# --------------------------------------------------------------------------- #
# months param
# --------------------------------------------------------------------------- #
def test_dashboard_summary_months_param(client: TestClient):
resp = client.get("/api/dashboard/summary?months=3", headers=JSON)
assert resp.status_code == 200
assert len(resp.json()["monthly"]) == 3
def test_dashboard_summary_months_param_min_max(client: TestClient):
# Floor is 1.
r = client.get("/api/dashboard/summary?months=1", headers=JSON)
assert r.status_code == 200
assert len(r.json()["monthly"]) == 1
# Ceiling is 24.
r = client.get("/api/dashboard/summary?months=24", headers=JSON)
assert r.status_code == 200
assert len(r.json()["monthly"]) == 24
# Out of range → 422.
r = client.get("/api/dashboard/summary?months=0", headers=JSON)
assert r.status_code == 422
r = client.get("/api/dashboard/summary?months=25", headers=JSON)
assert r.status_code == 422
# --------------------------------------------------------------------------- #
# top_providers param
# --------------------------------------------------------------------------- #
def test_dashboard_summary_top_providers_param(seeded_store):
resp = seeded_store.get(
"/api/dashboard/summary?top_providers=1", headers=JSON
)
assert resp.status_code == 200
body = resp.json()
assert len(body["topProviders"]) == 1
# providerCount is independent of topProviders.
assert body["providerCount"] == 1
# --------------------------------------------------------------------------- #
# Recent denials only contain denied claims
# --------------------------------------------------------------------------- #
def test_dashboard_summary_recent_denials_empty_when_no_denied(seeded_store):
"""Fixture has 2 submitted claims, 0 denied. recentDenials: []."""
body = seeded_store.get("/api/dashboard/summary", headers=JSON).json()
assert body["kpis"]["deniedCount"] == 0
assert body["recentDenials"] == []
# --------------------------------------------------------------------------- #
# Monthly buckets walk oldest → newest and the running AR is cumulative
# --------------------------------------------------------------------------- #
def test_dashboard_summary_monthly_chronological_and_labels(seeded_store):
body = seeded_store.get("/api/dashboard/summary", headers=JSON).json()
months = body["monthly"]
assert len(months) == 6
# Labels are short English month names (e.g. "Jan", "Feb").
for bucket in months:
assert len(bucket["label"]) == 3
assert bucket["label"][0].isupper()
# `month` strings sort in the same order the buckets appear.
keys = [b["month"] for b in months]
assert keys == sorted(keys)
# Running AR never goes negative (clamped at 0).
assert all(b["ar"] >= 0 for b in months)
# Today's bucket is the last one and has the running AR equal to the
# outstandingAr KPI (no received amounts yet in this fixture).
assert months[-1]["ar"] == pytest.approx(body["kpis"]["outstandingAr"])
# --------------------------------------------------------------------------- #
# Store-level aggregation (called by the endpoint)
# --------------------------------------------------------------------------- #
def test_store_get_dashboard_summary_directly():
"""Call the Store method without going through the API."""
from cyclone.store import store
summary = store.get_dashboard_summary(months=6, top_providers=4, denials=5)
# All claims come from the seeded fixture if any; we just assert the
# shape here so the endpoint and Store stay in sync.
assert set(summary.keys()) == {
"kpis", "monthly", "topProviders", "recentDenials",
"providerCount", "asOf",
}
assert set(summary["kpis"].keys()) == {
"claimCount", "billedTotal", "receivedTotal", "outstandingAr",
"deniedCount", "pendingCount", "denialRate",
}
assert set(summary["monthly"][0].keys()) == {
"month", "label", "count", "billed", "received", "denied",
"ar", "denialRate",
}
# Default window is 6 months even when called with explicit defaults.
assert len(summary["monthly"]) == 6
+61
View File
@@ -0,0 +1,61 @@
"""Audit log entries record the acting user_id.
Schema: the ``audit_log`` table has a ``user_id INTEGER`` column added
by migration 0011; the SQLAlchemy ``AuditLog`` model itself does not
declare it yet, so ``append_event`` cannot pass it through. These tests
fail before the model + dataclass are updated, and pass after.
"""
from __future__ import annotations
import pytest
from sqlalchemy import delete, select
from cyclone.audit_log import AuditEvent, append_event
from cyclone.db import AuditLog, SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(AuditLog))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(AuditLog))
db.execute(delete(User))
db.commit()
def test_append_event_accepts_user_id_kwarg():
with SessionLocal()() as db:
append_event(
db,
AuditEvent(
event_type="test",
entity_type="x",
entity_id="y",
user_id=42,
),
)
db.commit()
with SessionLocal()() as db:
row = db.execute(select(AuditLog)).scalars().one()
assert row.user_id == 42
def test_append_event_without_user_id_is_null():
with SessionLocal()() as db:
append_event(
db,
AuditEvent(
event_type="test",
entity_type="x",
entity_id="y",
),
)
db.commit()
with SessionLocal()() as db:
row = db.execute(select(AuditLog)).scalars().one()
assert row.user_id is None
+103
View File
@@ -0,0 +1,103 @@
"""Admin-only user management endpoints."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from cyclone.api import app
from cyclone.auth import users
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
@pytest.fixture
def admin_client():
client = TestClient(app)
with SessionLocal()() as db:
users.create(db, username="root", password="rootpassword1", role="admin")
login = client.post(
"/api/auth/login",
json={"username": "root", "password": "rootpassword1"},
)
assert login.status_code == 200
return client
@pytest.fixture
def user_client():
client = TestClient(app)
with SessionLocal()() as db:
users.create(db, username="plain", password="plainpassword1", role="user")
login = client.post(
"/api/auth/login",
json={"username": "plain", "password": "plainpassword1"},
)
return client
def test_list_users_as_admin(admin_client):
with SessionLocal()() as db:
users.create(db, username="alice", password="hunter2hunter2", role="user")
resp = admin_client.get("/api/admin/users")
assert resp.status_code == 200
body = resp.json()
usernames = {u["username"] for u in body}
assert {"root", "alice"} <= usernames
def test_list_users_as_nonadmin_returns_403(user_client):
resp = user_client.get("/api/admin/users")
assert resp.status_code == 403
def test_create_user_as_admin(admin_client):
resp = admin_client.post(
"/api/admin/users",
json={"username": "newbie", "password": "newbiepassword1", "role": "viewer"},
)
assert resp.status_code == 201
assert resp.json()["username"] == "newbie"
assert resp.json()["role"] == "viewer"
def test_create_user_rejects_invalid_role(admin_client):
resp = admin_client.post(
"/api/admin/users",
json={"username": "badrole", "password": "hunter2hunter2", "role": "owner"},
)
assert resp.status_code == 422
def test_patch_user_role_and_password(admin_client):
with SessionLocal()() as db:
u = users.create(db, username="subject", password="hunter2hunter2", role="viewer")
resp = admin_client.patch(
f"/api/admin/users/{u.id}",
json={"role": "user", "password": "newpassword1"},
)
assert resp.status_code == 200
assert resp.json()["role"] == "user"
def test_admin_cannot_demote_self(admin_client):
me = admin_client.get("/api/auth/me").json()
resp = admin_client.patch(
f"/api/admin/users/{me['id']}",
json={"role": "viewer"},
)
assert resp.status_code == 409
+79
View File
@@ -0,0 +1,79 @@
"""Bootstrap admin user on backend startup."""
from __future__ import annotations
import pytest
from sqlalchemy import delete, select
from cyclone.auth import bootstrap, users
from cyclone.auth.deps import AUTH_DISABLED
from cyclone.auth.permissions import Role
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear(monkeypatch):
# Reset the bootstrap-side AUTH_DISABLED flag so tests don't leak state
# into each other. The conftest fixture flips this back to True at the
# start of every test, but bootstrap.run() mutates this module-level
# value, so we restore it here too.
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
# conftest sets CYCLONE_AUTH_DISABLED=1 at module import. Drop it
# by default so tests exercise the real bootstrap path; the
# AUTH_DISABLED-specific test re-sets it explicitly.
monkeypatch.delenv("CYCLONE_AUTH_DISABLED", raising=False)
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
def test_bootstrap_creates_admin_when_users_empty_and_env_set(monkeypatch):
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "firstadmin")
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "firstadminpw1")
bootstrap.run()
with SessionLocal()() as db:
u = db.execute(select(User).where(User.username == "firstadmin")).scalar_one()
assert u.role == Role.ADMIN.value
def test_bootstrap_noop_when_users_exist(monkeypatch):
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "ignored")
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "ignoredignored1")
with SessionLocal()() as db:
users.create(db, username="existing", password="hunter2hunter2", role="admin")
bootstrap.run()
with SessionLocal()() as db:
all_users = db.execute(select(User)).scalars().all()
usernames = {u.username for u in all_users}
assert usernames == {"existing"}
def test_bootstrap_refuses_to_run_without_env(monkeypatch):
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
with pytest.raises(RuntimeError, match="CYCLONE_ADMIN_USERNAME"):
bootstrap.run()
def test_bootstrap_rejects_short_password(monkeypatch):
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "weak")
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "short")
with pytest.raises(RuntimeError, match="12 characters"):
bootstrap.run()
def test_bootstrap_skips_when_auth_disabled(monkeypatch):
monkeypatch.setenv("CYCLONE_AUTH_DISABLED", "1")
# Even without env vars, bootstrap should NOT raise when AUTH_DISABLED=1.
bootstrap.run()
# And it must flip the deps flag so the API skips auth checks.
from cyclone.auth import deps as _deps
assert _deps.AUTH_DISABLED is True
@@ -0,0 +1,81 @@
"""Login rate limit: 5 fails / 5 min per username."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from cyclone.api import app
from cyclone.auth import rate_limit, users
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
# Reset the in-memory rate-limit counter so a previous test's 5
# failures don't poison this test. The plan recipe calls this out.
rate_limit.reset("victim")
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
rate_limit.reset("victim")
def test_5_fails_then_429():
with SessionLocal()() as db:
users.create(db, username="victim", password="hunter2hunter2", role="user")
client = TestClient(app)
for _ in range(5):
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "WRONG"},
)
assert r.status_code == 401
# 6th should be 429.
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "WRONG"},
)
assert r.status_code == 429
assert "Retry-After" in r.headers
# And the module-level helper confirms we're now over the threshold.
assert rate_limit.check("victim") > 0
def test_successful_login_resets_counter():
with SessionLocal()() as db:
users.create(db, username="victim", password="hunter2hunter2", role="user")
client = TestClient(app)
for _ in range(4):
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "WRONG"},
)
assert r.status_code == 401
# Successful login — should reset the counter.
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "hunter2hunter2"},
)
assert r.status_code == 200
# Counter reset — 5 more fails allowed.
for _ in range(5):
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "WRONG"},
)
assert r.status_code == 401
# 6th is now throttled.
r = client.post(
"/api/auth/login",
json={"username": "victim", "password": "WRONG"},
)
assert r.status_code == 429
+111
View File
@@ -0,0 +1,111 @@
"""API tests for /api/auth/login, /api/auth/logout, /api/auth/me."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from cyclone.api import app
from cyclone.auth import users
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def seeded_admin(client):
with SessionLocal()() as db:
users.create(db, username="admin", password="adminpassword1", role="admin")
return client
def test_login_success_returns_user_and_cookie(client):
with SessionLocal()() as db:
users.create(db, username="alice", password="hunter2hunter2", role="user")
resp = client.post(
"/api/auth/login",
json={"username": "alice", "password": "hunter2hunter2"},
)
assert resp.status_code == 200
body = resp.json()
assert body["username"] == "alice"
assert body["role"] == "user"
assert "password_hash" not in body
assert "cyclone_session" in resp.cookies
def test_login_bad_password_returns_401(client):
with SessionLocal()() as db:
users.create(db, username="bob", password="hunter2hunter2", role="user")
resp = client.post(
"/api/auth/login",
json={"username": "bob", "password": "WRONG"},
)
assert resp.status_code == 401
assert resp.json()["error"] == "invalid_credentials"
def test_login_unknown_user_returns_401(client):
resp = client.post(
"/api/auth/login",
json={"username": "ghost", "password": "whatever"},
)
assert resp.status_code == 401
assert resp.json()["error"] == "invalid_credentials"
def test_login_disabled_user_returns_403(client):
with SessionLocal()() as db:
u = users.create(db, username="carol", password="hunter2hunter2", role="user")
users.disable(db, u.id)
resp = client.post(
"/api/auth/login",
json={"username": "carol", "password": "hunter2hunter2"},
)
assert resp.status_code == 403
assert resp.json()["error"] == "account_disabled"
def test_logout_clears_session_and_cookie(seeded_admin):
login = seeded_admin.post(
"/api/auth/login",
json={"username": "admin", "password": "adminpassword1"},
)
cookie = login.cookies.get("cyclone_session")
assert cookie
resp = seeded_admin.post("/api/auth/logout", cookies={"cyclone_session": cookie})
assert resp.status_code == 204
def test_me_returns_current_user(seeded_admin):
login = seeded_admin.post(
"/api/auth/login",
json={"username": "admin", "password": "adminpassword1"},
)
cookie = login.cookies.get("cyclone_session")
resp = seeded_admin.get("/api/auth/me", cookies={"cyclone_session": cookie})
assert resp.status_code == 200
assert resp.json()["username"] == "admin"
def test_me_without_cookie_returns_401(seeded_admin):
resp = seeded_admin.get("/api/auth/me")
assert resp.status_code == 401
+86
View File
@@ -0,0 +1,86 @@
"""Unit tests for cyclone.auth.sessions — Session create/validate/expire/touch."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import delete
from cyclone.auth import sessions, users
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
def _make_user():
with SessionLocal()() as db:
return users.create(db, username="sessuser", password="hunter2hunter2", role="user")
def test_create_session_returns_id_and_session():
user = _make_user()
with SessionLocal()() as db:
sid, sess = sessions.create(db, user_id=user.id)
assert len(sid) >= 32
assert sess.user_id == user.id
assert sess.expires_at > datetime.now(timezone.utc)
def test_get_valid_returns_session_for_active():
user = _make_user()
with SessionLocal()() as db:
sid, _ = sessions.create(db, user_id=user.id)
with SessionLocal()() as db:
got = sessions.get_valid(db, sid)
assert got is not None
assert got.user_id == user.id
def test_get_valid_returns_none_for_missing():
with SessionLocal()() as db:
assert sessions.get_valid(db, "does-not-exist") is None
def test_get_valid_returns_none_for_expired():
user = _make_user()
with SessionLocal()() as db:
sid, _ = sessions.create(db, user_id=user.id)
with SessionLocal()() as db:
sess = sessions.get_valid(db, sid)
sess.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
db.commit()
with SessionLocal()() as db:
assert sessions.get_valid(db, sid) is None
def test_delete_removes_session():
user = _make_user()
with SessionLocal()() as db:
sid, _ = sessions.create(db, user_id=user.id)
sessions.delete(db, sid)
with SessionLocal()() as db:
assert sessions.get_valid(db, sid) is None
def test_touch_extends_expiry():
user = _make_user()
with SessionLocal()() as db:
sid, sess = sessions.create(db, user_id=user.id)
original_expiry = sess.expires_at
sessions.touch(db, sid)
with SessionLocal()() as db:
refreshed = sessions.get_valid(db, sid)
assert refreshed.expires_at >= original_expiry
+106
View File
@@ -0,0 +1,106 @@
"""Unit tests for cyclone.auth.users — User CRUD + bcrypt hashing."""
from __future__ import annotations
import pytest
from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError
from cyclone.auth import users
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear_users():
with SessionLocal()() as db:
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(User))
db.commit()
def test_hash_password_returns_bcrypt():
h = users.hash_password("hunter2hunter2")
assert h.startswith("$2")
def test_hash_password_produces_unique_salts():
a = users.hash_password("same-password")
b = users.hash_password("same-password")
assert a != b
def test_verify_password_correct():
h = users.hash_password("hunter2hunter2")
assert users.verify_password("hunter2hunter2", h) is True
def test_verify_password_incorrect():
h = users.hash_password("hunter2hunter2")
assert users.verify_password("WRONG", h) is False
def test_create_user_persists_with_hashed_password():
with SessionLocal()() as db:
u = users.create(db, username="alice", password="hunter2hunter2", role="admin")
assert u.id is not None
assert u.username == "alice"
assert u.role == "admin"
assert u.disabled_at is None
assert u.password_hash != "hunter2hunter2"
assert u.password_hash.startswith("$2")
def test_create_user_rejects_duplicate_username():
with SessionLocal()() as db:
users.create(db, username="bob", password="hunter2hunter2", role="user")
with SessionLocal()() as db:
with pytest.raises(IntegrityError):
users.create(db, username="bob", password="anotherone", role="user")
def test_get_by_username_returns_user():
with SessionLocal()() as db:
users.create(db, username="carol", password="hunter2hunter2", role="viewer")
with SessionLocal()() as db:
u = users.get_by_username(db, "carol")
assert u is not None
assert u.username == "carol"
def test_get_by_username_returns_none_for_missing():
with SessionLocal()() as db:
assert users.get_by_username(db, "ghost") is None
def test_disable_user_sets_disabled_at():
with SessionLocal()() as db:
u = users.create(db, username="dave", password="hunter2hunter2", role="user")
users.disable(db, u.id)
with SessionLocal()() as db:
refreshed = users.get_by_username(db, "dave")
assert refreshed.disabled_at is not None
def test_to_public_shape_omits_password_hash():
with SessionLocal()() as db:
u = users.create(db, username="eve", password="hunter2hunter2", role="admin")
shape = users.to_public(u)
assert "password_hash" not in shape
assert shape["username"] == "eve"
assert shape["role"] == "admin"
assert "id" in shape and "createdAt" in shape
def test_update_password_actually_rehashes_and_verifies():
with SessionLocal()() as db:
u = users.create(db, username="frank", password="hunter2hunter2", role="user")
with SessionLocal()() as db:
users.update_password(db, u.id, "newpassword1")
# Old password no longer verifies.
with SessionLocal()() as db:
refreshed = users.get_by_username(db, "frank")
assert not users.verify_password("hunter2hunter2", refreshed.password_hash)
assert users.verify_password("newpassword1", refreshed.password_hash)
+128
View File
@@ -0,0 +1,128 @@
"""CLI: python -m cyclone users {create,list,disable,reset-password,set-role}.
Uses Click's CliRunner (matches the existing parse-837/parse-835 CLI tests).
The full CLI group lives in ``cyclone.cli.main`` — we exercise the
``users`` subgroup through the top-level ``main`` so the wiring is real.
"""
from __future__ import annotations
import pytest
from sqlalchemy import delete, select
from cyclone.auth import users
from cyclone.cli import main as cli_main
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
def test_create_user_via_cli():
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(
cli_main,
[
"users", "create", "cli-user",
"--role", "viewer",
"--password", "clipassword1",
],
input="", # don't prompt
)
assert result.exit_code == 0, result.output
with SessionLocal()() as db:
u = users.get_by_username(db, "cli-user")
assert u is not None
assert u.role == "viewer"
def test_list_users_via_cli():
from click.testing import CliRunner
with SessionLocal()() as db:
users.create(db, username="listed", password="hunter2hunter2", role="user")
runner = CliRunner()
result = runner.invoke(cli_main, ["users", "list"])
assert result.exit_code == 0, result.output
assert "listed" in result.output
def test_disable_user_via_cli():
from click.testing import CliRunner
with SessionLocal()() as db:
users.create(db, username="todie", password="hunter2hunter2", role="user")
runner = CliRunner()
result = runner.invoke(cli_main, ["users", "disable", "todie"])
assert result.exit_code == 0, result.output
with SessionLocal()() as db:
u = users.get_by_username(db, "todie")
assert u.disabled_at is not None
def test_reset_password_via_cli():
from click.testing import CliRunner
with SessionLocal()() as db:
users.create(db, username="pwchange", password="oldpassword1", role="user")
runner = CliRunner()
result = runner.invoke(
cli_main,
[
"users", "reset-password", "pwchange",
"--password", "newpassword1",
],
)
assert result.exit_code == 0, result.output
with SessionLocal()() as db:
u = users.get_by_username(db, "pwchange")
assert users.verify_password("newpassword1", u.password_hash)
def test_set_role_via_cli():
from click.testing import CliRunner
with SessionLocal()() as db:
users.create(db, username="promote", password="hunter2hunter2", role="viewer")
runner = CliRunner()
result = runner.invoke(
cli_main,
["users", "set-role", "promote", "--role", "admin"],
)
assert result.exit_code == 0, result.output
with SessionLocal()() as db:
u = users.get_by_username(db, "promote")
assert u.role == "admin"
def test_create_rejects_short_password():
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(
cli_main,
[
"users", "create", "weak",
"--role", "viewer",
"--password", "short",
],
)
# Click surfaces validation failures with a non-zero exit code.
assert result.exit_code != 0, result.output
with SessionLocal()() as db:
rows = db.execute(select(User).where(User.username == "weak")).scalars().all()
assert rows == []
def test_disable_unknown_user_exits_nonzero():
from click.testing import CliRunner
runner = CliRunner()
result = runner.invoke(cli_main, ["users", "disable", "ghost"])
assert result.exit_code != 0, result.output
@@ -0,0 +1,70 @@
"""Spot-check that existing endpoints now require auth (when AUTH_DISABLED is not set).
The conftest in ``tests/conftest.py`` flips ``AUTH_DISABLED=True`` for
every test module that does NOT start with ``test_auth`` — this module
deliberately is NOT named ``test_auth_*`` so it inherits the default
disabled posture... wait, that's the opposite of what we want.
This module is named ``test_existing_endpoints_require_auth`` so the
conftest will treat it as a legacy test and set ``AUTH_DISABLED=True``,
bypassing the auth check entirely. To actually verify the gate, this
test's ``client`` fixture flips ``AUTH_DISABLED`` to ``False``
*just for this test*, then restores it afterwards. This way the
rest of the suite still sees the disabled posture and existing
tests keep passing.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from cyclone.api import app
from cyclone.auth import deps as _auth_deps
from cyclone.auth.deps import AUTH_DISABLED
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
@pytest.fixture
def client():
# Force AUTH_DISABLED=False for these tests so the dep actually checks the cookie.
original = AUTH_DISABLED
_auth_deps.AUTH_DISABLED = False
try:
yield TestClient(app)
finally:
_auth_deps.AUTH_DISABLED = original
@pytest.mark.parametrize("method,path", [
("GET", "/api/claims"),
("GET", "/api/remittances"),
("GET", "/api/providers"),
("GET", "/api/batches"),
("GET", "/api/dashboard/summary"),
("GET", "/api/activity"),
])
def test_existing_get_endpoints_require_auth(client, method, path):
resp = client.request(method, path)
assert resp.status_code == 401, f"{method} {path} returned {resp.status_code}"
def test_health_is_public(client):
"""``/api/health`` is the public healthcheck and must remain reachable."""
resp = client.get("/api/health")
assert resp.status_code != 401, f"/api/health returned {resp.status_code}"
+431 -1
View File
@@ -33,6 +33,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" },
]
[[package]]
name = "backports-tarfile"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
]
[[package]]
name = "bcrypt"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/ae/3af7d006aacf513975fd1948a6b4d6f8b4a307f8a244e1a3d3774b297aad/bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd", size = 25498, upload-time = "2022-10-09T15:36:49.775Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/d4/3b2657bd58ef02b23a07729b0df26f21af97169dbd0b5797afa9e97ebb49/bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f", size = 473446, upload-time = "2022-10-09T15:36:25.481Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0a/1582790232fef6c2aa201f345577306b8bfe465c2c665dec04c86a016879/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0", size = 583044, upload-time = "2022-10-09T15:37:09.447Z" },
{ url = "https://files.pythonhosted.org/packages/41/16/49ff5146fb815742ad58cafb5034907aa7f166b1344d0ddd7fd1c818bd17/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410", size = 583189, upload-time = "2022-10-09T15:37:10.69Z" },
{ url = "https://files.pythonhosted.org/packages/aa/48/fd2b197a9741fa790ba0b88a9b10b5e88e62ff5cf3e1bc96d8354d7ce613/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344", size = 593473, upload-time = "2022-10-09T15:36:27.195Z" },
{ url = "https://files.pythonhosted.org/packages/7d/50/e683d8418974a602ba40899c8a5c38b3decaf5a4d36c32fc65dce454d8a8/bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a", size = 593249, upload-time = "2022-10-09T15:36:28.481Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a7/ee4561fd9b78ca23c8e5591c150cc58626a5dfb169345ab18e1c2c664ee0/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3", size = 583586, upload-time = "2022-10-09T15:37:11.962Z" },
{ url = "https://files.pythonhosted.org/packages/64/fe/da28a5916128d541da0993328dc5cf4b43dfbf6655f2c7a2abe26ca2dc88/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2", size = 593659, upload-time = "2022-10-09T15:36:30.049Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4f/3632a69ce344c1551f7c9803196b191a8181c6a1ad2362c225581ef0d383/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535", size = 613116, upload-time = "2022-10-09T15:37:14.107Z" },
{ url = "https://files.pythonhosted.org/packages/87/69/edacb37481d360d06fc947dab5734aaf511acb7d1a1f9e2849454376c0f8/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", size = 624290, upload-time = "2022-10-09T15:36:31.251Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ca/6a534669890725cbb8c1fb4622019be31813c8edaa7b6d5b62fc9360a17e/bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab", size = 159428, upload-time = "2022-10-09T15:36:32.893Z" },
{ url = "https://files.pythonhosted.org/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930, upload-time = "2022-10-09T15:36:34.635Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
@@ -42,6 +70,76 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
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/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" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.4.1"
@@ -167,15 +265,75 @@ toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "cryptography"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
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 = [
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ 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 = "cyclone"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "bcrypt" },
{ name = "click" },
{ name = "fastapi" },
{ name = "keyring" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pydantic" },
{ name = "python-multipart" },
{ name = "pyyaml" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -187,21 +345,33 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
]
sftp = [
{ name = "paramiko" },
]
sqlcipher = [
{ name = "sqlcipher3" },
]
[package.metadata]
requires-dist = [
{ name = "bcrypt", specifier = "<4.1" },
{ name = "click", specifier = ">=8.1,<9" },
{ name = "fastapi", specifier = ">=0.110,<1" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27,<1" },
{ name = "keyring", specifier = ">=25.0,<26" },
{ name = "paramiko", marker = "extra == 'sftp'", specifier = ">=3.4,<6" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "pydantic", specifier = ">=2.6,<3" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23,<1" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
{ name = "python-multipart", specifier = ">=0.0.9,<1" },
{ name = "pyyaml", specifier = ">=6.0,<7" },
{ name = "sqlalchemy", specifier = ">=2.0,<3" },
{ name = "sqlcipher3", marker = "extra == 'sqlcipher'", specifier = ">=0.6,<1" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.27,<1" },
]
provides-extras = ["dev"]
provides-extras = ["dev", "sqlcipher", "sftp"]
[[package]]
name = "fastapi"
@@ -371,6 +541,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "importlib-metadata"
version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -380,6 +562,87 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "invoke"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-tarfile", marker = "python_full_version < '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" },
]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]]
name = "keyring"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]]
name = "more-itertools"
version = "11.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" },
]
[[package]]
name = "packaging"
version = "26.2"
@@ -389,6 +652,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "paramiko"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bcrypt" },
{ name = "cryptography" },
{ name = "invoke" },
{ name = "pynacl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
]
[[package]]
name = "passlib"
version = "1.7.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
]
[package.optional-dependencies]
bcrypt = [
{ name = "bcrypt" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -398,6 +690,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
@@ -524,6 +825,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pynacl"
version = "1.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" },
{ url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" },
{ url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" },
{ url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" },
{ url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" },
{ url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" },
{ url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" },
{ url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" },
{ url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" },
{ url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" },
{ url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" },
{ url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" },
{ url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" },
{ url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" },
{ url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" },
{ url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" },
{ url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
@@ -584,6 +920,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -639,6 +984,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.51"
@@ -687,6 +1045,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
]
[[package]]
name = "sqlcipher3"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/c1/414003d77549c444bafd636149ab3ace6f4e2cb4666c9955d54ad62096cb/sqlcipher3-0.6.2.tar.gz", hash = "sha256:a2b675289ba8889f389625a21f3a01f1ff159a551b5b88fba8fd92da0e02380a", size = 2663213, upload-time = "2026-01-07T23:13:26.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/97/6461dc2ab41fbaed4942aaf7d1bac23d8cd130c751d40830ce391e80d332/sqlcipher3-0.6.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:87432804bf88e9017fc174bdd3d0862a1d1e9ef3c755517595c91da2c59e3808", size = 4942310, upload-time = "2026-01-07T23:11:58.393Z" },
{ url = "https://files.pythonhosted.org/packages/45/dc/73f238aa994e08ac6971838907fd2b80d9bd86277c3c0e99300985287cd5/sqlcipher3-0.6.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eece737c583c285e9bbe3bf829eee3b6624eb6e9dad8ccff7821a45641f436dd", size = 2893419, upload-time = "2026-01-07T23:11:59.762Z" },
{ url = "https://files.pythonhosted.org/packages/e5/4c/098cf3dd0af6ce4cfba88fbdeb63a3b156f4b7f0620f6cc5f35ecfb72607/sqlcipher3-0.6.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:22e6502c364706fe64695219877f2bb01cdb25450bec81e69c8a08deff8c14ee", size = 3160663, upload-time = "2026-01-07T23:12:01.011Z" },
{ url = "https://files.pythonhosted.org/packages/71/09/5ae806f9f5833ab7decfef0b24471f57c06c10b7fdc2a097a3066d6c80d4/sqlcipher3-0.6.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0ca92202881bcb69b3703b744b40a3a3476e122d4612a82eb2b0a36f2f78de1d", size = 7252828, upload-time = "2026-01-07T23:12:02.076Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bd/10527a221d2b7c33b1c32058c3882a03033fca013b4f9f98f0befc0fe98e/sqlcipher3-0.6.2-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:15abe3de01faa194f1aaea144ff9ecbfdce2991964dcc7ce8ec1ecc5950a4bc4", size = 6868146, upload-time = "2026-01-07T23:12:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/72/b0/faa2a8fc9dc3210e0af31e57c5ec86e8a523eaa3d44e854aa8f95ff66d50/sqlcipher3-0.6.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0f08e5bb5eb1ab93819c444ebec61fa3349e9690c14f5d0276fd4f61c3049fd9", size = 7031136, upload-time = "2026-01-07T23:12:05.242Z" },
{ url = "https://files.pythonhosted.org/packages/6d/37/1a82b3fc1504741df5aa4dccc6fd4265244e5b5dc98aa95e24321d73f0bb/sqlcipher3-0.6.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dfe90f1e0e81a8c6c8c4129a8439ed6b9b27a8e32077c59ed3b7f1263e3c5544", size = 7245432, upload-time = "2026-01-07T23:12:07.346Z" },
{ url = "https://files.pythonhosted.org/packages/a3/e5/68bbaa1790e0f2fc571924933db01a6af2991ebf479496934ab5f1b19484/sqlcipher3-0.6.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5f805c1f156634e4e91f1b073d95930756fdf23eeeeb7b85c511a5cf165b10c", size = 7051580, upload-time = "2026-01-07T23:12:08.674Z" },
{ url = "https://files.pythonhosted.org/packages/5b/51/0939f292767fe26b978b845cefac077916f5b2f1caae110be89d2b3a79dc/sqlcipher3-0.6.2-cp311-cp311-win32.whl", hash = "sha256:fc08ff475ab0e0f43adca0647d827e81da5fa406bbb6bd04471e28a3ad2864d9", size = 1981400, upload-time = "2026-01-07T23:12:09.849Z" },
{ url = "https://files.pythonhosted.org/packages/9d/87/6cb7a6ced1244350514cf419cbeb442f12f445e3c4d09da0e7a49ac7ee80/sqlcipher3-0.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:4ad7e4a32de907011ea22ac2012c9bca1bb414e2f599c56a55c8b0fe6445b932", size = 2485356, upload-time = "2026-01-07T23:12:11.253Z" },
{ url = "https://files.pythonhosted.org/packages/2c/2f/52f001d2b884047ab490964d37ee7891d497cc891cee7e64bac5672eb7d4/sqlcipher3-0.6.2-cp311-cp311-win_arm64.whl", hash = "sha256:3ad6b39a7fa8c2f7ec471dd29fadbffa19c194fbae1730f013f0d29f5b96fae0", size = 2652186, upload-time = "2026-01-07T23:12:12.488Z" },
{ url = "https://files.pythonhosted.org/packages/96/ac/3612ebe2504d7c6786ddf73d28ae3d2707eb39d6e5730336854071ccb612/sqlcipher3-0.6.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a51b18bd782652a2282f9cb1b03b840ba5a6c0c675de6cefb76262c9789c8f06", size = 4944108, upload-time = "2026-01-07T23:12:13.751Z" },
{ url = "https://files.pythonhosted.org/packages/bc/a4/7be8f0199ca6a1a2c6f7bb8118b2e37d29077ade2319d6271b1724d0cc86/sqlcipher3-0.6.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fea0f1264f09d219dd6ce699ffca8cc9022a914661c6efa4390e85a2bf78acf9", size = 2895406, upload-time = "2026-01-07T23:12:14.954Z" },
{ url = "https://files.pythonhosted.org/packages/2f/b7/b9e897cf9e4740ca148fb03b493fa708a9b729ccc0cd656099f16bc9f2fd/sqlcipher3-0.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bc2edd981e65783bc0d4e337704a9eb436871ab91c68af02ed76354876087642", size = 3161395, upload-time = "2026-01-07T23:12:16.049Z" },
{ url = "https://files.pythonhosted.org/packages/b4/98/57e7f7e170b6065a21735687a94373aba9ce6866708e5a386e6af1a90ff2/sqlcipher3-0.6.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:50f64086da0a5f14281f2b0b459c4d9923b50055813a48ad29baf8c41c7fa56c", size = 7261870, upload-time = "2026-01-07T23:12:17.732Z" },
{ url = "https://files.pythonhosted.org/packages/69/e4/cb0ed654a9642ee707c79f5e46db11518737318fd24968463b5aaa367a31/sqlcipher3-0.6.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:2288215f462a16996689e1c22d611c94dd865faddb703cb105981dc3c0307b23", size = 6874049, upload-time = "2026-01-07T23:12:19.471Z" },
{ url = "https://files.pythonhosted.org/packages/f5/03/d55fe69fb380dadb2f5d19b3eac9256218243cced6aa4696ef90d560d223/sqlcipher3-0.6.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6b26d28ca844dc2a69b8f74b390e940db47760f0be4c96d93337c57ae8250a48", size = 7040115, upload-time = "2026-01-07T23:12:21.168Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bd/afe3998add9f877533480f48d0deaa1c42a31832954424f4414539f59bfc/sqlcipher3-0.6.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:11e85c1fa4dfe6bf031af8ada500e94b5a77762355b500580360aa162896cecd", size = 7253646, upload-time = "2026-01-07T23:12:22.467Z" },
{ url = "https://files.pythonhosted.org/packages/da/ff/9723596e7d220d933d5016adcea249b8e6f4f54116219ead6cf919646de4/sqlcipher3-0.6.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:80ae14562d98419b32149e8d66eea567eb3792e149b103ee5c8e1e5c67c5d799", size = 7060703, upload-time = "2026-01-07T23:12:23.907Z" },
{ url = "https://files.pythonhosted.org/packages/c4/63/e220211b098bc54d5345369759e21e4b34804d7e1c9f80f53372e2041b39/sqlcipher3-0.6.2-cp312-cp312-win32.whl", hash = "sha256:fb15c43f8a4f8b6b0ebe62ad2ab97a7946e3b75cb98a02069ff56b7d5a96c415", size = 1982081, upload-time = "2026-01-07T23:12:25.241Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/88e8a0b96a52d291e24424cf8a222a6c7cc5356fd0a2287086afff764c2e/sqlcipher3-0.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:8bd60ffb7bfa65bd0e51da3d5c308553d7149f0091d4ea9f754c33d5ebbf0a66", size = 2485691, upload-time = "2026-01-07T23:12:26.498Z" },
{ url = "https://files.pythonhosted.org/packages/5e/83/69217a75ed282dc865c4d658993af935210efe907b5ab6a863365ed6f20b/sqlcipher3-0.6.2-cp312-cp312-win_arm64.whl", hash = "sha256:99148bf4bf8e73c2c35f810f80de776d7de09b6cf277322c07759026400e90d0", size = 2652185, upload-time = "2026-01-07T23:12:27.594Z" },
{ url = "https://files.pythonhosted.org/packages/0e/79/56fb1ea7cffc2ef32446b6fb9ba499464324995f9ea21c88791af9176682/sqlcipher3-0.6.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8093773cd59b2a205d2cdb21383a93f7725126497032c269983ed89a89993631", size = 4943278, upload-time = "2026-01-07T23:12:28.735Z" },
{ url = "https://files.pythonhosted.org/packages/98/5f/805772f52b10abe1e1aedba6f8c0ab7fb5eb5ccb4de5dfb51fef71132096/sqlcipher3-0.6.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:29f39d50bea02d78a824022989164c171e865bfeced3f9b84d1d45193dae074c", size = 2894958, upload-time = "2026-01-07T23:12:29.863Z" },
{ url = "https://files.pythonhosted.org/packages/56/0d/2cee40de57d47245de09382c64e649c8cc8e86fa549ecba7591633fabf20/sqlcipher3-0.6.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8e1ff6079603dfd955d57c26dad5eab14f6baacdc643d8753dd651913ba789cf", size = 3160350, upload-time = "2026-01-07T23:12:30.934Z" },
{ url = "https://files.pythonhosted.org/packages/fc/7a/72e7001af10c5b0e34592eae8e2634c22f1eb67d9859327c2fbbbf2b4961/sqlcipher3-0.6.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:35ae605f7594fca64a6d71007795dd39effd625cdc2a181d47f7d9fc8a5e1965", size = 7257677, upload-time = "2026-01-07T23:12:32.091Z" },
{ url = "https://files.pythonhosted.org/packages/a0/16/2e36fc23f4cc3baad39938c71c2db33a92ffa230a81073aa9186ad33a540/sqlcipher3-0.6.2-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:8a3e39ad5f73060232b17715aa3b757e82ec4b67bb6acfc081147f66d00c2659", size = 6870821, upload-time = "2026-01-07T23:12:33.516Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6b/874f72b6f3c3ebbe889e4279a0d422b7271ef7b3c63e45fae80a4ce16ec7/sqlcipher3-0.6.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9fb7109981583b631ac795e7e955d4bf78058f64b54c7f334ccc437adc322d4b", size = 7037142, upload-time = "2026-01-07T23:12:34.803Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a0/36af1ad4a3d45cc6c3a5dd508d1d5ddd24a6903a478405d40da37b1acc07/sqlcipher3-0.6.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ab63dcba15868853cb4d318cceb50dc47b94095e0c434f2785b9b098f3f5b42", size = 7249529, upload-time = "2026-01-07T23:12:36.079Z" },
{ url = "https://files.pythonhosted.org/packages/fd/67/544729cd60a1cd961d6e70b7880a5a2dd18dd38bf9575527b5b2a893daa5/sqlcipher3-0.6.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16d54822ad2fe44e49818a27f4862ba041f2d4a6aeb69422186379f9af97ced0", size = 7057767, upload-time = "2026-01-07T23:12:37.487Z" },
{ url = "https://files.pythonhosted.org/packages/5b/63/20730edd16df0d5ba8198cf8f4b679335c9db61c469d30ba2ef8adfbee2e/sqlcipher3-0.6.2-cp313-cp313-win32.whl", hash = "sha256:31789ce5ec7dd3f6c4ebd612c9cd9f7079a1d3698829111f7a382b0c10da3a87", size = 1981467, upload-time = "2026-01-07T23:12:39.16Z" },
{ url = "https://files.pythonhosted.org/packages/ab/e3/11e2e945557fe300bc399d9fafbba9154089f84483f4940546fd81b49b29/sqlcipher3-0.6.2-cp313-cp313-win_amd64.whl", hash = "sha256:9dc959ff792228c6df836cfd3667c713ae13e6e18dc2905c9d5666558606e832", size = 2485219, upload-time = "2026-01-07T23:12:40.473Z" },
{ url = "https://files.pythonhosted.org/packages/f3/79/245ef275ef93b45005e000105eb62df5779c056b1ba699eb7b5ba663a1c8/sqlcipher3-0.6.2-cp313-cp313-win_arm64.whl", hash = "sha256:30eeac16e755e5b0cff584ff541d3001bfcfc20be0ae364ff5305bbaeccbb3f1", size = 2651933, upload-time = "2026-01-07T23:12:41.727Z" },
{ url = "https://files.pythonhosted.org/packages/27/a6/8e0ac8e198ba5c92b4af150f1b405968dacb399259bb74e732818dbacb46/sqlcipher3-0.6.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:dae7ce66554f2416d9e9012cc78dfe4d4053385e7fa289a8d0bd7772e5f5a702", size = 4943422, upload-time = "2026-01-07T23:12:42.883Z" },
{ url = "https://files.pythonhosted.org/packages/e1/99/72b4e23936bd4065fb97c07429a395c828a1d7419776219c7e98932df14f/sqlcipher3-0.6.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be137cc92c9a039e469a758ee55e27e2385f419d1387f24e2029c536aa5d9736", size = 2894925, upload-time = "2026-01-07T23:12:44.133Z" },
{ url = "https://files.pythonhosted.org/packages/f6/01/f3552874b158d83c15fb9d550576020cc42b34019d0daf3291b381fbfb01/sqlcipher3-0.6.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5c1f4a5805faa418c9c7290e6a556a8c5abae40ea59b04d76e960e33c257e618", size = 3160559, upload-time = "2026-01-07T23:12:45.552Z" },
{ url = "https://files.pythonhosted.org/packages/20/a0/274cbe5180a837818f5502823b5fa20f198546abc36ee56803636db5dcaf/sqlcipher3-0.6.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2328f0848ffb78807cf0898749dc22ff3f5aa95ab0d5a8a253628de20c11a1c", size = 7257835, upload-time = "2026-01-07T23:12:46.856Z" },
{ url = "https://files.pythonhosted.org/packages/d5/49/0d9a84435658ea3a6cdbb54cb8e67725e4c146726d008248531dca146eb7/sqlcipher3-0.6.2-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:a1fbb693bf7c2f6f46ed038544b0bf76ea43dcc3231905cf5a686af15dc9b424", size = 6872199, upload-time = "2026-01-07T23:12:48.235Z" },
{ url = "https://files.pythonhosted.org/packages/ff/12/8d554633c3975f429e07cf07e136fb94ace10b460e1cb86b4c8019b7cdb4/sqlcipher3-0.6.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e00988174ecd67ecd4537504c3df55bf8daeb75fce98401f099dff8e22c43ae1", size = 7037131, upload-time = "2026-01-07T23:12:49.416Z" },
{ url = "https://files.pythonhosted.org/packages/2e/7a/58dbb21db860d006d2be466678e7fdc317118a55c0e8a3e2a7f848f42112/sqlcipher3-0.6.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a105e816579bb7cce6f03e7e208b06d6d886c6445e1c738ed9aa2febabff3041", size = 7250574, upload-time = "2026-01-07T23:12:50.73Z" },
{ url = "https://files.pythonhosted.org/packages/62/84/8d1f55fd8fadef56b40ee19bfae23100b5ff883bec65582a12a4bfbd56d5/sqlcipher3-0.6.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e3a936d7414ae62f40880668bc036b0fde1ef0f48ed86cfe6564340f780ceea4", size = 7058156, upload-time = "2026-01-07T23:12:52.13Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/80861eeeba9f5f6a0f202b3d27f9ab696e2c885442ed9340ad96ba0ed66c/sqlcipher3-0.6.2-cp314-cp314-win32.whl", hash = "sha256:7a07dafe752e013d4030accf218e80472d08de1309ddaf26df6f02d0850b2cec", size = 2028880, upload-time = "2026-01-07T23:12:53.316Z" },
{ url = "https://files.pythonhosted.org/packages/a6/11/950f0b092588866213e5d89b08701d24a938c9df116673bba54ac35f61af/sqlcipher3-0.6.2-cp314-cp314-win_amd64.whl", hash = "sha256:7de6133b19aec27b30698267cc2a0ea6e82c21d9a81d349cf0b480439fb549ac", size = 2556915, upload-time = "2026-01-07T23:12:54.615Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e7/cc1ce8e013bb84b8429b9736de7aca58b738781b08230bc9ba1c1e1a6d55/sqlcipher3-0.6.2-cp314-cp314-win_arm64.whl", hash = "sha256:765e133bd4ddda5596275f1221fa63b2b5d7d2b6e3670809bbf630edb705e27a", size = 2722598, upload-time = "2026-01-07T23:12:55.743Z" },
{ url = "https://files.pythonhosted.org/packages/26/68/611dd86b446e069f769cf129cde96967982e0b41f49e66df9d3d21255df7/sqlcipher3-0.6.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:83533b5b7622ec9b78bec7596d96534e30015136f3e3e69a22f836fc59e393bc", size = 4947511, upload-time = "2026-01-07T23:12:56.968Z" },
{ url = "https://files.pythonhosted.org/packages/80/24/b7e39a394841d56a678edb4b5a3b259b5a07bbb86a3863e15c5e1b041a58/sqlcipher3-0.6.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:310d7adbea382bda31007ee7d3dc63ba6ca86fcf7c0626ea804161fad2efce5e", size = 2896925, upload-time = "2026-01-07T23:12:58.09Z" },
{ url = "https://files.pythonhosted.org/packages/31/ff/0b4b0cb02dd4084325bce526f0f4467c6a1ebcdf8fc625516734640f37ba/sqlcipher3-0.6.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1010c4ff1ff13a7e53284a3b03980754dbd37e6eea6faed9c6409e52bac082e6", size = 3164288, upload-time = "2026-01-07T23:12:59.362Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c3/bab9952c3c1bbde45313716610c5dcd36528b315228ac0b51e2d45217509/sqlcipher3-0.6.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d437215611b620b32cb6b68dbc66dbeaccdfb3f76a7b6d8118a40849f8612088", size = 7300761, upload-time = "2026-01-07T23:13:00.836Z" },
{ url = "https://files.pythonhosted.org/packages/2b/ca/3315292ed9ddbcd2de28f445ad11cc4a7f710d8b2ab2095aa0a629682d25/sqlcipher3-0.6.2-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:09e4170b1b2744b02b1c9315996a228d0b8d8a3ec1a0f7d4d41db0e79872fcea", size = 6911561, upload-time = "2026-01-07T23:13:02.072Z" },
{ url = "https://files.pythonhosted.org/packages/a8/66/fdfdb11110d7166ca3707180d9ce85642be096d348072aba0ef8483b307a/sqlcipher3-0.6.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:bb4eaa9093bd46a7d51a65b9f63bac29ec4fc6b4ac794083e53eeb49f6db7e2c", size = 7074025, upload-time = "2026-01-07T23:13:03.327Z" },
{ url = "https://files.pythonhosted.org/packages/ca/24/69b9bdc5ae7859a009b5336515c7e4ea5d58dfe3e358e44fabbcdd1431ae/sqlcipher3-0.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:782111277cbd999b7bc4e9e910396492ee28397c2c60ef7287b6dbc36a0b6a24", size = 7293516, upload-time = "2026-01-07T23:13:04.67Z" },
{ url = "https://files.pythonhosted.org/packages/07/1b/2a62a605851b6ada8686c31b0cd82dd4305b7b19a57af3dbf7fe43249d1e/sqlcipher3-0.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a01424f0d0120e8d9d3e0e1751ef78e70867bbce91f283b56911e8c6adaafddb", size = 7096368, upload-time = "2026-01-07T23:13:06.205Z" },
{ url = "https://files.pythonhosted.org/packages/c1/7a/6c7d3356c9885e87a49d8f649f25019125ce6e68b4c8bb3933538d7add2c/sqlcipher3-0.6.2-cp314-cp314t-win32.whl", hash = "sha256:311fa50be627a4d1566bed31fd7725dec535a71332dcefdcdf9ec2472c4f824d", size = 2031516, upload-time = "2026-01-07T23:13:07.374Z" },
{ url = "https://files.pythonhosted.org/packages/47/be/42d8d5cbcd0a89a5d7bfa1fe2ff986355d8b5910445b92f9d109b18eca93/sqlcipher3-0.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7ac16581a5b80c54237c5f08f2e488051dfa7f52e3890e7765a6364d5bb3a2c6", size = 2559803, upload-time = "2026-01-07T23:13:08.447Z" },
{ url = "https://files.pythonhosted.org/packages/9d/27/140a300fd151773604c4ff75ff5785febf25071231f239d6ce84b32e1408/sqlcipher3-0.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:76125dd222f4946302f70281e155ae9336efa4bc6fabdc81a7ae9bd4dfce9180", size = 2724322, upload-time = "2026-01-07T23:13:09.5Z" },
]
[[package]]
name = "starlette"
version = "1.3.1"
@@ -999,3 +1420,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "zipp"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" },
]
+101
View File
@@ -0,0 +1,101 @@
# Cyclone — local docker-compose stack.
#
# Two services on a user-defined network:
#
# frontend (nginx) — published on http://127.0.0.1:8080
# serves the built SPA + reverse-proxies /api/* to backend
# backend (uvicorn) — NOT published externally by default; reachable from
# the frontend over the compose network on backend:8000
# (override `ports:` below to expose it for curl/debug)
#
# Persistent state:
# - `cyclone-data` named volume mounted at /data in the backend holds
# the SQLite database file. Survives `docker compose down`; only
# `docker compose down -v` wipes it.
# - `config/payers.yaml` is baked into the backend image at build time.
# To edit payer config, change the YAML, rebuild the backend image,
# and `POST /api/admin/reload-config` (or just restart the container).
#
# Usage:
# docker compose up -d --build # start (or rebuild + restart)
# docker compose logs -f # tail both services
# docker compose restart backend # bounce the backend (e.g. after crash)
# docker compose down # stop containers, KEEP the data volume
# docker compose down -v # stop AND wipe the data volume
name: cyclone
services:
backend:
build:
context: .
dockerfile: backend/Dockerfile
image: cyclone-backend:local
container_name: cyclone-backend
restart: unless-stopped
environment:
# Bind on all interfaces inside the container (required — 127.0.0.1
# would only be reachable from inside the container itself).
CYCLONE_HOST: "0.0.0.0"
CYCLONE_PORT: "8000"
CYCLONE_RELOAD: "0"
# Absolute path inside the container; the named volume mounts at /data.
CYCLONE_DB_URL: "sqlite:////data/cyclone.db"
# Bootstrap admin (required on first boot unless at least one user
# already exists). The ${VAR:?msg} syntax makes docker-compose refuse
# to start with a clear error if the env var isn't set in the host
# environment.
CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?CYCLONE_ADMIN_USERNAME is required on first boot}
CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?CYCLONE_ADMIN_PASSWORD is required on first boot (min 12 chars)}
volumes:
- cyclone-data:/data
# The healthcheck in the Dockerfile hits /api/health. The frontend
# depends_on `service_healthy` so it won't accept traffic until the
# backend is responsive.
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8000/api/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
# No `ports:` — the frontend reaches the backend over the compose
# network. Uncomment the next line to expose the API directly for
# `curl http://localhost:8000/api/...` from the host.
# ports:
# - "127.0.0.1:8000:8000"
networks:
- cyclone-net
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
image: cyclone-frontend:local
container_name: cyclone-frontend
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
ports:
# Bind address defaults to 0.0.0.0 (reachable from the LAN). Set
# CYCLONE_BIND_ADDRESS=127.0.0.1 to tighten to loopback-only and
# match the standalone install's local-only posture.
# Port defaults to 8081 to dodge the common clash on 8080; override
# with CYCLONE_WEB_PORT=... (see README).
- "${CYCLONE_BIND_ADDRESS:-0.0.0.0}:${CYCLONE_WEB_PORT:-8081}:80"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/healthz"]
interval: 15s
timeout: 3s
retries: 3
start_period: 5s
networks:
- cyclone-net
networks:
cyclone-net:
driver: bridge
volumes:
cyclone-data:
name: cyclone-data
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,554 @@
# Cyclone Auth (admin / user / viewer) — Design
**Date:** 2026-06-22
**Status:** Draft (pending user review)
**Scope:** Adds username/password authentication and three predefined roles (admin, user, viewer) to the existing Cyclone FastAPI backend and React frontend. Browser-based login form, server-side SQLite sessions, HttpOnly cookie, role-gated endpoints. Single-machine deployment; no external IdP.
---
## 1. Overview
Cyclone currently has no authentication. The README states the system is "local-only on purpose: binds to `127.0.0.1`, no auth, no internet exposure. Built for one operator, one machine." That was true for the original single-operator design, but the system is now being prepared for production deployment where multiple humans will share the box (a clearinghouse operator, billing staff, and read-only auditors).
This sub-project adds the minimum viable role-based access control:
- A `/login` page that posts username + password to the backend.
- Server-side sessions in SQLite, keyed by an HttpOnly cookie.
- Three predefined roles — `admin`, `user`, `viewer` — with a static permission matrix.
- An admin-only user-management surface so the first admin can create other accounts.
- All existing endpoints get a `current_user` dependency; write-affording endpoints get a `require_role` gate.
After this, an operator can:
1. Start the stack with `CYCLONE_ADMIN_USERNAME=admin CYCLONE_ADMIN_PASSWORD=...` (or use a CLI command) so the first admin account is created on first boot.
2. Open `http://localhost:8081/`, get redirected to `/login`, sign in.
3. Browse the Dashboard, Claims, Remittances, etc. as a `user` or `viewer`.
4. As `admin`, visit a new Users page to create accounts, change roles, disable users, and reset passwords.
## 2. Goals
1. **Authenticate every API request.** Every existing endpoint under `/api/*` returns 401 unless a valid session cookie is present. The only unauthenticated paths are `/api/healthz` (declared before any auth dependency in `cyclone.api`) and `POST /api/auth/login` itself.
2. **Three predefined roles with a static permission matrix.** `admin` = everything including user management. `user` = read + write on claims/remits/batches/reconciliation + uploads. `viewer` = read-only — no uploads, no state changes.
3. **Server-side sessions in SQLite.** New `users` and `sessions` tables. 24-hour sliding expiry. HttpOnly cookie named `cyclone_session`.
4. **Bootstrap the first admin.** If `users` table is empty on startup, read `CYCLONE_ADMIN_USERNAME` / `CYCLONE_ADMIN_PASSWORD` env vars and create the first admin. If those env vars are also missing, refuse to start with a clear error message.
5. **Frontend `/login` route + auth context.** `AuthProvider` loads `/api/auth/me` on mount, redirects to `/login` on 401, restores the user on reload. Sidebar shows the real current user instead of the hardcoded "Jordan K.".
6. **Disable write-affording UI for `viewer`.** Upload, parse, resubmit, acknowledge, reconcile buttons render disabled with a tooltip when the role lacks permission. Server still gates as the source of truth — disabling the UI is a UX nicety.
7. **Audit log includes the acting user.** The existing `audit_log` table (SP11) gets a `user_id` column on a new migration; entries record the user that triggered each event.
8. **CLI command for user management.** `python -m cyclone users create <username> --role admin --password ...` works as an alternative to the admin UI for ops.
## 3. Non-goals (this sub-project)
- **LDAP / SAML / OIDC.** No external identity providers. Auth is local-only — credentials live in the SQLite `users` table.
- **Password reset emails / "forgot password" flow.** Admins reset passwords via the admin UI or CLI. There is no self-service flow.
- **Multi-factor authentication (MFA / TOTP).** Username + password is the only factor.
- **Per-resource ACLs / per-claim visibility.** The role applies globally. There is no concept of "this user can only see claims for provider X".
- **Account lockout after N failed attempts.** We rate-limit per username (5 fails per 5 min) but never permanently lock the account.
- **Cross-device session sync / "see my active sessions" UI.** Sessions are opaque cookie values; the admin UI shows the user list, not their sessions.
- **Branding / SSO.** Out of scope.
## 4. Stack
**Backend additions:**
- New module `cyclone.auth` (`users`, `sessions`, `permissions`, `routes`, `admin`, `deps`, `rate_limit`).
- `passlib[bcrypt]` for password hashing (industry standard, slow on purpose).
- `secrets.token_urlsafe(32)` for session IDs (256 bits of entropy).
- SQLAlchemy ORM (already in use) — new `User` and `Session` models on the same `cyclone.db.Base`.
- Migration `0015_users_and_sessions.py` creates both tables.
- FastAPI dependency injection for `get_current_user` and `require_role`.
**Frontend additions:**
- New `src/auth/` module: `AuthProvider` context, `useAuth` hook, `RoleGate` component, fetch wrapper that handles 401.
- New `/login` page (`src/pages/Login.tsx`).
- No new build tools; the existing Vite + React + react-query stack stays.
**Infrastructure:**
- nginx `proxy_cookie_path /api/ /;` so the session cookie path survives the frontend → backend reverse proxy.
- `docker-compose.yml` adds `CYCLONE_ADMIN_USERNAME` and `CYCLONE_ADMIN_PASSWORD` env vars to the backend service.
- Backend image installs `passlib[bcrypt]`.
## 5. Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌──────────────────────────────────────────┐ ┌──────────────────────┐ │
│ │ React SPA │ │ Cookie jar │ │
│ │ ┌────────────────────────────────────┐ │ │ cyclone_session=<id> │ │
│ │ │ <AuthProvider> │ │ │ HttpOnly, Lax, │ │
│ │ │ on mount: GET /api/auth/me │──┼──┼──Path=/api │ │
│ │ │ on 401: hard nav to /login │ │ └──────────────────────┘ │
│ │ │ </AuthProvider> │ │ │
│ │ │ ┌─────────────────────────────┐ │ │ │
│ │ │ │ <RoleGate allow=...> │ │ │ │
│ │ │ │ disables write buttons │ │ │ │
│ │ │ │ for users without role │ │ │ │
│ │ │ └─────────────────────────────┘ │ │ │
│ │ └────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────┘ │
└────────────────────────────────┬─────────────────────────────────────────┘
│ HTTPS (or HTTP behind LAN)
┌─────────────────────────────────────────────────────────────────────────┐
│ nginx (frontend container) │
│ - serves SPA static bundle │
│ - location /api/* → proxy_pass http://cyclone-backend:8000 │
│ - proxy_cookie_path /api/ /; (rewrites cookie path) │
└────────────────────────────────┬─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ FastAPI (backend container) │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ cyclone.auth.routes POST /api/auth/login, logout │ │
│ │ GET /api/auth/me │ │
│ │ cyclone.auth.admin CRUD /api/admin/users │ │
│ │ cyclone.api (existing) every endpoint gains │ │
│ │ Depends(get_current_user) │ │
│ │ sensitive endpoints gain │ │
│ │ Depends(require_role("admin")) │ │
│ ├──────────────────────────────────────────────────────────────────┤ │
│ │ cyclone.auth.deps get_current_user, require_role │ │
│ │ cyclone.auth.sessions create/validate/expire │ │
│ │ cyclone.auth.users create/get/update/disable (bcrypt) │ │
│ │ cyclone.auth.permissions Role enum + matrix │ │
│ │ cyclone.auth.rate_limit in-memory 5-fail-per-5-min per username│ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ cyclone.db (SQLAlchemy) │ │
│ │ users(id, username UNIQUE, password_hash, role, │ │
│ │ created_at, disabled_at) │ │
│ │ sessions(id PK, user_id FK, expires_at, created_at) │ │
│ │ audit_log (existing — gets user_id column via 0016) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
**Data flow on login:**
1. User opens `/login` (the SPA redirects there on first visit because `/api/auth/me` returned 401).
2. User submits `{username, password}``POST /api/auth/login`.
3. Backend rate-limit check: if username has ≥5 failed logins in the last 5 min, return 429 with `Retry-After`.
4. Backend looks up user by username. If not found OR `bcrypt.verify(password, password_hash)` fails, increment the rate-limit counter and return 401 `{error: "invalid_credentials"}`. The error message is generic so it doesn't leak whether the username exists.
5. If user.disabled_at is not None, return 403 `{error: "account_disabled"}`.
6. Create a `sessions` row: `id = secrets.token_urlsafe(32)`, `user_id`, `expires_at = now() + 24h`.
7. Set `Set-Cookie: cyclone_session=<id>; HttpOnly; SameSite=Lax; Path=/api; Max-Age=86400` (also `Secure` when behind HTTPS — detected via `request.url.scheme` or a `BEHIND_HTTPS=1` env var).
8. Return `200 {id, username, role, createdAt}`.
**Data flow on a normal request:**
1. Browser sends request with `Cookie: cyclone_session=<id>`.
2. FastAPI middleware / dependency reads the cookie, looks up `sessions` row by `id`.
3. If missing, expired (`expires_at < now`), or user is disabled → raise `HTTPException(401, "session_expired")`.
4. Otherwise, attach `User` to `request.state.user`. The `get_current_user` dependency returns it.
5. For endpoints with `Depends(require_role("admin"))`, check `user.role == "admin"`; else raise `HTTPException(403, "forbidden")`.
**Data flow on logout:**
1. SPA sends `POST /api/auth/logout`.
2. Backend deletes the `sessions` row matching the cookie's id.
3. Backend sets `Set-Cookie: cyclone_session=; Max-Age=0` to clear the cookie.
4. SPA's `AuthProvider` clears its state and navigates to `/login`.
**Sliding expiry:**
Every successful authenticated request refreshes `sessions.expires_at = now() + 24h` (cheap UPDATE) **and** re-emits the `Set-Cookie` header with a fresh `Max-Age=86400`. Without re-emitting the cookie, the browser would log the user out after 24h regardless of activity (because the original cookie's Max-Age expires). With this loop, an active user stays logged in indefinitely; an inactive user is logged out 24h after their last request.
## 6. Backend changes
### 6.1 New module `backend/src/cyclone/auth/`
```
auth/
├── __init__.py # re-exports the public API
├── users.py # User model, CRUD, bcrypt hashing
├── sessions.py # Session model, create/validate/expire
├── permissions.py # Role enum, permission matrix
├── deps.py # get_current_user, require_role
├── routes.py # /api/auth/login, logout, me
├── admin.py # /api/admin/users/* (admin-only)
└── rate_limit.py # per-username failed-login counter
```
### 6.2 Data model
New SQLAlchemy models in `cyclone.db`:
```python
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(16)) # "admin" | "user" | "viewer"
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Session(Base):
__tablename__ = "sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True) # token_urlsafe(32)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
```
Migration `0015_users_and_sessions.py` creates both tables and adds indexes on `users.username`, `sessions.expires_at`, `sessions.user_id`.
Migration `0016_audit_log_user_id.py` adds `user_id INT NULL` to the existing `audit_log` table (SP11's hash-chained audit log).
### 6.3 Permissions matrix
`cyclone/auth/permissions.py`:
```python
from enum import Enum
class Role(str, Enum):
ADMIN = "admin"
USER = "user"
VIEWER = "viewer"
# Endpoint path prefix → set of roles allowed.
PERMISSIONS: dict[str, set[Role]] = {
# Public paths (no auth required). Empty set = anyone, including unauthenticated.
"GET /api/healthz": set(),
"POST /api/auth/login": set(),
# Auth surface (authenticated, all roles).
"POST /api/auth/logout": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/auth/me": {Role.ADMIN, Role.USER, Role.VIEWER},
# Admin-only user management.
"GET /api/admin/users": {Role.ADMIN},
"POST /api/admin/users": {Role.ADMIN},
"PATCH /api/admin/users": {Role.ADMIN},
"DELETE /api/admin/users": {Role.ADMIN},
# Read endpoints (everyone authenticated can read).
"GET /api/claims": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/remittances": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/providers": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/batches": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/dashboard/summary": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/activity": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/inbox/lanes": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/reconcile": {Role.ADMIN, Role.USER, Role.VIEWER},
"GET /api/audit-log": {Role.ADMIN}, # SP11 — admin only
# Write endpoints (admin + user, no viewer).
"POST /api/parse-837": {Role.ADMIN, Role.USER},
"POST /api/parse-835": {Role.ADMIN, Role.USER},
"POST /api/inbox": {Role.ADMIN, Role.USER},
"POST /api/reconcile": {Role.ADMIN, Role.USER},
"POST /api/resubmit": {Role.ADMIN, Role.USER},
"POST /api/acks": {Role.ADMIN, Role.USER},
# ...every other POST/PATCH/DELETE goes here too.
# CSV export — read-only, so all roles.
"GET /api/export.csv": {Role.ADMIN, Role.USER, Role.VIEWER},
}
```
The `require_role` dependency reads `(method, path)` from the request and looks up the allowed roles. Endpoints not in the matrix default to **deny** — fail-closed.
### 6.4 Dependencies
```python
# deps.py
from fastapi import Depends, HTTPException, Request, status
async def get_current_user(request: Request) -> User:
sid = request.cookies.get("cyclone_session")
if not sid:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
session = await sessions.get_valid(sid)
if not session:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
user = await users.get(session.user_id)
if not user or user.disabled_at is not None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "account_disabled")
# Sliding expiry refresh.
await sessions.touch(sid)
request.state.user = user
return user
def require_role(*allowed: Role):
async def _dep(request: Request, user: User = Depends(get_current_user)) -> User:
method = request.method
path = request.url.path
key = f"{method} {path}"
# Longest-prefix match: e.g. /api/claims/CLM-1 matches "GET /api/claims".
allowed_roles = _lookup_permissions(key)
if user.role not in allowed_roles:
raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden")
return user
return _dep
```
### 6.5 Endpoints
**Auth (`cyclone/auth/routes.py`):**
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| POST | `/api/auth/login` | public | `{username, password}` | `200 {id, username, role, createdAt}` + Set-Cookie |
| POST | `/api/auth/logout` | session | — | `204` + cookie cleared |
| GET | `/api/auth/me` | session | — | `200 {id, username, role, createdAt}` |
**Admin (`cyclone/auth/admin.py`):**
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| GET | `/api/admin/users` | admin | — | `200 [{id, username, role, createdAt, disabledAt}]` |
| POST | `/api/admin/users` | admin | `{username, password, role}` | `201 {id, username, role, createdAt}` |
| PATCH | `/api/admin/users/{id}` | admin | `{role?, password?, disabled?}` | `200 {id, username, role, createdAt, disabledAt}` |
| DELETE | `/api/admin/users/{id}` | admin | — | `204` (only if user has no authored data; otherwise 409) |
**Error shapes:**
```json
// 401
{ "error": "session_expired", "detail": "Session is missing or expired." }
// 403
{ "error": "forbidden", "detail": "Your role lacks permission for this action." }
// 429
{ "error": "rate_limited", "detail": "Too many login attempts. Try again in N seconds." }
// Login 401 (generic — never leak username existence)
{ "error": "invalid_credentials", "detail": "Username or password is incorrect." }
```
### 6.6 Rate limit
In-memory dict in `rate_limit.py`:
```python
_FAILS: dict[str, list[float]] = {} # username → [timestamp, ...] of recent fails
WINDOW_SECONDS = 300
MAX_FAILS = 5
def check(username: str) -> int:
"""Return Retry-After seconds, or 0 if allowed."""
now = time.monotonic()
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
if len(fails) >= MAX_FAILS:
return int(WINDOW_SECONDS - (now - fails[0]))
return 0
def record_failure(username: str) -> None:
_FAILS.setdefault(username, []).append(time.monotonic())
def reset(username: str) -> None:
_FAILS.pop(username, None)
```
Per-process. Resets on backend restart. Acceptable for v1.
### 6.7 Bootstrap (first admin)
In `cyclone/__main__.py`, before `uvicorn.run(...)`:
```python
async def bootstrap_admin():
async with SessionLocal() as db:
if await db.scalar(select(func.count()).select_from(User)) > 0:
return
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
if not username or not password:
raise RuntimeError(
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
"CYCLONE_ADMIN_PASSWORD env vars, or run "
"`python -m cyclone users create <username> --role admin` first."
)
if len(password) < 12:
raise RuntimeError("CYCLONE_ADMIN_PASSWORD must be at least 12 characters.")
await users.create(db, username=username, password=password, role=Role.ADMIN)
print(f"[cyclone] bootstrap admin user '{username}' created")
```
### 6.8 CLI command
`python -m cyclone users ...`:
```
python -m cyclone users create <username> --role {admin|user|viewer} [--password <pw>]
python -m cyclone users list
python -m cyclone users disable <username>
python -m cyclone users reset-password <username> [--password <pw>]
python -m cyclone users set-role <username> --role {admin|user|viewer}
```
If `--password` is omitted, the CLI prompts interactively (no echo). On Windows / non-tty, refuse and require `--password` from env to avoid accidental empty-password accounts.
## 7. Frontend changes
### 7.1 New module `src/auth/`
```
auth/
├── AuthProvider.tsx # React context + reducer
├── AuthProvider.test.tsx
├── useAuth.ts # hook: {user, login, logout, status}
├── api.ts # fetch wrapper that handles 401
├── RoleGate.tsx # disables children when role not allowed
└── RoleGate.test.tsx
```
### 7.2 AuthProvider
```typescript
// AuthProvider.tsx (sketch)
type Status = "loading" | "authenticated" | "unauthenticated";
interface AuthState {
status: Status;
user: User | null;
}
const AuthContext = createContext<AuthState & { login, logout }>(...);
export function AuthProvider({ children }) {
const [state, setState] = useState<AuthState>({ status: "loading", user: null });
useEffect(() => {
api.getAuthMe()
.then(user => setState({ status: "authenticated", user }))
.catch(err => {
if (err.status === 401) setState({ status: "unauthenticated", user: null });
else setState({ status: "unauthenticated", user: null }); // network errors also treat as logged out
});
}, []);
// Expose login() that POSTs /api/auth/login + sets state on success.
// Expose logout() that POSTs /api/auth/logout + clears state + nav to /login.
}
```
### 7.3 Login page
`src/pages/Login.tsx`:
- Centered card on the existing dark background, ~400px wide.
- Username + password fields, "Sign in" button, error message slot.
- On submit: POST `/api/auth/login`, on success → `useNavigate()/<prev>` or `/`.
- On `401 invalid_credentials` → "Username or password is incorrect."
- On `403 account_disabled` → "Account is disabled. Contact your administrator."
- On `429 rate_limited` → "Too many attempts. Try again in N seconds." (N from `Retry-After`).
- The page renders *without* the sidebar — it lives outside the protected `<Layout>`.
### 7.4 API wrapper
`src/auth/api.ts` re-exports the existing `lib/api.ts` but wraps `fetch` so any 401 response:
1. Calls `POST /api/auth/logout` (best-effort, ignore failure).
2. Clears the auth context.
3. `window.location.href = "/login?next=" + encodeURIComponent(currentPath)`.
Use a hard navigation (not `<Navigate>`) so the SPA's in-memory state is wiped.
### 7.5 RoleGate
```tsx
// RoleGate.tsx
interface Props {
allow: Role[];
children: ReactNode;
fallback?: ReactNode; // optional explicit "no permission" UI
}
export function RoleGate({ allow, children, fallback }: Props) {
const { user } = useAuth();
if (!user) return null;
if (allow.includes(user.role)) return <>{children}</>;
if (fallback) return <>{fallback}</>;
return (
<Tooltip content={`Your role (${user.role}) cannot perform this action.`}>
<span className="pointer-events-none opacity-50">{children}</span>
</Tooltip>
);
}
```
Applied at the call sites — e.g. wrap the Upload dropzone, the Parse button, the Resubmit button, the Acknowledge action, the Reconcile "match" button.
### 7.6 Sidebar
`src/components/Sidebar.tsx` — replace the hardcoded "Jordan K. / Administrator" block with `<CurrentUser />` which reads `useAuth()`. Shows the user's actual username + role.
### 7.7 Routes
`src/main.tsx`:
- `QueryClientProvider`
- `AuthProvider`
- `<BrowserRouter>` with two route groups:
- **Public:** `/login` (no `<Layout>` wrap).
- **Protected:** everything else, wrapped in a `<RequireAuth>` guard that waits for `AuthProvider.status === "loading"` to resolve, then renders `<Outlet />` or `<Navigate to="/login" />`.
### 7.8 react-query keys
`useAuth()` itself is not stored in react-query (it's a long-lived auth state, not a query). Existing query keys (`['claims', ...]`, etc.) are unchanged. On logout, the app does a hard reload to `/login` which wipes all react-query state.
## 8. Infrastructure
### 8.1 nginx (`frontend/nginx.conf`)
The existing nginx config already proxies `/api/*` to the backend. With cookie-based auth, the only requirement is that the cookie's `Path` matches a prefix of the request URL the browser sends. We set `Path=/api` on the cookie (matching the proxied path), and the browser sends it automatically on every `/api/*` request to the frontend nginx — no `proxy_cookie_path` rewrite needed.
We do add `proxy_set_header X-Forwarded-Proto $scheme;` so the backend can detect when it's behind HTTPS and emit the `Secure` cookie flag.
### 8.2 docker-compose.yml
```yaml
services:
backend:
environment:
CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?set me}
CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?set me}
# CYCLONE_BEHIND_HTTPS=1 # uncomment if behind an HTTPS reverse proxy
```
A `.env.example` documents the required vars.
### 8.3 Cookie `Secure` flag
Detected at request time: if `request.url.scheme == "https"` OR `os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"`, the cookie gets `Secure`. This lets the same binary work in dev (`http://localhost`) and prod (HTTPS in front of nginx).
## 9. Testing
### 9.1 Backend (`backend/tests/`)
| File | Coverage |
|---|---|
| `test_auth_users.py` | bcrypt hash/verify round-trip; create/get/disable; password never returned in any response shape; username uniqueness; role must be one of the enum |
| `test_auth_sessions.py` | create/validate/expire; expired session is rejected; cookie attrs (HttpOnly, Path, Max-Age) on the login response |
| `test_auth_routes.py` | login success path returns 200 + cookie; login with bad password returns 401 with `invalid_credentials`; login with disabled user returns 403; logout deletes the session row + clears cookie; /me with valid cookie returns user; /me with no cookie returns 401 |
| `test_auth_permissions.py` | for each role × each endpoint, assert the right HTTP code (200/401/403); fail-closed: endpoints not in the matrix return 403 |
| `test_auth_admin.py` | admin can GET/POST/PATCH/DELETE users; non-admin gets 403 on every /api/admin/* path; admin cannot delete themselves (409); admin cannot demote themselves below admin (409) |
| `test_auth_bootstrap.py` | empty users + env vars → admin created; empty users + missing env vars → backend refuses to start; non-empty users → env vars ignored |
| `test_auth_login_rate_limit.py` | 5 failed logins OK, 6th returns 429 with Retry-After; counter resets after window; successful login resets the counter |
| `test_audit_log_user_id.py` | existing audit-log entries get NULL user_id (back-compat); new entries after auth lands record the acting user's id |
| `test_existing_endpoints_require_auth.py` | spot-check 10 existing endpoints (claims GET, parse-837 POST, etc.) all return 401 without a cookie |
### 9.2 Frontend (`src/**/__tests__/`)
| File | Coverage |
|---|---|
| `src/auth/AuthProvider.test.tsx` | /me on mount populates user; 401 from /me leaves user null; login() POSTs and updates state; logout() POSTs and clears state |
| `src/auth/RoleGate.test.tsx` | renders children for allowed role; renders disabled-with-tooltip for disallowed role; renders fallback when provided |
| `src/pages/Login.test.tsx` | form submits with username/password; error message on 401; redirect to `next` query param on success; rate-limit message on 429 |
| `src/lib/api.test.ts` (extended) | fetch wrapper hard-navigates to `/login` on 401; passes through on other statuses |
### 9.3 End-to-end
`/tmp/verify_auth.py` (Playwright) — login with seeded admin, verify dashboard renders, verify viewer cannot see Upload dropzone enabled, verify viewer clicking Upload sees a disabled state with tooltip.
## 10. Risk + open questions
- **Cookie path rewriting is fragile.** If a future deployment puts the API at a different prefix than `/api/`, the `proxy_cookie_path` will need to change. Documented in the README.
- **The audit-log migration (0016) is technically out of scope** for "add auth" — but the user said "I want admin, user, viewer. or something like that" and not seeing *who* did *what* in the audit log is a half-measure. Include it.
- **The CLI command on Windows** will need to handle non-tty differently from Linux/macOS. Stubbed: require `--password` from env on non-tty platforms.
- **No logout-everywhere UI.** If an admin wants to invalidate all sessions for a user, they currently have to wait for sessions to expire or wipe the `sessions` table directly. Acceptable for v1; admin can `DELETE FROM sessions WHERE user_id = ?` from `sqlite3 /data/cyclone.db` if urgent.
## 11. Rollout
- All code lands in one PR (sub-project is small enough).
- Migrations 0015 + 0016 ship together; both are backwards-compatible (additive).
- The Docker image rebuilds pick up `passlib[bcrypt]` automatically.
- `README.md` gets a new "Auth" section explaining env vars, the bootstrap admin, the CLI command, and the role matrix.
- Auth applies everywhere once enabled — both the Docker deployment (`http://localhost:8081`) and the Vite dev server (`http://localhost:5173`, which proxies `/api/*` to the backend on port 8000). Operators who want to disable auth in dev can set `CYCLONE_AUTH_DISABLED=1` env var; the bootstrap function is a no-op when this is set, and the `get_current_user` dependency returns a synthetic admin user. This is purely a developer-experience escape hatch — production deployments leave it unset.
+13
View File
@@ -0,0 +1,13 @@
# Build-time-only exclusions for the frontend image.
**/node_modules
**/dist
**/.vite
**/.cache
**/.eslintcache
**/coverage
docs/
backend/
tests/
*.test.ts
*.test.tsx
**/*.test.*
+57
View File
@@ -0,0 +1,57 @@
# syntax=docker/dockerfile:1.7
# Cyclone frontend — Vite/React SPA built once and served by nginx.
#
# Multi-stage so node_modules + build cache (~hundreds of MB) don't ship
# in the runtime image. The final image is nginx:alpine serving the
# pre-built static bundle, with /api/* reverse-proxied to the backend
# service over the compose network.
#
# Build context is the repo root (../) so package.json / vite config /
# src/ are all reachable from the docker-compose `dockerfile:` directive.
# ---- build stage --------------------------------------------------------
FROM node:20-alpine AS build
WORKDIR /app
# Copy manifests first so dependency layer caches when only source changes.
COPY package.json package-lock.json* /app/
RUN npm ci --no-audit --no-fund
# Source + build configs. VITE_API_BASE_URL is left empty so the SPA
# calls /api/* on the same origin (handled by nginx in the runtime stage).
COPY index.html postcss.config.js tailwind.config.js \
tsconfig.json tsconfig.app.json tsconfig.node.json \
vite.config.ts /app/
COPY public /app/public
COPY src /app/src
ENV NODE_ENV=production \
VITE_API_BASE_URL=
RUN npm run build
# ---- runtime stage ------------------------------------------------------
FROM nginx:1.27-alpine AS runtime
# Drop the default nginx site and ship ours (SPA fallback + /api proxy).
RUN rm /etc/nginx/conf.d/default.conf
COPY frontend/nginx.conf /etc/nginx/conf.d/cyclone.conf
# Static bundle from the build stage.
COPY --from=build /app/dist /usr/share/nginx/html
# nginx:alpine ships with a non-root `nginx` user (UID 101). We stay as
# root only long enough to write the pid + cache dirs, then exec under
# that user. This avoids permission issues with mounted volumes.
RUN touch /var/run/nginx.pid \
&& chown -R nginx:nginx /var/run/nginx.pid /var/cache/nginx /usr/share/nginx/html
USER nginx
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
+84
View File
@@ -0,0 +1,84 @@
# Cyclone frontend — nginx site config.
#
# Two responsibilities:
# 1. Serve the built SPA bundle from /usr/share/nginx/html with a
# fallback to index.html for client-side routing.
# 2. Reverse-proxy /api/* to the backend service so the SPA can call
# relative URLs (VITE_API_BASE_URL empty) without CORS round-trips.
#
# `backend` resolves via Docker's user-defined network (compose service
# name). `chunked_transfer_encoding off` keeps SSE/NDJSON streams flowing
# instead of being buffered by nginx — uvicorn already streams, but the
# explicit override makes the intent obvious and survives future nginx
# default changes.
upstream cyclone_backend {
server backend:8000;
keepalive 16;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Reasonable defaults for a single-operator dashboard.
client_max_body_size 25m;
# gzip JS/CSS/JSON/SVG so the initial bundle loads faster.
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types
application/javascript
application/json
application/xml
image/svg+xml
text/css
text/plain;
# Cache fingerprinted assets aggressively; index.html stays fresh.
location ~* \.(?:js|css|woff2?|ttf|otf|eot|svg|png|jpg|jpeg|webp|ico)$ {
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, immutable";
}
# /api/* → backend. Don't buffer so live-tail NDJSON streams flow.
location /api/ {
proxy_pass http://cyclone_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# Keep idle NDJSON streams alive across nginx's default 60s.
proxy_read_timeout 1h;
proxy_send_timeout 60s;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
add_header X-Accel-Buffering no;
}
# SPA fallback — anything that isn't a real file in / serves index.html
# so client-side routes (/inbox, /claims/:id) don't 404 on refresh.
location / {
try_files $uri $uri/ /index.html;
}
# Lightweight health probe for orchestrators that prefer /healthz over
# the static index.
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
}
+10 -1
View File
@@ -12,6 +12,8 @@ import { Acks } from "@/pages/Acks";
import { Batches } from "@/pages/Batches";
import { BatchDiff } from "@/pages/BatchDiff";
import Inbox from "@/pages/Inbox";
import { Login } from "@/pages/Login";
import { RequireAuth } from "@/auth/RequireAuth";
function NotFound() {
return (
@@ -26,7 +28,14 @@ export default function App() {
return (
<>
<Routes>
<Route element={<Layout />}>
{/* /login sits OUTSIDE the auth-gated Layout so an
unauthenticated operator can reach the sign-in screen. */}
<Route path="/login" element={<Login />} />
{/* Every other route inherits the auth gate via RequireAuth
wrapping the Layout outlet. Authenticated operators see
the full app; unauthenticated ones are bounced back here
with `?next=<current>`. */}
<Route element={<RequireAuth><Layout /></RequireAuth>}>
<Route index element={<Dashboard />} />
<Route path="claims" element={<Claims />} />
<Route path="remittances" element={<Remittances />} />
+78
View File
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
vi.mock("./api", () => ({
authApi: {
me: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
},
}));
import { authApi } from "./api";
import { AuthProvider, useAuth } from "./AuthProvider";
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
describe("AuthProvider", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("loads user on mount", async () => {
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 1,
username: "alice",
role: "user",
});
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe("authenticated"));
expect(result.current.user?.username).toBe("alice");
});
it("status is unauthenticated when /me fails", async () => {
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("401")
);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe("unauthenticated"));
expect(result.current.user).toBeNull();
});
it("login() updates state on success", async () => {
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("401")
);
(
authApi.login as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({ id: 1, username: "alice", role: "admin" });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe("unauthenticated"));
await act(async () => {
await result.current.login("alice", "pw");
});
expect(result.current.user?.username).toBe("alice");
expect(result.current.status).toBe("authenticated");
});
it("logout() clears state", async () => {
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 1,
username: "alice",
role: "user",
});
(
authApi.logout as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue(undefined);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.status).toBe("authenticated"));
await act(async () => {
await result.current.logout();
});
expect(result.current.user).toBeNull();
expect(result.current.status).toBe("unauthenticated");
});
});
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState, useCallback, type ReactNode } from "react";
import { AuthContext, type AuthContextValue, type AuthStatus } from "./useAuth";
import { authApi } from "./api";
import type { User } from "@/types";
// Re-export useAuth so consumers can import both the context provider and
// the hook from the same module. The hook itself lives in `./useAuth` so
// the type definitions stay tree-shakeable independent of the provider.
export { useAuth } from "./useAuth";
/**
* Wraps the app and exposes the auth context. On mount it probes
* /api/auth/me to decide whether the existing `cyclone_session`
* cookie is still valid. Callers (route guard, sidebar, RoleGate,
* Login page) read the resulting `status` + `user` via `useAuth()`.
*
* - "loading" → while the probe is in flight
* - "authenticated" → probe succeeded; user populated
* - "unauthenticated"→ probe failed; user is null
*
* `login` and `logout` mutate local state directly so the UI flips
* without waiting for a second /me round-trip.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AuthStatus>("loading");
const [user, setUser] = useState<User | null>(null);
const refresh = useCallback(async () => {
setStatus("loading");
try {
const me = await authApi.me();
setUser(me as User);
setStatus("authenticated");
} catch {
setUser(null);
setStatus("unauthenticated");
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const login = useCallback(async (username: string, password: string) => {
const u = await authApi.login(username, password);
setUser(u as User);
setStatus("authenticated");
}, []);
const logout = useCallback(async () => {
await authApi.logout().catch(() => undefined);
setUser(null);
setStatus("unauthenticated");
}, []);
const value: AuthContextValue = { status, user, login, logout, refresh };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
+43
View File
@@ -0,0 +1,43 @@
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "./useAuth";
import type { ReactNode } from "react";
/**
* Route guard. Wrap any subtree that requires a logged-in operator:
*
* <Route element={<RequireAuth><Layout/></RequireAuth>}>
* <Route path="/" element={<Dashboard />} />
* ...
* </Route>
*
* Three branches, in priority order:
*
* 1. `status === "loading"` → render a centered "Loading…"
* placeholder so we don't bounce the user to /login before the
* cookie has had a chance to prove itself via /api/auth/me.
* 2. `status === "unauthenticated"` → redirect to
* `/login?next=<current path+search>`. The Login page reads
* `next` and bounces the operator back here on success.
* 3. otherwise → render children (status is
* "authenticated").
*/
export function RequireAuth({ children }: { children: ReactNode }) {
const { status } = useAuth();
const location = useLocation();
if (status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center text-muted-foreground text-sm">
Loading
</div>
);
}
if (status === "unauthenticated") {
return (
<Navigate
to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`}
replace
/>
);
}
return <>{children}</>;
}
+80
View File
@@ -0,0 +1,80 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { cleanup } from "@testing-library/react";
vi.mock("./useAuth", () => ({
useAuth: vi.fn(),
}));
import { useAuth } from "./useAuth";
import { RoleGate } from "./RoleGate";
function mockUser(role: "admin" | "user" | "viewer" | null) {
(useAuth as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
user: role ? { id: 1, username: "x", role } : null,
status: role ? "authenticated" : "unauthenticated",
});
}
describe("RoleGate", () => {
beforeEach(() => {
vi.resetAllMocks();
cleanup();
});
it("renders children when role is allowed", () => {
mockUser("admin");
const { container } = render(
<RoleGate allow={["admin", "user"]}>
<button>Do thing</button>
</RoleGate>
);
expect(container.querySelector("button")).not.toBeNull();
expect(container.textContent).toContain("Do thing");
});
it("renders disabled (via span wrap) when role is NOT allowed", () => {
mockUser("viewer");
const { container } = render(
<RoleGate allow={["admin", "user"]}>
<button>Do thing</button>
</RoleGate>
);
const buttons = container.querySelectorAll("button");
expect(buttons.length).toBeGreaterThan(0);
// The single child element is wrapped in a span that carries the
// disabled visual signal — opacity-50 plus pointer-events-none so
// the inner control can't be clicked.
const wrapper = container.querySelector("span");
expect(wrapper).not.toBeNull();
expect(wrapper?.className).toContain("opacity-50");
expect(wrapper?.className).toContain("pointer-events-none");
// The wrapper also carries a native `title` attribute that
// explains why the affordance is disabled.
expect(wrapper?.getAttribute("title")).toMatch(/role \(viewer\) cannot perform this action/);
});
it("renders fallback when provided and role not allowed", () => {
mockUser("viewer");
const { container } = render(
<RoleGate allow={["admin"]} fallback={<span>NO</span>}>
<button>Do thing</button>
</RoleGate>
);
expect(container.textContent).toContain("NO");
// Fallback path does NOT wrap children — original button is absent.
expect(container.querySelector("button")).toBeNull();
});
it("renders nothing when user is null", () => {
mockUser(null);
const { container } = render(
<RoleGate allow={["admin", "user"]}>
<button>Do thing</button>
</RoleGate>
);
expect(container.querySelector("button")).toBeNull();
expect(container.textContent).not.toContain("Do thing");
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { ReactNode } from "react";
import { useAuth } from "./useAuth";
type Role = "admin" | "user" | "viewer";
interface Props {
/**
* Roles allowed to interact with the gated affordance. The user's
* role must be in this list for children to render normally.
*/
allow: Role[];
children: ReactNode;
/**
* What to render instead when the user lacks the role AND we don't
* want to show the disabled affordance at all (e.g. "contact your
* admin" hint, or just nothing).
*/
fallback?: ReactNode;
}
/**
* RoleGate wraps any write affordance (parse button, resubmit action,
* "match selected", etc.) so it visibly disables itself when the
* current user's role isn't in `allow`.
*
* Behavior matrix:
*
* - user is null → render nothing (the route guard
* already redirects to /login; this
* is belt-and-braces).
* - user.role in `allow` → render children unchanged.
* - user.role NOT in `allow` and
* `fallback` is provided → render `fallback`.
* - user.role NOT in `allow` and
* no `fallback` → wrap children in a span carrying
* `opacity-50` + `pointer-events-none`
* + a `title` attribute that
* explains why. We use the native
* HTML `title` instead of a custom
* Tooltip component to keep this
* dependency-free and accessible
* by default.
*/
export function RoleGate({ allow, children, fallback }: Props) {
const { user } = useAuth();
if (!user) return null;
if (allow.includes(user.role)) return <>{children}</>;
if (fallback !== undefined) return <>{fallback}</>;
return (
<span
className="pointer-events-none opacity-50 inline-flex"
title={`Your role (${user.role}) cannot perform this action.`}
>
{children}
</span>
);
}
+83
View File
@@ -0,0 +1,83 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("auth/api fetch wrapper", () => {
let originalFetch: typeof globalThis.fetch;
let originalLocation: Location;
beforeEach(() => {
originalFetch = globalThis.fetch;
originalLocation = window.location;
delete (window as any).__navCalls;
(window as any).__navCalls = [];
});
afterEach(() => {
globalThis.fetch = originalFetch;
window.location = originalLocation as any;
});
it("redirects to /login on 401 response", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: "Unauthorized",
headers: new Headers(),
json: async () => ({ error: "session_expired" }),
} as unknown as Response) as unknown as typeof globalThis.fetch;
// Stub window.location.href setter to capture navigation without
// actually navigating (jsdom does not implement location.href assignment).
let href = originalLocation.href;
Object.defineProperty(window, "location", {
configurable: true,
get: () => ({
...originalLocation,
get href() {
return href;
},
set href(v: string) {
(window as any).__navCalls.push(v);
href = v;
},
}),
});
const { authedFetch } = await import("./api");
await expect(authedFetch("/api/anything")).rejects.toThrow();
expect((window as any).__navCalls).toContainEqual(expect.stringContaining("/login"));
});
it("does NOT redirect on 403", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: "Forbidden",
headers: new Headers(),
json: async () => ({ error: "forbidden" }),
} as unknown as Response) as unknown as typeof globalThis.fetch;
Object.defineProperty(window, "location", {
configurable: true,
get: () => ({
...originalLocation,
set href(_: string) {
throw new Error("should not nav");
},
}),
});
const { authedFetch } = await import("./api");
await expect(authedFetch("/api/anything")).rejects.toThrow();
});
it("returns parsed JSON on 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({ hello: "world" }),
} as unknown as Response) as unknown as typeof globalThis.fetch;
const { authedFetch } = await import("./api");
const data = await authedFetch("/api/anything");
expect(data).toEqual({ hello: "world" });
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* Auth-aware fetch wrapper.
*
* Every authenticated backend call in the SPA goes through `authedFetch`.
* Responsibilities:
*
* 1. Attach `credentials: "include"` so the `cyclone_session` HttpOnly
* cookie rides along on cross-origin XHR calls.
* 2. Tag every request with `Accept: application/json` by default so
* the FastAPI backend knows to return its structured error shape
* (`{"error": "...", "detail": "..."}`) on failures.
* 3. On a 401 from anything OTHER than the auth endpoints themselves,
* redirect to `/login?next=<current>` so the operator sees a real
* sign-in screen instead of an infinite stream of failing queries.
* 401 from `/api/auth/*` is the normal "bad password" path — let
* the caller handle it.
* 4. On any other non-2xx, parse the JSON body and throw an `ApiError`
* carrying `status`, the backend's `error` code, and the `detail`
* string. The Login page and hooks both branch on `.code`.
* 5. On 204, return `undefined` (so callers can `await` without
* blowing up on `res.json()` of an empty body).
* 6. Otherwise return the parsed JSON.
*
* `authApi` is the typed wrapper around the three auth endpoints
* (`/api/auth/login`, `/api/auth/me`, `/api/auth/logout`).
*/
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
function joinUrl(path: string): string {
if (BASE_URL) return `${BASE_URL.replace(/\/$/, "")}${path}`;
return path;
}
/**
* Error thrown for any non-2xx `authedFetch` response. Carries the HTTP
* `status`, the backend's `error` code (so callers can branch on
* `err.code === "invalid_credentials"`, etc.), and the optional `detail`
* string for surfacing in toasts.
*
* Distinct from the `ApiError` in `src/lib/api.ts` — that one only
* carries `status` and is used by the existing pages; this one is the
* richer auth-aware variant that the Login page + hooks depend on.
*/
export class ApiError extends Error {
status: number;
code: string;
detail?: string;
constructor(status: number, code: string, detail?: string) {
super(detail ?? code);
this.name = "ApiError";
this.status = status;
this.code = code;
this.detail = detail;
}
}
function redirectToLogin() {
const next = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?next=${next}`;
}
export async function authedFetch<T = unknown>(
path: string,
init?: RequestInit
): Promise<T> {
const res = await fetch(joinUrl(path), {
credentials: "include",
headers: { Accept: "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 401 && !path.startsWith("/api/auth/")) {
redirectToLogin();
throw new ApiError(401, "session_expired");
}
if (!res.ok) {
let body: any = null;
try {
body = await res.json();
} catch {
/* no body */
}
const code = body?.error ?? "error";
const detail = body?.detail ?? res.statusText;
throw new ApiError(res.status, code, detail);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
/**
* Like `authedFetch` but returns the response body as text instead of
* parsed JSON. Used by `serializeClaim837` (the SP8 endpoint returns
* `text/x12`, not JSON). Same 401-redirect + error-shape behavior as
* the JSON variant.
*/
export async function authedFetchText(path: string, init?: RequestInit): Promise<string> {
const res = await fetch(joinUrl(path), {
credentials: "include",
...init,
});
if (res.status === 401 && !path.startsWith("/api/auth/")) {
redirectToLogin();
throw new ApiError(401, "session_expired");
}
if (!res.ok) {
let body: any = null;
try {
body = await res.json();
} catch {
/* no body */
}
throw new ApiError(
res.status,
body?.error ?? "error",
body?.detail ?? res.statusText
);
}
return res.text();
}
/**
* Like `authedFetch` but returns the raw `Response` so the caller can
* stream the body (NDJSON). Used by `parse837` / `parse835`. 401 still
* redirects; the caller is responsible for reading the body.
*/
export async function authedFetchResponse(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(joinUrl(path), {
credentials: "include",
...init,
});
if (res.status === 401 && !path.startsWith("/api/auth/")) {
redirectToLogin();
throw new ApiError(401, "session_expired");
}
return res;
}
// ---------------------------------------------------------------------------
// Auth-specific endpoints. `login` and `logout` use raw `fetch` because
// they bypass the 401-redirect behavior on purpose — a 401 from
// /api/auth/login is "wrong password", not "session expired".
// ---------------------------------------------------------------------------
export const authApi = {
async login(username: string, password: string) {
const res = await fetch(joinUrl("/api/auth/login"), {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(
res.status,
body.error ?? "error",
body.detail ?? res.statusText
);
}
return res.json();
},
async me() {
return authedFetch("/api/auth/me");
},
async logout() {
// Fire-and-forget. A network error here is fine — the server has
// already cleared the cookie or the operator is signing out because
// they're about to be disconnected. Either way, the client should
// drop the user into the unauthenticated state.
await fetch(joinUrl("/api/auth/logout"), {
method: "POST",
credentials: "include",
}).catch(() => undefined);
},
};
+56
View File
@@ -0,0 +1,56 @@
import { createContext, useContext } from "react";
import type { User } from "@/types";
/**
* Tri-state lifecycle for the auth surface.
*
* - "loading" → we haven't asked /api/auth/me yet, or the result
* is still in flight. Route guards render a
* placeholder so we don't bounce the user to
* /login before the cookie has had a chance to
* prove itself.
* - "authenticated" → /me returned a real User. The rest of the app
* can safely render protected surfaces.
* - "unauthenticated"→ /me failed (no cookie, expired cookie, server
* down). Route guards redirect to /login.
*/
export type AuthStatus = "loading" | "authenticated" | "unauthenticated";
export interface AuthContextValue {
status: AuthStatus;
user: User | null;
/**
* Authenticate against /api/auth/login. Throws `ApiError` on failure
* (with `code` = "invalid_credentials" | "account_disabled" |
* "rate_limited" | "error"). On success, status flips to
* "authenticated" and `user` is populated.
*/
login: (username: string, password: string) => Promise<void>;
/**
* Hit /api/auth/logout and clear local state. Always succeeds
* locally — even if the server is unreachable, the operator is
* effectively signed out from the SPA's perspective.
*/
logout: () => Promise<void>;
/**
* Re-run the /api/auth/me probe. Useful after a profile edit on the
* admin pages so the sidebar reflects the new role without a
* full reload.
*/
refresh: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
/**
* Read the current auth context. Throws when used outside of an
* `<AuthProvider>` so consumers fail loudly during development instead
* of silently rendering the unauthenticated branch.
*/
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used inside <AuthProvider>");
}
return ctx;
}
+60 -13
View File
@@ -7,6 +7,7 @@ import {
Inbox as InboxIcon,
Layers,
LayoutDashboard,
LogOut,
Receipt,
Stethoscope,
Upload as UploadIcon,
@@ -14,6 +15,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useReconciliation } from "@/hooks/useReconciliation";
import { useAuth } from "@/auth/useAuth";
const nav = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
@@ -188,25 +190,70 @@ export function Sidebar() {
</nav>
{/* Operator card — always at the bottom. Ringed, mono id, the
account role under the name. */}
account role under the name. The avatar initial + username +
role all come from `useAuth()` now (replaced the hardcoded
"Jordan K. / Administrator"); the Sign out button next to it
hits `authApi.logout` and drops the operator back at /login. */}
<div className="px-3 py-4 border-t border-sidebar-border/80">
<div className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 cursor-default">
<div className="relative h-8 w-8 rounded-full bg-accent/20 ring-1 ring-inset ring-accent/40 flex items-center justify-center text-[10.5px] font-semibold text-accent mono">
JK
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[hsl(var(--success))] ring-2 ring-sidebar" />
</div>
<div className="leading-tight min-w-0">
<div className="text-[13px] font-medium truncate">Jordan K.</div>
<div className="mono text-[10px] text-muted-foreground tracking-wider uppercase">
Administrator
</div>
</div>
</div>
<CurrentUserCard />
</div>
</aside>
);
}
/**
* Bottom-of-sidebar identity card. Driven by `useAuth()` so the
* username + role update whenever the operator's session changes
* (e.g. an admin re-grants a role via the admin pages, or the
* session expires and a fresh login lands them back with a
* different username).
*/
function CurrentUserCard() {
const { user, logout } = useAuth();
if (!user) {
// Shouldn't happen — the route guard ensures the sidebar only
// mounts after auth is established. Render a harmless placeholder
// so the layout doesn't reflow if the context ever lags.
return (
<div
className="flex items-center gap-3 rounded-md p-2 text-[12px] text-muted-foreground"
aria-hidden
>
<div className="h-8 w-8 rounded-full bg-muted/40" />
<div className="leading-tight">Signed out</div>
</div>
);
}
// Use the first letter of the username for the avatar monogram;
// fall back to "?" if the username is somehow empty.
const initial = user.username?.[0]?.toUpperCase() ?? "?";
return (
<div className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 group">
<div className="relative h-8 w-8 rounded-full bg-accent/20 ring-1 ring-inset ring-accent/40 flex items-center justify-center text-[10.5px] font-semibold text-accent mono shrink-0">
{initial}
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[hsl(var(--success))] ring-2 ring-sidebar" />
</div>
<div className="leading-tight min-w-0 flex-1">
<div className="text-[13px] font-medium truncate">{user.username}</div>
<div className="mono text-[10px] text-muted-foreground tracking-wider uppercase">
{user.role}
</div>
</div>
<button
type="button"
onClick={() => {
void logout();
}}
aria-label="Sign out"
title="Sign out"
className="shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring opacity-60 group-hover:opacity-100"
>
<LogOut className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
</div>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="px-3 mb-1.5 eyebrow text-muted-foreground/60">
+5 -3
View File
@@ -55,9 +55,11 @@ function Sparkline({ breakdown }: { breakdown: ScoreBreakdown | null }) {
);
}
type Props =
| { row: InboxClaimRow; accent: Accent; onClick: () => void }
| { row: InboxCandidateRow; accent: Accent; onClick: () => void };
type Props = {
row: InboxClaimRow | InboxCandidateRow;
accent: Accent;
onClick: () => void;
};
export function InboxRow({ row, accent, onClick }: Props) {
const isCandidate = row.kind === "remit";
+169
View File
@@ -0,0 +1,169 @@
import { useQuery } from "@tanstack/react-query";
import { useSyncExternalStore } from "react";
import {
api,
type DashboardSummaryParams,
} from "@/lib/api";
import { useAppStore } from "@/store";
import {
type Claim,
type DashboardKpis,
type DashboardSummary,
type MonthlyBucket,
type Provider,
} from "@/types";
/**
* Aggregated Dashboard view (KPIs + 6-month sparkline + top providers +
* recent denials). One round-trip replaces the four hook calls the
* Dashboard SPA previously needed (`useClaims` for KPIs, `useClaims` for
* denials, `useProviders`, `useActivity`).
*
* Falls back to the in-memory zustand store when no backend is
* configured. The fallback computes the same shape from `sampleClaims`
* + `sampleProviders` so the Dashboard renders consistently in both
* modes.
*/
export function useDashboardSummary(params: DashboardSummaryParams = {}) {
const fallback = useSyncExternalStore(
(cb) => useAppStore.subscribe(cb),
() => useAppStore.getState(),
() => useAppStore.getState()
);
const q = useQuery<DashboardSummary>({
queryKey: ["dashboard-summary", params],
queryFn: () => api.getDashboardSummary(params),
enabled: api.isConfigured,
});
if (!api.isConfigured) {
return {
data: synthesizeFromSamples(fallback.claims, fallback.providers, params),
isLoading: false,
isError: false,
error: null,
refetch: () => Promise.resolve(),
} as const;
}
return q;
}
const MONTH_LABELS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
/**
* Mirror of the backend's `Store.get_dashboard_summary` for the sample
* fallback. Buckets in local time (matches the existing Dashboard's
* behavior pre-summary); the live backend buckets in UTC. Counts,
* sums, top providers, and denials are computed from the in-memory
* arrays exactly the same way the backend computes them from the DB.
*/
function synthesizeFromSamples(
claims: Claim[],
providers: Provider[],
params: DashboardSummaryParams,
): DashboardSummary {
const months = params.months ?? 6;
const topProvidersCap = params.topProviders ?? 4;
const denialsCap = params.denials ?? 5;
const now = new Date();
const bucketKeys: { year: number; month: number; label: string }[] = [];
for (let i = months - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
bucketKeys.push({
year: d.getFullYear(),
month: d.getMonth(),
label: MONTH_LABELS[d.getMonth()]!,
});
}
const perBucket: Record<
string,
{ count: number; billed: number; received: number; denied: number }
> = {};
for (const k of bucketKeys) {
perBucket[`${k.year}-${k.month}`] = {
count: 0, billed: 0, received: 0, denied: 0,
};
}
let billedTotal = 0;
let receivedTotal = 0;
let deniedCount = 0;
let pendingCount = 0;
const denialsList: Claim[] = [];
for (const c of claims) {
const billed = c.billedAmount ?? 0;
const received = c.receivedAmount ?? 0;
billedTotal += billed;
receivedTotal += received;
const status = (c.status ?? "").toLowerCase();
if (status === "denied") {
deniedCount += 1;
denialsList.push(c);
}
if (status === "submitted" || status === "pending") pendingCount += 1;
const sub = c.submissionDate ? new Date(c.submissionDate) : null;
if (sub) {
const key = `${sub.getFullYear()}-${sub.getMonth()}`;
if (perBucket[key]) {
const b = perBucket[key]!;
b.count += 1;
b.billed += billed;
b.received += received;
if (status === "denied") b.denied += 1;
}
}
}
let runningAr = 0;
const monthly: MonthlyBucket[] = bucketKeys.map((k) => {
const key = `${k.year}-${k.month}`;
const b = perBucket[key]!;
runningAr = Math.max(0, runningAr + b.billed - b.received);
return {
month: `${k.year}-${String(k.month + 1).padStart(2, "0")}`,
label: k.label,
count: b.count,
billed: b.billed,
received: b.received,
denied: b.denied,
ar: runningAr,
denialRate: b.count ? (b.denied / b.count) * 100 : 0,
};
});
denialsList.sort(
(a, b) =>
new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime()
);
const topProviders = [...providers]
.sort((a, b) => b.claimCount - a.claimCount)
.slice(0, topProvidersCap);
const kpis: DashboardKpis = {
claimCount: claims.length,
billedTotal,
receivedTotal,
outstandingAr: billedTotal - receivedTotal,
deniedCount,
pendingCount,
denialRate: claims.length ? (deniedCount / claims.length) * 100 : 0,
};
return {
kpis,
monthly,
topProviders,
recentDenials: denialsList.slice(0, denialsCap),
providerCount: providers.length,
asOf: now.toISOString(),
};
}
+156 -167
View File
@@ -27,6 +27,7 @@ import type {
ClaimDetail,
ClaimOutput,
ClaimPayment,
DashboardSummary,
Envelope,
FinancialInfo,
MatchResponse,
@@ -41,10 +42,32 @@ import type {
UnmatchedResponse,
BatchSummary as ParserBatchSummary,
} from "@/types";
import {
authedFetch,
authedFetchResponse,
} from "@/auth/api";
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
/**
* Base URL the SPA uses for API calls. Comes from `VITE_API_BASE_URL` at
* build time.
*
* Two distinct cases matter here:
*
* - **Env var absent** (undefined) → the SPA was built with no backend
* in mind. Hooks fall back to the in-memory zustand sample store so
* the UI still renders. Treat as "not configured".
*
* - **Env var present**, even set to an empty string → the deployer
* explicitly opted into "the SPA talks to a backend reachable from
* the browser". An empty value is meaningful: it's how the Docker
* frontend ([frontend/Dockerfile]) asks for same-origin `/api/*`
* calls (nginx proxies those to the backend service). Both empty
* and a full URL count as "configured".
*/
const BASE_URL_RAW = import.meta.env.VITE_API_BASE_URL as string | undefined;
const BASE_URL = BASE_URL_RAW ?? "";
export const isConfigured = BASE_URL.length > 0;
export const isConfigured = BASE_URL_RAW !== undefined;
// ---------------------------------------------------------------------------
// Shared types
@@ -124,6 +147,15 @@ export interface ListActivityParams {
limit?: number;
}
export interface DashboardSummaryParams {
/** Trailing-months window for the sparkline buckets. */
months?: number;
/** Cap on `topProviders`. */
topProviders?: number;
/** Cap on `recentDenials`. */
denials?: number;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
@@ -165,10 +197,6 @@ export class ApiError extends Error {
}
}
function joinUrl(path: string): string {
return `${BASE_URL.replace(/\/$/, "")}${path}`;
}
async function readErrorBody(res: Response): Promise<string> {
try {
const t = await res.text();
@@ -282,11 +310,18 @@ async function parse837(
form.append("file", file, file.name);
const accept = onProgress ? "application/x-ndjson" : "application/json";
const res = await fetch(joinUrl(`/api/parse-837?${params.toString()}`), {
method: "POST",
body: form,
headers: { Accept: accept },
});
// authedFetchResponse still hits fetch() (and still does the
// 401-redirect for any non-/api/auth path), but hands back the raw
// Response so we can stream NDJSON line-by-line below. For non-stream
// callers it returns the parsed JSON.
const res = await authedFetchResponse(
`/api/parse-837?${params.toString()}`,
{
method: "POST",
body: form,
headers: { Accept: accept },
}
);
if (!res.ok) {
const detail = await readErrorBody(res);
@@ -318,11 +353,17 @@ async function parse835(
form.append("file", file, file.name);
const accept = onProgress ? "application/x-ndjson" : "application/json";
const res = await fetch(joinUrl(`/api/parse-835?${params.toString()}`), {
method: "POST",
body: form,
headers: { Accept: accept },
});
// See parse837 for why this uses authedFetchResponse instead of
// authedFetch — NDJSON streaming needs the raw Response so we can
// iterate `res.body` one chunk at a time.
const res = await authedFetchResponse(
`/api/parse-835?${params.toString()}`,
{
method: "POST",
body: form,
headers: { Accept: accept },
}
);
if (!res.ok) {
const detail = await readErrorBody(res);
@@ -348,16 +389,7 @@ async function parse999(
if (!isConfigured) throw notConfiguredError();
const form = new FormData();
form.append("file", file, file.name);
const res = await fetch(joinUrl("/api/parse-999"), {
method: "POST",
body: form,
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
const body = (await res.json()) as {
const body = await authedFetch<{
ack: {
id: number;
accepted_count: number;
@@ -368,7 +400,14 @@ async function parse999(
raw_999_text: string;
};
parsed: unknown;
};
}>("/api/parse-999", {
method: "POST",
body: form,
// Don't set Content-Type — the browser sets the multipart
// boundary when the body is FormData. authedFetch only adds a
// default Accept, which we override here.
headers: { Accept: "application/json" },
});
return {
ack: {
id: body.ack.id,
@@ -389,14 +428,14 @@ async function parse999(
async function health(): Promise<HealthResponse | null> {
if (!isConfigured) return null;
const res = await fetch(joinUrl("/api/health"));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
// health() is best-effort — used by the side-scan ping. Don't let a
// 401 from authedFetch redirect the operator; just return null when
// anything goes wrong.
try {
return await authedFetch<HealthResponse>("/api/health");
} catch {
return null;
}
return (await res.json()) as HealthResponse;
}
// ---------------------------------------------------------------------------
@@ -409,48 +448,26 @@ async function listBatches(limit?: number): Promise<BatchSummary[]> {
if (!isConfigured) throw notConfiguredError();
const params: Record<string, unknown> = {};
if (limit !== undefined) params.limit = limit;
const res = await fetch(joinUrl(`/api/batches${qs(params)}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
const body = (await res.json()) as { items: BatchSummary[] };
const body = await authedFetch<{ items: BatchSummary[] }>(
`/api/batches${qs(params)}`
);
return body.items;
}
async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/batches/${encodeURIComponent(id)}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as ParseResult837 | ParseResult835;
return authedFetch<ParseResult837 | ParseResult835>(
`/api/batches/${encodeURIComponent(id)}`
);
}
async function listClaims<T = unknown>(
params: ListClaimsParams
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/claims${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
return authedFetch<PaginatedResponse<T>>(
`/api/claims${qs(params as Record<string, unknown>)}`
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
}
/**
@@ -466,14 +483,31 @@ async function listClaims<T = unknown>(
*/
async function getClaimDetail(id: string): Promise<ClaimDetail> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/claims/${encodeURIComponent(id)}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
try {
return await authedFetch<ClaimDetail>(
`/api/claims/${encodeURIComponent(id)}`
);
} catch (err) {
// authedFetch throws the richer auth/api.ts ApiError (carries
// `status` + `code` + `detail`). The drawer code branches on
// `.status === 404` for the "claim doesn't exist" state, and
// existing callers use `instanceof ApiError` (the lib/api.ts one)
// to detect structured failures. Re-wrap into the lib/api.ts
// ApiError so both keep working unchanged.
if (
err &&
typeof err === "object" &&
"status" in err &&
typeof (err as { status: unknown }).status === "number"
) {
const e = err as { status: number; code?: string; detail?: string };
throw new ApiError(
e.status,
e.detail ?? e.code ?? "error"
);
}
throw err;
}
return (await res.json()) as ClaimDetail;
}
/**
@@ -495,8 +529,14 @@ async function serializeClaim837(
id: string,
): Promise<{ text: string; filename: string }> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/claims/${encodeURIComponent(id)}/serialize-837`),
// authedFetchText gives us the body as a string with the same
// 401-redirect + error-shape behavior as authedFetch. We need the
// Response object for the Content-Disposition header, so this
// function delegates to authedFetchResponse and reads text +
// headers itself. No Accept negotiation — the endpoint always
// returns text/x12.
const res = await authedFetchResponse(
`/api/claims/${encodeURIComponent(id)}/serialize-837`
);
if (!res.ok) {
const detail = await readErrorBody(res);
@@ -516,17 +556,9 @@ async function listRemittances<T = unknown>(
params: ListRemittancesParams
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/remittances${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
return authedFetch<PaginatedResponse<T>>(
`/api/remittances${qs(params as Record<string, unknown>)}`
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
}
/**
@@ -535,14 +567,7 @@ async function listRemittances<T = unknown>(
*/
async function getRemittance<T = unknown>(id: string): Promise<T> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/remittances/${encodeURIComponent(id)}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
return (await res.json()) as T;
return authedFetch<T>(`/api/remittances/${encodeURIComponent(id)}`);
}
/**
@@ -556,51 +581,45 @@ async function getRemittance<T = unknown>(id: string): Promise<T> {
*/
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(
`/api/batch-diff?${qs({ a, b })}`,
),
{ headers: { Accept: "application/json" } },
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
return (await res.json()) as BatchDiff;
return authedFetch<BatchDiff>(`/api/batch-diff?${qs({ a, b })}`);
}
async function listProviders<T = unknown>(
params: ListProvidersParams = {}
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/providers${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
return authedFetch<PaginatedResponse<T>>(
`/api/providers${qs(params as Record<string, unknown>)}`
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
}
async function listActivity<T = unknown>(
params: ListActivityParams = {}
): Promise<PaginatedResponse<T>> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/activity${qs(params as Record<string, unknown>)}`),
{ headers: { Accept: "application/json" } }
return authedFetch<PaginatedResponse<T>>(
`/api/activity${qs(params as Record<string, unknown>)}`
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(
`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`
);
}
return (await res.json()) as PaginatedResponse<T>;
}
/**
* Fetch the aggregated Dashboard view (KPIs, monthly sparkline buckets,
* top providers, recent denials) in one round-trip.
*
* Drives `GET /api/dashboard/summary`. Aggregations are server-side so
* totals stay accurate past the 1000-row cap on `/api/claims`. The
* `useDashboardSummary` hook synthesizes the same shape from the
* in-memory sample store when no backend is configured.
*/
async function getDashboardSummary(
params: DashboardSummaryParams = {}
): Promise<DashboardSummary> {
if (!isConfigured) throw notConfiguredError();
const query: Record<string, unknown> = {};
if (params.months !== undefined) query.months = params.months;
if (params.topProviders !== undefined) query.top_providers = params.topProviders;
if (params.denials !== undefined) query.denials = params.denials;
return authedFetch<DashboardSummary>(`/api/dashboard/summary${qs(query)}`);
}
// ---------------------------------------------------------------------------
@@ -611,14 +630,7 @@ async function listActivity<T = unknown>(
async function listUnmatched(): Promise<UnmatchedResponse> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/reconciliation/unmatched`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
return (await res.json()) as UnmatchedResponse;
return authedFetch<UnmatchedResponse>(`/api/reconciliation/unmatched`);
}
async function matchRemit(
@@ -626,36 +638,20 @@ async function matchRemit(
remitId: string
): Promise<MatchResponse> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/reconciliation/match`), {
return authedFetch<MatchResponse>(`/api/reconciliation/match`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ claim_id: claimId, remit_id: remitId }),
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
return (await res.json()) as MatchResponse;
}
async function unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/reconciliation/unmatch`), {
return authedFetch<{ claim: UnmatchedClaim }>(`/api/reconciliation/unmatch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ claim_id: claimId }),
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
return (await res.json()) as { claim: UnmatchedClaim };
}
// ---------------------------------------------------------------------------
@@ -694,15 +690,12 @@ async function listAcks(params: { limit?: number } = {}): Promise<PaginatedRespo
if (!isConfigured) throw notConfiguredError();
const query: Record<string, unknown> = {};
if (params.limit !== undefined) query.limit = params.limit;
const res = await fetch(
joinUrl(`/api/acks${qs(query)}`),
{ headers: { Accept: "application/json" } }
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
const body = (await res.json()) as { items: RawAckRow[]; total: number; returned: number; has_more: boolean };
const body = await authedFetch<{
items: RawAckRow[];
total: number;
returned: number;
has_more: boolean;
}>(`/api/acks${qs(query)}`);
return {
items: body.items.map(mapAck),
total: body.total,
@@ -713,14 +706,9 @@ async function listAcks(params: { limit?: number } = {}): Promise<PaginatedRespo
async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(joinUrl(`/api/acks/${encodeURIComponent(String(id))}`), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
const row = (await res.json()) as RawAckRow & { raw_json: unknown };
const row = await authedFetch<RawAckRow & { raw_json: unknown }>(
`/api/acks/${encodeURIComponent(String(id))}`
);
return { ...mapAck(row), rawJson: row.raw_json };
}
@@ -741,6 +729,7 @@ export const api = {
getRemittance,
listProviders,
listActivity,
getDashboardSummary,
listUnmatched,
matchRemit,
unmatchClaim,
+8 -1
View File
@@ -2,6 +2,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "@/auth/AuthProvider";
import App from "./App";
import "./index.css";
@@ -18,7 +19,13 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
{/* AuthProvider must sit inside BrowserRouter (so it can use
react-router hooks) but outside the route tree (so the
`/login` route still has access to auth state for the
auto-redirect when the operator is already signed in). */}
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
+240 -147
View File
@@ -13,93 +13,104 @@ import { PageHeader } from "@/components/PageHeader";
import { KpiCard } from "@/components/KpiCard";
import { ActivityFeed } from "@/components/ActivityFeed";
import { AnimatedNumber } from "@/components/AnimatedNumber";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { fmt } from "@/lib/format";
import { useAppStore } from "@/store";
import { useDashboardSummary } from "@/hooks/useDashboardSummary";
import { useActivity } from "@/hooks/useActivity";
import type { MonthlyBucket } from "@/types";
const MONTHS_BACK = 6;
function greetingForHour(hour: number): string {
if (hour < 12) return "Good morning.";
if (hour < 18) return "Good afternoon.";
return "Good evening.";
}
function buildMonthly(claims: ReturnType<typeof useAppStore.getState>["claims"]) {
const now = new Date();
const months: {
key: string;
label: string;
count: number;
billed: number;
received: number;
denied: number;
}[] = [];
for (let i = MONTHS_BACK - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push({
key: `${d.getFullYear()}-${d.getMonth()}`,
label: d.toLocaleString("en-US", { month: "short" }),
count: 0,
billed: 0,
received: 0,
denied: 0,
});
}
const index = new Map(months.map((m, i) => [m.key, i]));
for (const c of claims) {
const d = new Date(c.submissionDate);
const k = `${d.getFullYear()}-${d.getMonth()}`;
const i = index.get(k);
if (i === undefined) continue;
months[i]!.count += 1;
months[i]!.billed += c.billedAmount;
months[i]!.received += c.receivedAmount;
if (c.status === "denied") months[i]!.denied += 1;
}
let running = 0;
const ar: number[] = [];
for (const m of months) {
running += m.billed - m.received;
ar.push(Math.max(0, running));
}
interface Delta {
value: string;
direction: "up" | "down";
positive: boolean;
}
/**
* Percent change from `prev` to `curr`. Returns undefined when the prior
* period is zero (no meaningful baseline).
*/
function pctDelta(curr: number, prev: number): Delta | undefined {
if (prev === 0) return undefined;
const change = ((curr - prev) / prev) * 100;
return {
count: months.map((m) => m.count),
billed: months.map((m) => m.billed),
received: months.map((m) => m.received),
ar,
denialRate: months.map((m) => (m.count ? (m.denied / m.count) * 100 : 0)),
value: `${change >= 0 ? "+" : ""}${change.toFixed(1)}%`,
direction: change >= 0 ? "up" : "down",
positive: change >= 0,
};
}
/**
* Absolute percentage-point change from `prev` to `curr`. For metrics
* where "down is good" (denial rate), `positive: true` means the change
* is favorable.
*/
function ptsDelta(curr: number, prev: number, lowerIsBetter: boolean): Delta | undefined {
const change = curr - prev;
return {
value: `${change >= 0 ? "+" : ""}${change.toFixed(1)} pts`,
direction: change >= 0 ? "up" : "down",
positive: lowerIsBetter ? change <= 0 : change >= 0,
};
}
function bucketField<T extends keyof MonthlyBucket>(
monthly: MonthlyBucket[],
key: T
): MonthlyBucket[T][] {
return monthly.map((m) => m[key]);
}
export function Dashboard() {
const claims = useAppStore((s) => s.claims);
const providers = useAppStore((s) => s.providers);
const activity = useAppStore((s) => s.activity);
const summaryQ = useDashboardSummary({
months: 6,
topProviders: 4,
denials: 5,
});
// Activity has its own 30s polling cadence (see useActivity); keep it
// separate so the summary's single fetch doesn't gate activity freshness.
const activityQ = useActivity({ limit: 10 });
const kpis = useMemo(() => {
const billed = claims.reduce((s, c) => s + c.billedAmount, 0);
const received = claims.reduce((s, c) => s + c.receivedAmount, 0);
const outstandingAr = billed - received;
const denied = claims.filter((c) => c.status === "denied").length;
const denialRate = claims.length > 0 ? (denied / claims.length) * 100 : 0;
const pending = claims.filter(
(c) => c.status === "submitted" || c.status === "pending"
).length;
return {
count: claims.length,
billed,
received,
outstandingAr,
denialRate,
pending,
};
}, [claims]);
const summary = summaryQ.data;
const kpis = summary?.kpis;
const monthly = summary?.monthly ?? [];
const topProviders = summary?.topProviders ?? [];
const denials = summary?.recentDenials ?? [];
const providerCount = summary?.providerCount ?? 0;
const activity = activityQ.data?.items ?? [];
const monthly = useMemo(() => buildMonthly(claims), [claims]);
const isError = summaryQ.isError || activityQ.isError;
const firstError = summaryQ.error ?? activityQ.error;
const topProviders = useMemo(
() => [...providers].sort((a, b) => b.claimCount - a.claimCount).slice(0, 4),
[providers]
);
const refetchAll = () => {
void summaryQ.refetch();
void activityQ.refetch();
};
const topDenials = useMemo(
() => claims.filter((c) => c.status === "denied").slice(0, 5),
[claims]
);
// "This month vs. last month" deltas from the sparkline series.
// With fewer than two months of data we suppress the delta entirely
// (KpiCard handles a missing delta gracefully).
const lastIdx = monthly.length - 1;
const prevIdx = lastIdx - 1;
const billedDelta =
prevIdx >= 0 && kpis
? pctDelta(monthly[lastIdx]!.billed, monthly[prevIdx]!.billed)
: undefined;
const receivedDelta =
prevIdx >= 0 && kpis
? pctDelta(monthly[lastIdx]!.received, monthly[prevIdx]!.received)
: undefined;
const denialDelta =
prevIdx >= 0 && kpis
? ptsDelta(monthly[lastIdx]!.denialRate, monthly[prevIdx]!.denialRate, true)
: undefined;
// Stagger choreography — the hero lands first, then the KPIs in
// a left-to-right wave, then the supporting cards. Total
@@ -109,8 +120,19 @@ export function Dashboard() {
const kpiStep = 60;
const sectionBase = kpiBase + kpiStep * 5 + 80;
const greeting = useMemo(() => greetingForHour(new Date().getHours()), []);
const summaryLoaded = summaryQ.data !== undefined;
return (
<div className="space-y-6 lg:space-y-10">
{isError ? (
<ErrorState
message="Couldn't load dashboard data from the backend."
detail={firstError instanceof Error ? firstError.message : String(firstError)}
onRetry={refetchAll}
/>
) : null}
{/* Hero — the dashboard's only moment of editorial display. The
ghost total sits behind the greeting at single-digit opacity
so it reads as a watermark, not a competing headline. */}
@@ -128,20 +150,27 @@ export function Dashboard() {
lineHeight: 1,
}}
>
{fmt.usd(kpis.billed)}
{fmt.usd(kpis?.billedTotal ?? 0)}
</div>
<div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
<div className="min-w-0">
<PageHeader
eyebrow={`Today · ${fmt.date(new Date().toISOString())}`}
title={<>Good morning, Jordan.</>}
title={<>{greeting}</>}
subtitle={
<>
<span className="display text-foreground">Three NPIs</span> are
in flight. {kpis.pending} claims pending, clearinghouse cycle
is normal.
</>
summaryLoaded ? (
<>
<span className="display text-foreground">
{providerCount} NPI{providerCount === 1 ? "" : "s"}
</span>{" "}
{providerCount === 1 ? "is" : "are"} in flight.{" "}
{kpis?.pendingCount ?? 0} claims pending, clearinghouse
cycle is normal.
</>
) : (
<>Pulling the latest from the clearinghouse</>
)
}
/>
</div>
@@ -165,11 +194,18 @@ export function Dashboard() {
<KpiCard
label="Claims"
icon={Receipt}
sparkline={monthly.count}
sparkline={bucketField(monthly, "count")}
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 0 * kpiStep}ms` }}
value={
<AnimatedNumber value={kpis.count} format={(n) => fmt.num(Math.round(n))} />
summaryQ.isLoading ? (
<Skeleton variant="text" className="h-8 w-20" />
) : (
<AnimatedNumber
value={kpis?.claimCount ?? 0}
format={(n) => fmt.num(Math.round(n))}
/>
)
}
hint="last 6 months"
/>
@@ -177,51 +213,71 @@ export function Dashboard() {
label="Billed"
icon={CircleDollarSign}
accent="accent"
sparkline={monthly.billed}
sparkline={bucketField(monthly, "billed")}
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 1 * kpiStep}ms` }}
value={<AnimatedNumber value={kpis.billed} format={fmt.usd} />}
delta={{ value: "+12.4%", direction: "up", positive: true }}
value={
summaryQ.isLoading ? (
<Skeleton variant="text" className="h-8 w-28" />
) : (
<AnimatedNumber value={kpis?.billedTotal ?? 0} format={fmt.usd} />
)
}
delta={billedDelta}
/>
<KpiCard
label="Received"
icon={Banknote}
accent="success"
sparkline={monthly.received}
sparkline={bucketField(monthly, "received")}
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 2 * kpiStep}ms` }}
value={<AnimatedNumber value={kpis.received} format={fmt.usd} />}
delta={{ value: "+8.1%", direction: "up", positive: true }}
value={
summaryQ.isLoading ? (
<Skeleton variant="text" className="h-8 w-28" />
) : (
<AnimatedNumber value={kpis?.receivedTotal ?? 0} format={fmt.usd} />
)
}
delta={receivedDelta}
/>
<KpiCard
label="Pending AR"
icon={Clock}
accent="warning"
sparkline={monthly.ar}
sparkline={bucketField(monthly, "ar")}
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 3 * kpiStep}ms` }}
value={
<AnimatedNumber
value={kpis.outstandingAr}
format={(n) => fmt.usd(Math.max(0, n))}
/>
summaryQ.isLoading ? (
<Skeleton variant="text" className="h-8 w-28" />
) : (
<AnimatedNumber
value={kpis?.outstandingAr ?? 0}
format={(n) => fmt.usd(Math.max(0, n))}
/>
)
}
hint={`${kpis.pending} in queue`}
hint={`${kpis?.pendingCount ?? 0} in queue`}
/>
<KpiCard
label="Denial rate"
icon={TrendingDown}
accent="destructive"
sparkline={monthly.denialRate}
sparkline={bucketField(monthly, "denialRate")}
className="animate-fade-in-up"
style={{ animationDelay: `${kpiBase + 4 * kpiStep}ms` }}
value={
<AnimatedNumber
value={kpis.denialRate}
format={(n) => fmt.pct(n)}
/>
summaryQ.isLoading ? (
<Skeleton variant="text" className="h-8 w-16" />
) : (
<AnimatedNumber
value={kpis?.denialRate ?? 0}
format={(n) => fmt.pct(n)}
/>
)
}
delta={{ value: "-1.2 pts", direction: "down", positive: true }}
delta={denialDelta}
/>
</section>
@@ -246,7 +302,20 @@ export function Dashboard() {
</span>
</CardHeader>
<CardContent className="pt-0">
<ActivityFeed items={activity.slice(0, 10)} />
{activityQ.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : activity.length === 0 ? (
<EmptyState
eyebrow="Activity · log idle"
message="Activity will appear here after the first parse."
/>
) : (
<ActivityFeed items={activity.slice(0, 10)} />
)}
</CardContent>
</Card>
@@ -258,52 +327,76 @@ export function Dashboard() {
</p>
</CardHeader>
<CardContent className="pt-0">
<ul className="space-y-4">
{topProviders.map((p, i) => (
<li key={p.npi} className="flex items-center gap-3">
<div className="h-7 w-7 rounded-md bg-muted/60 ring-1 ring-inset ring-border/40 flex items-center justify-center mono text-[10.5px] text-muted-foreground">
{String(i + 1).padStart(2, "0")}
</div>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium truncate">{p.name}</div>
<div className="mono text-[10.5px] text-muted-foreground">
NPI {p.npi}
{summaryQ.isLoading ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : topProviders.length === 0 ? (
<EmptyState
eyebrow="Providers · directory empty"
message="Providers appear here as soon as an 837P file is parsed."
/>
) : (
<ul className="space-y-4">
{topProviders.map((p, i) => (
<li key={p.npi} className="flex items-center gap-3">
<div className="h-7 w-7 rounded-md bg-muted/60 ring-1 ring-inset ring-border/40 flex items-center justify-center mono text-[10.5px] text-muted-foreground">
{String(i + 1).padStart(2, "0")}
</div>
</div>
<div className="text-right">
<div className="display mono text-[15px]">{fmt.num(p.claimCount)}</div>
<div className="mono text-[10.5px] text-muted-foreground">
{fmt.usd(p.outstandingAr)} AR
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium truncate">{p.name}</div>
<div className="mono text-[10.5px] text-muted-foreground">
NPI {p.npi}
</div>
</div>
</div>
</li>
))}
</ul>
<div className="text-right">
<div className="display mono text-[15px]">{fmt.num(p.claimCount)}</div>
<div className="mono text-[10.5px] text-muted-foreground">
{fmt.usd(p.outstandingAr)} AR
</div>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</section>
{topDenials.length > 0 ? (
<section
className="animate-fade-in-up"
style={{ animationDelay: `${sectionBase + 90}ms` }}
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-[14px]">
<AlertCircle
className="h-3.5 w-3.5 text-destructive"
strokeWidth={1.75}
/>
Recent denials
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Investigate and resubmit where appropriate.
</p>
</CardHeader>
<CardContent className="pt-0">
<section
className="animate-fade-in-up"
style={{ animationDelay: `${sectionBase + 90}ms` }}
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-[14px]">
<AlertCircle
className="h-3.5 w-3.5 text-destructive"
strokeWidth={1.75}
/>
Recent denials
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Investigate and resubmit where appropriate.
</p>
</CardHeader>
<CardContent className="pt-0">
{summaryQ.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : denials.length === 0 ? (
<EmptyState
eyebrow="Denials · none in window"
message="No denied claims in the latest batch."
/>
) : (
<ul className="divide-y divide-border/40">
{topDenials.map((c) => (
{denials.map((c) => (
<li
key={c.id}
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
@@ -328,10 +421,10 @@ export function Dashboard() {
</li>
))}
</ul>
</CardContent>
</Card>
</section>
) : null}
)}
</CardContent>
</Card>
</section>
</div>
);
}
}
+11
View File
@@ -5,6 +5,17 @@ import Inbox from "./Inbox";
import * as inboxApi from "@/lib/inbox-api";
import * as downloadModule from "@/lib/download";
// The Inbox page now gates the Resubmit/Dismiss BulkBars behind
// <RoleGate allow={["admin", "user"]}>. Tests need an "admin"
// principal so the bulk bar still renders — otherwise the
// SP8 resubmit-with-download assertion below has nothing to click.
vi.mock("@/auth/useAuth", () => ({
useAuth: () => ({
user: { id: 1, username: "test-admin", role: "admin" },
status: "authenticated",
}),
}));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
+26 -15
View File
@@ -18,6 +18,7 @@ import {
resubmitRejectedWithDownload,
} from "@/lib/inbox-api";
import { downloadBlob } from "@/lib/download";
import { RoleGate } from "@/auth/RoleGate";
type LaneKey = "rejected" | "candidates" | "unmatched" | "done_today";
@@ -202,21 +203,31 @@ export default function Inbox() {
/>
</main>
{/* Per-lane bulk bars. Each shows when its lane has a selection. */}
<BulkBar
lane="rejected"
count={selected.rejected.length}
onResubmit={onResubmit}
onDismiss={() => {}}
onExport={() => onExport("rejected")}
/>
<BulkBar
lane="candidates"
count={selected.candidates.length}
onResubmit={() => {}}
onDismiss={onDismiss}
onExport={() => onExport("candidates")}
/>
{/* Per-lane bulk bars. Each shows when its lane has a selection.
The two write-affordance BulkBars (rejected → resubmit,
candidates → dismiss) are gated by RoleGate so a viewer-role
account can still SEE the rows and select them but cannot
trigger a state change. The export buttons live inside the
same BulkBar component but are read-only, so we let them
remain visible to viewers. */}
<RoleGate allow={["admin", "user"]} fallback={null}>
<BulkBar
lane="rejected"
count={selected.rejected.length}
onResubmit={onResubmit}
onDismiss={() => {}}
onExport={() => onExport("rejected")}
/>
</RoleGate>
<RoleGate allow={["admin", "user"]} fallback={null}>
<BulkBar
lane="candidates"
count={selected.candidates.length}
onResubmit={() => {}}
onDismiss={onDismiss}
onExport={() => onExport("candidates")}
/>
</RoleGate>
<BulkBar
lane="unmatched"
count={selected.unmatched.length}
+95
View File
@@ -0,0 +1,95 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { Login } from "./Login";
vi.mock("@/auth/useAuth", () => ({ useAuth: vi.fn() }));
import { useAuth } from "@/auth/useAuth";
function setup() {
const login = vi.fn();
(useAuth as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
user: null,
status: "unauthenticated",
login,
logout: vi.fn(),
refresh: vi.fn(),
});
return { login };
}
describe("Login page", () => {
beforeEach(() => {
vi.resetAllMocks();
cleanup();
});
it("submits username + password", async () => {
const { login } = setup();
login.mockResolvedValue(undefined);
const { getByLabelText, getByRole } = render(
<MemoryRouter initialEntries={["/login"]}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<div>HOME</div>} />
</Routes>
</MemoryRouter>
);
fireEvent.change(getByLabelText(/username/i), {
target: { value: "alice" },
});
fireEvent.change(getByLabelText(/password/i), {
target: { value: "hunter2hunter2" },
});
fireEvent.click(getByRole("button", { name: /sign in/i }));
await waitFor(() =>
expect(login).toHaveBeenCalledWith("alice", "hunter2hunter2")
);
});
it("shows error on failed login", async () => {
const { login } = setup();
login.mockRejectedValue(
Object.assign(new Error("401"), { code: "invalid_credentials" })
);
const { getByLabelText, getByRole, getByText } = render(
<MemoryRouter initialEntries={["/login"]}>
<Routes>
<Route path="/login" element={<Login />} />
</Routes>
</MemoryRouter>
);
fireEvent.change(getByLabelText(/username/i), {
target: { value: "alice" },
});
fireEvent.change(getByLabelText(/password/i), {
target: { value: "wrong" },
});
fireEvent.click(getByRole("button", { name: /sign in/i }));
await waitFor(() =>
expect(getByText(/username or password is incorrect/i)).toBeTruthy()
);
});
it("redirects to `next` query param on success", async () => {
const { login } = setup();
login.mockResolvedValue(undefined);
const { getByLabelText, getByRole, getByText } = render(
<MemoryRouter initialEntries={["/login?next=/claims"]}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/claims" element={<div>CLAIMS</div>} />
</Routes>
</MemoryRouter>
);
fireEvent.change(getByLabelText(/username/i), {
target: { value: "alice" },
});
fireEvent.change(getByLabelText(/password/i), {
target: { value: "hunter2hunter2" },
});
fireEvent.click(getByRole("button", { name: /sign in/i }));
await waitFor(() => expect(getByText("CLAIMS")).toBeTruthy());
});
});
+104
View File
@@ -0,0 +1,104 @@
import { useState, type FormEvent } from "react";
import { useNavigate, useSearchParams, Navigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
/**
* Sign-in screen. Mounted by the `/login` route in `App.tsx`, which
* sits outside `<RequireAuth>` so unauthenticated operators can reach
* it. The page is also reachable via the auto-redirect on a 401 from
* `authedFetch` — that path appends `?next=<current>` so the operator
* lands back on the page they were trying to view.
*
* Error mapping is driven by the backend's `error` code on the
* `ApiError` thrown from `authApi.login`. The codes are defined in
* `backend/src/cyclone/auth/routes.py::login`.
*/
export function Login() {
const { user, login } = useAuth();
const [params] = useSearchParams();
const next = params.get("next") ?? "/";
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Already authenticated — bounce to the original destination (or
// dashboard) so we don't show a sign-in form to a logged-in user.
if (user) return <Navigate to={next} replace />;
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await login(username, password);
navigate(next, { replace: true });
} catch (err) {
const code = (err as { code?: string })?.code ?? "error";
if (code === "invalid_credentials") {
setError("Username or password is incorrect.");
} else if (code === "account_disabled") {
setError("Account is disabled. Contact your administrator.");
} else if (code === "rate_limited") {
setError("Too many attempts. Try again shortly.");
} else {
setError("Sign in failed. Try again.");
}
} finally {
setSubmitting(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background text-foreground p-6">
<form
onSubmit={onSubmit}
className="w-full max-w-sm p-8 rounded-lg border border-border bg-card shadow-lg"
>
<h1 className="text-2xl font-semibold mb-1 tracking-tight">Cyclone</h1>
<p className="text-sm text-muted-foreground mb-6">
Sign in to continue.
</p>
<label className="block mb-4">
<span className="text-sm font-medium">Username</span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
autoComplete="username"
className="mt-1 w-full px-3 py-2 rounded bg-background border border-border text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</label>
<label className="block mb-5">
<span className="text-sm font-medium">Password</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="mt-1 w-full py-2 px-3 rounded bg-background border border-border text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</label>
{error ? (
<div
className="mb-4 text-sm text-red-400 bg-red-400/10 border border-red-400/30 rounded px-3 py-2"
role="alert"
>
{error}
</div>
) : null}
<button
type="submit"
disabled={submitting}
className="w-full py-2 rounded bg-primary text-primary-foreground font-medium disabled:opacity-50 hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{submitting ? "Signing in…" : "Sign in"}
</button>
</form>
</div>
);
}
+12
View File
@@ -27,6 +27,18 @@ vi.mock("@/lib/api", () => ({
},
}));
// Match selected is now behind <RoleGate allow={["admin", "user"]}>.
// The renderIntoContainer helper does not wrap in <AuthProvider>,
// so the hook would otherwise throw. Pretend we're an admin so the
// button still renders and these tests can exercise the two-column
// selection chrome + empty-state paths.
vi.mock("@/auth/useAuth", () => ({
useAuth: () => ({
user: { id: 1, username: "test-admin", role: "admin" },
status: "authenticated",
}),
}));
/**
* Minimal `render` helper using react-dom/client + act(). Mirrors the
* `renderHook` helper in `useReconciliation.test.ts` — see that file's
+17 -8
View File
@@ -9,6 +9,7 @@ import { ErrorState } from "@/components/ui/error-state";
import { PageHeader } from "@/components/PageHeader";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { RoleGate } from "@/auth/RoleGate";
/**
* Two-column manual reconciliation surface. The operator picks one row from
@@ -191,14 +192,22 @@ export function ReconciliationPage() {
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
onClick={handleMatch}
disabled={!selectedClaim || !selectedRemit || match.isPending}
className="min-h-[44px] sm:min-h-0"
>
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
Match selected
</Button>
{/* Match selected is the only true write affordance on this
page — it transitions a claim from "submitted" /
"rejected" to "paid" / "partial" / "denied" / "received"
via `record_manual_match`. Viewers can still pick rows
and see the selection chrome, but the button itself is
disabled + tooltip-explained for them. */}
<RoleGate allow={["admin", "user"]}>
<Button
onClick={handleMatch}
disabled={!selectedClaim || !selectedRemit || match.isPending}
className="min-h-[44px] sm:min-h-0"
>
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
Match selected
</Button>
</RoleGate>
<Button
variant="outline"
onClick={handleClear}
+100 -80
View File
@@ -32,6 +32,7 @@ import type {
ServicePayment,
} from "@/types";
import { cn } from "@/lib/utils";
import { RoleGate } from "@/auth/RoleGate";
// ---------------------------------------------------------------------------
// Streaming state — every claim that arrives from the backend accumulates
@@ -595,67 +596,80 @@ export function Upload() {
</div>
</div>
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
className={cn(
"relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors",
"px-6 py-12 min-h-[160px] cursor-pointer",
dragging
? "border-accent bg-accent/5 ring-2 ring-accent/30"
: "border-border/60 bg-muted/20 hover:bg-muted/30 hover:border-border",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
{/* Soft inner glow when dragging a precision-instrument
"active" state. */}
{/* Write affordances dropzone + Parse button are gated by
RoleGate so a viewer-role account can SEE the configuration
controls (kind, payer) but can't actually ingest a file. */}
<RoleGate allow={["admin", "user"]} fallback={
<div className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-6 py-12 min-h-[160px] flex flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<UploadIcon className="h-5 w-5" strokeWidth={1.5} aria-hidden />
<div className="text-[13.5px] font-medium text-foreground">Read-only access</div>
<div className="mono text-[11px]">
Your role (viewer) cannot ingest new files.
</div>
</div>
}>
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
className={cn(
"h-10 w-10 rounded-md ring-1 ring-inset ring-border/60 flex items-center justify-center transition-colors",
dragging ? "bg-accent/15 text-accent ring-accent/40" : "bg-muted/30 text-muted-foreground"
"relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors",
"px-6 py-12 min-h-[160px] cursor-pointer",
dragging
? "border-accent bg-accent/5 ring-2 ring-accent/30"
: "border-border/60 bg-muted/20 hover:bg-muted/30 hover:border-border",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
<UploadIcon className="h-4 w-4" strokeWidth={1.5} />
</div>
{file ? (
<div className="flex items-center gap-2 text-[13px]">
<FileText
className="h-3.5 w-3.5 text-muted-foreground"
strokeWidth={1.75}
/>
<span className="font-medium">{file.name}</span>
<span className="mono text-muted-foreground">
· {formatBytes(file.size)}
</span>
{/* Soft inner glow when dragging a precision-instrument
"active" state. */}
<div
className={cn(
"h-10 w-10 rounded-md ring-1 ring-inset ring-border/60 flex items-center justify-center transition-colors",
dragging ? "bg-accent/15 text-accent ring-accent/40" : "bg-muted/30 text-muted-foreground"
)}
>
<UploadIcon className="h-4 w-4" strokeWidth={1.5} />
</div>
) : (
<>
<div className="text-[13.5px] font-medium">
{dragging ? "Release to upload" : "Drop a file here, or click to choose"}
{file ? (
<div className="flex items-center gap-2 text-[13px]">
<FileText
className="h-3.5 w-3.5 text-muted-foreground"
strokeWidth={1.75}
/>
<span className="font-medium">{file.name}</span>
<span className="mono text-muted-foreground">
· {formatBytes(file.size)}
</span>
</div>
<div className="mono text-[11px] text-muted-foreground">
.txt the X12 837/835 file as exported from your clearinghouse
</div>
</>
)}
<input
ref={inputRef}
type="file"
accept=".txt"
className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
/>
</div>
) : (
<>
<div className="text-[13.5px] font-medium">
{dragging ? "Release to upload" : "Drop a file here, or click to choose"}
</div>
<div className="mono text-[11px] text-muted-foreground">
.txt the X12 837/835 file as exported from your clearinghouse
</div>
</>
)}
<input
ref={inputRef}
type="file"
accept=".txt"
className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
/>
</div>
</RoleGate>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mt-4 pt-4 border-t border-border/30">
<div className="mono text-[11px] text-muted-foreground">
@@ -674,35 +688,41 @@ export function Upload() {
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{file ? (
{/* Parse button the second half of the write affordance.
Wrapping it independently so the dropzone's `fallback`
message doesn't sit next to a disabled-looking Parse
button when the role is viewer. */}
<RoleGate allow={["admin", "user"]} fallback={null}>
<div className="flex items-center gap-2 flex-wrap">
{file ? (
<Button
variant="ghost"
onClick={() => pickFile(null)}
disabled={running}
className="min-h-[44px] sm:min-h-0"
>
Clear
</Button>
) : null}
<Button
variant="ghost"
onClick={() => pickFile(null)}
disabled={running}
onClick={onParse}
disabled={!file || running}
className="min-h-[44px] sm:min-h-0"
>
Clear
{running ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse
</>
)}
</Button>
) : null}
<Button
onClick={onParse}
disabled={!file || running}
className="min-h-[44px] sm:min-h-0"
>
{running ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse
</>
)}
</Button>
</div>
</div>
</RoleGate>
</div>
</div>
+64
View File
@@ -105,6 +105,55 @@ export interface Activity {
amount?: number;
}
// ---------------------------------------------------------------------------
// Dashboard summary surface (SP10)
//
// Aggregated view of the whole clearinghouse, returned by
// `GET /api/dashboard/summary`. The shape is server-stable; the same
// shape is synthesized by `useDashboardSummary` when no backend is
// configured (see `sampleClaims` / `sampleProviders`).
// ---------------------------------------------------------------------------
export interface DashboardKpis {
claimCount: number;
billedTotal: number;
receivedTotal: number;
outstandingAr: number;
deniedCount: number;
pendingCount: number;
/** Percentage 0100. */
denialRate: number;
}
export interface MonthlyBucket {
/** YYYY-MM (UTC). */
month: string;
/** English short label, e.g. "Jan". */
label: string;
count: number;
billed: number;
received: number;
denied: number;
/** Cumulative billed cumulative received, clamped at 0. */
ar: number;
/** Percentage 0100; 0 when count is 0. */
denialRate: number;
}
export interface DashboardSummary {
kpis: DashboardKpis;
/** Oldest → newest, sized to the requested `months` window. */
monthly: MonthlyBucket[];
/** Top N by claim count (descending). */
topProviders: Provider[];
/** Latest N denied claims, newest first. */
recentDenials: Claim[];
/** Distinct provider count across the whole store — independent of topProviders cap. */
providerCount: number;
/** UTC ISO timestamp the aggregation was computed at. */
asOf: string;
}
// ---------------------------------------------------------------------------
// Backend-aligned types (snake_case to match FastAPI JSON output 1:1).
// These mirror the Pydantic models in
@@ -782,3 +831,18 @@ export interface InboxClaimRow {
totalLines: number;
} | null;
}
// ---------------------------------------------------------------------------
// Auth (admin/user/viewer). Mirrors `users.to_public()` on the backend
// (backend/src/cyclone/auth/users.py). Role is the triple ("admin" |
// "user" | "viewer"); `disabledAt` is non-null when the account has been
// disabled by an admin. `createdAt` mirrors the original DB column.
// ---------------------------------------------------------------------------
export interface User {
id: number;
username: string;
role: "admin" | "user" | "viewer";
createdAt: string | null;
disabledAt?: string | null;
}