Compare commits
27 Commits
v0.2.0
...
33d5283667
| Author | SHA1 | Date | |
|---|---|---|---|
| 33d5283667 | |||
| 83b80535be | |||
| f91d7b3b46 | |||
| b8b679b108 | |||
| 337020cca1 | |||
| 319ac5f586 | |||
| 3e8d52ba08 | |||
| 56249402fb | |||
| 89bd07bca7 | |||
| f1f2eee69e | |||
| 5cdfc05b41 | |||
| 064909e1cd | |||
| a0d3448c0c | |||
| 0f7ec133d7 | |||
| c4d2d2b5bf | |||
| 63ae0d2905 | |||
| 23c1bb6b34 | |||
| b988c4c518 | |||
| b5f10c780b | |||
| 4e8e03363f | |||
| 982ac127b3 | |||
| 129fb2308d | |||
| 517f9e23e6 | |||
| 0677e4fd65 | |||
| 4a382c0b16 | |||
| 54a361551e | |||
| 63b2870f6e |
@@ -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
@@ -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
|
||||
@@ -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**
|
||||
(50–74, 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 1–7,
|
||||
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
|
||||
|
||||
@@ -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/
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Auth module — users, sessions, permissions, routes, admin, rate_limit."""
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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}'.")
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,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);
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
Generated
+431
-1
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
@@ -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.*
|
||||
@@ -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;"]
|
||||
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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 0–100. */
|
||||
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 0–100; 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user