Commit Graph

700 Commits

Author SHA1 Message Date
Tyler 9e595503b7 docs(spec): SP24 — align docs and decisions with the auth work in main
The auth-merge that landed on 2026-06-23 (14 commits, a25504b..39ae988)
adds real auth: User/Session models, bcrypt, matrix_gate, AUTH_DISABLED,
a login router, RBAC on every existing endpoint, and a RequireAuth
guard on the React shell. But CLAUDE.md, docs/REQUIREMENTS.md, and
docs/ARCHITECTURE.md (all added/updated on the css-reduction branch)
still describe the pre-auth "no auth, local-only, single operator,
single host" posture. The doc/code divergence is real and would
confuse anyone reading the docs.

This is the spec for SP24 — the alignment. It is intentionally a
docs/skills-only SP: no schema, no role, no gate changes. The plan
section enumerates the doc lines to update; the implementation step
sweeps the markdown for residual "no auth" claims.

Draft, awaiting user sign-off per the SP-N spec flow.
2026-06-23 14:49:21 -06:00
Tyler 1148a03a43 docs(architecture): add top-level technical design doc
Closes round 4 of doc-prep. ARCHITECTURE.md is the day-1 read for
engineers joining Cyclone: process & network topology, tech stack
(backend + frontend + external), backend package layout with
one-line module responsibility map, frontend architecture (route
map, state management, live tail), data model (migrations 0001-0012,
ERD, 7-state claim lifecycle, audit chain), data flow (manual upload,
SFTP polling, auto-match, outbound 837P, ack ingestion), API surface,
cross-cutting concerns (logging, audit, PHI/PII, error model),
operational concerns (startup order, schedulers, health probe,
shutdown), deployment, out-of-scope, and references.

759 lines, 13 top-level sections. Cross-linked from REQUIREMENTS.md
sister-docs block and from README.md Roadmap section.
2026-06-23 14:49:16 -06:00
Tyler cad104a38c docs(requirements): add top-level requirements + DoD
38 functional requirements + 18 non-functional requirements across 13 sections.
Includes traceability matrix mapping FRs to NFRs and to existing
docs/superpowers/specs/* artifacts. Closes round 1 of doc-prep
preparation for Cyclone implementation.
2026-06-23 14:49:12 -06:00
Nora 0c81968d0c fix(permissions+seed): add /api/reconciliation routes and shape billing_provider.address as dict
Two bugs surfaced as 403/500 storms in the live UI:

1. PERMISSIONS matrix only had '/api/reconcile' as a prefix but the
   client hits '/api/reconciliation/*' (dashboard reconciliation tab,
   manual match endpoint). Every call returned 403 even for admin.
   Add ('GET', '/api/reconciliation') and ('POST', '/api/reconciliation')
   alongside the existing /api/reconcile entries.

2. The seed CLI built billing_provider.address as a flat string but
   to_ui_claim_detail._address_to_ui expects {line1,line2,city,state,zip},
   so opening any seeded claim in the detail drawer threw
   'str object has no attribute get' → 500. Build the dict from the
   provider fields instead.
2026-06-22 17:54:39 -06:00
Nora add6e982a4 Populate receivedAmount from matched Remittance.total_paid
Before this change, every claim — matched or not — came back from
`/api/claims` with `receivedAmount: 0.0`. `to_ui_claim_from_orm`
hardcoded the value, so the Dashboard's 'Received' KPI and the
claim detail drawer's paid amount always read $0 regardless of
whether the claim had been paired with a paying remittance.

The fix threads the real value through the read path:

- `to_ui_claim_from_orm` accepts a new `received_total: float`
  kwarg (default 0.0 for callers that don't have a remittance in
  scope).
- `iter_claims` bulk-loads `Remittance.total_paid` for every
  matched claim id in the result set (single SQL roundtrip via
  `IN` filter — no N+1) and stamps the sum onto each claim dict.
- `manual_match` passes `float(remit.total_paid)` from the remit
  it's already holding.
- `manual_unmatch` reads `paired_remit.total_paid` before clearing
  the FK so the response still reports what was paid pre-unpair.
- `add` (write path) and `list_unmatched` (filters matched-remit
  is NULL) pass `0.0` explicitly for readability.

The dev seed CLI now also generates matched Remittance rows for
every PAID/PARTIAL claim — `matched_remittance_id` is set on the
claim, and the remittance's `total_paid` is derived from the
billed amount using the same ratios the frontend's old sample
fixtures used (60–100% for PAID, 20–50% for PARTIAL). That gives
the Dashboard a non-zero 'Received' KPI on a fresh dev DB.

New tests:
- tests/test_iter_claims_received.py — 4 cases covering matched,
  unmatched, orphan FK, and bulk-load paths. Catches regressions
  if anyone re-introduces the hardcoded 0.0 or breaks the bulk
  query.
2026-06-22 17:46:47 -06:00
Nora 414d2eb722 Add cyclone seed CLI subcommand for dev DB population
Now that Dashboard reads live API hooks instead of the in-memory
sample store, a fresh dev DB renders as $0 KPIs and 'No activity
yet.' for a long time. `python -m cyclone seed` inserts a
deterministic batch of 96 claims + 28 activity events so the
Dashboard / Claims / Activity Log pages have something to render
without going through the Inbox / EDI parser pipeline.

  python -m cyclone seed           # insert (no-op if seeded)
  python -m cyclone seed --reset   # wipe + re-insert
  python -m cyclone seed --count 10
  python -m cyclone seed --status  # counts only

The seed mirrors the frontend `sampleData.ts` fixtures (3 TX
providers, 7 payers, 8 CPTs, 96 claims spread across the last 200
days) so dev dashboards look identical to the previous in-memory
fixture mode. `raw_json` and `payload_json` are populated in the
shape that `store.to_ui_claim_from_orm` / `recent_activity` parse
back into the UI wire format — wire parity, not shortcuts.

`--reset` cleans by id prefix (`SEED-` / `CLM-S`) rather than
relying on SQLite's `ON DELETE CASCADE`, which the dev session
doesn't enforce (no `PRAGMA foreign_keys = ON` at the SQLAlchemy
session layer). Safer than silent orphans.

Verified end-to-end: 986 backend tests pass, dashboard renders
$75,833 / 96 claims / 11.5% denial rate / 10 activity events /
top-3 providers ranked by claim count.
2026-06-22 17:30:39 -06:00
Nora 7e4bb4d2c8 Wire Dashboard to live API hooks
Dashboard.tsx was reading claims/providers/activity directly from the
zustand `useAppStore`, which returns the hardcoded sample fixtures
(sampleClaims / sampleProviders / sampleActivity) regardless of whether
a backend session is active. The hooks useClaims / useProviders /
useActivity are wired correctly to /api/* when api.isConfigured, so
the fix is to swap the three direct store reads for the hooks.

buildMonthly's parameter is now typed as Claim[] (the live Claim
shape) instead of the store's heterogeneous slice, and the
`useAppStore` import is gone.

Verified end-to-end: after logging in as admin via the live server,
the Dashboard now shows $0 KPIs, "0 events / No activity yet.",
and the live operator name in the greeting — proving the hooks are
hitting /api/* and not falling back to the sample store.
2026-06-22 17:20:48 -06:00
Nora fb913f0617 fix(ui): Dashboard greeting uses live operator; api.isConfigured proxies through
Two issues surfaced by going live against the dev backend:

1. Dashboard greeting was hardcoded 'Good morning, Jordan.' in
   src/pages/Dashboard.tsx. The sidebar already pulls from useAuth()
   but the dashboard had been missed. Replace with a time-of-day
   greeting ('morning' / 'afternoon' / 'evening') plus the live
   operator's username from useAuth(). Falls back to 'there' while
   /api/auth/me is in flight so we never flash 'undefined' at the
   operator.

2. api.isConfigured was returning false whenever VITE_API_BASE_URL
   was empty, which is the default in .env.example and the
   recommended setting for the Vite dev proxy / nginx. With the flag
   false, every hook (useClaims, useBatches, useActivity, ...)
   threw notConfiguredError() and silently fell back to the
   in-memory zustand store's hardcoded sample data — so 'files not
   serving' was actually the Dashboard rendering fixtures, not the
   real DB. With the flag now true, joinUrl('') produces relative
   URLs that the proxy intercepts and forwards to FastAPI on :8000.
   Set VITE_API_BASE_URL=disabled to opt out (useful for Storybook
   / offline UI work).

Also installs @radix-ui/react-tabs which was listed in package.json
but missing from node_modules — Vite was failing pre-transform on
tabs.tsx and refusing to compile downstream pages until deps were
re-installed via 'npm install'.
2026-06-22 17:05:27 -06:00
Nora 39ae988101 fix(auth): bootstrap init_db on fresh DB; permissions matrix for inbox endpoints
Two bootability gaps caught by running the live stack:

1. cyclone.auth.bootstrap.run() called SessionLocal() before db.init_db()
   had run on a fresh DB, so 'python -m cyclone serve' / 'users create'
   / '--help' all crashed with 'db.init_db() has not been called'.
   Add init_db() inside bootstrap (idempotent — no-op once the schema
   is current) so first-boot works without manual prep.

2. PERMISSIONS matrix didn't register the inbox endpoints that were
   added in SP10 (export.csv) and SP14 (candidates/dismiss,
   rejected/resubmit, payer-rejected/acknowledge). The matrix's
   longest-prefix match meant /api/inbox/candidates/dismiss matched
   the '/api/inbox' prefix but only because that single entry
   existed; adding the dedicated write-prefix entries makes the
   intent explicit and keeps viewer role out of every inbox write
   even if the broader /api/inbox prefix is later broadened.

Verified live:
- admin can login, GET /api/inbox/lanes (200), GET
  /api/inbox/export.csv (200), POST /api/inbox/candidates/dismiss
  (200).
- viewer can login, GET /api/inbox/lanes (200), GET
  /api/inbox/export.csv (200), but POST /api/inbox/candidates/dismiss
  and POST /api/inbox/rejected/resubmit both return 403.
2026-06-22 17:00:30 -06:00
Nora e2d4a595a4 fix(api): use request.app.state in inbox endpoints; preserve dict HTTPException detail
- inbox_lanes, inbox_dismiss_candidates, inbox_export_csv: switch
  module-level 'app' to per-request 'request.app' so per-endpoint
  state stays consistent with the test's TestClient target across
  importlib.reload() (test_api.py::test_cors_extra_origins_via_env
  reloads the api module to mutate CORS allow-lists; pre-reload
  endpoints then mutate the wrong app instance).

- _http_exc_handler: when HTTPException.detail is a dict, wrap under
  'detail' so the standard envelope stays stable and callers can
  branch on body['detail']['error'].

- conftest._auto_init_db: re-resolve cyclone.api.app each fixture
  invocation (instead of caching at module load) so the reload pattern
  doesn't leave event_bus set on a stale app.

- test_acks.test_migration_latest_idempotent_on_fresh_db: bump
  user_version assertion from 12 to 14 after auth migration renumber
  (0013 users+sessions, 0014 audit_log.user_id).

Also installs sqlcipher3 + paramiko into the backend venv so the
capability tests can run; both modules were optional deps that
test_db_crypto and test_sftp_paramiko assume are present.

Backend test results: 1008 passed, 9 skipped (gitignored prodfile
fixtures), 0 failed.
2026-06-22 16:55:55 -06:00
Nora 81bcb1c1ef fix(db): renumber auth migrations to come after SP14/12-backups
The auth cherry-picks brought in 0010_auth_users_and_sessions.sql
(version: 10) and 0011_audit_log_user_id.sql (version: 11), which
collide with main's 0010_payer_rejected_acknowledged.sql (version: 10)
and 0011_processed_inbound_files.sql (version: 11). The migration
runner is filename-sorted but uses the -- version: N header to gate
'application vs skip', so the second file at each version gets
silently skipped — claims.payer_rejected_acknowledged_at never
got ALTERed, breaking every test that touches the claims table.

Renumber to 0013 and 0014 (and bump the -- version headers to
match) so they apply after 0012_backups.sql and main's existing
SP14 / SP15 migrations.
2026-06-22 16:07:10 -06:00
Nora 8fc3d9adda test(auth): add test_existing_endpoints_require_auth + per-test AUTH_DISABLED flips
- New test_existing_endpoints_require_auth.py: spot-check that existing
  /api/* endpoints now require auth (gated via Depends(matrix_gate)) when
  AUTH_DISABLED is False. Health remains public.

- conftest.py: flip AUTH_DISABLED=True for the suite so the legacy
  pre-auth tests keep passing without login. Auth tests flip it back
  off via their own autouse fixture (now patched to use monkeypatch
  for cleanup).

Verified: 53 auth tests pass; 222 pre-existing non-auth failures are
unchanged.
2026-06-22 15:59:08 -06:00
Nora 35e0f5a422 feat(auth): disable write affordances for viewer role
Re-apply f91d7b3 manually against current Upload.tsx / Inbox.tsx /
Reconciliation.tsx since main's UI text diverged from the original
cherry-pick (Release to 'parse' span, mono uppercase tracking, etc.).
The write affordances are now wrapped in <RoleGate allow={admin,user}>:

  - Upload.tsx: dropzone + Parse/Clear buttons
  - Inbox.tsx: rejected / payer_rejected / candidates BulkBars
  - Reconciliation.tsx: 'Match selected' button

Test fixtures stub useAuth to return an admin user so RoleGate lets
the BulkBars / Match button render synchronously on first paint —
the tests don't need a full AuthProvider + /api/auth/me probe.
2026-06-22 15:54:38 -06:00
Nora a25504bd3a fix(auth): ungate auth_router so login is public 2026-06-22 15:41:52 -06:00
Nora 70280f70bb feat(auth): gate existing endpoints with matrix_gate (router-level + per-endpoint)
Replaces the skipped 63ae0d2 commit: adds matrix_gate to deps.py and
applies it as a dependency on every authenticated FastAPI route, including
extracted routers (acks, ta1_acks, admin) and inline endpoints in api.py.

The PERMISSIONS matrix in auth/permissions.py controls which roles can
hit which (method, path) combinations; matrix_gate is fail-closed by
default (any endpoint not in the matrix returns 403).
2026-06-22 15:40:00 -06:00
Nora a933672411 docs: auth section + env vars 2026-06-22 15:38:38 -06:00
Nora ca40fd72c3 feat(docker): CYCLONE_ADMIN_* env vars for backend 2026-06-22 15:38:38 -06:00
Nora 4402a993d1 feat(auth): sidebar shows current user + logout button 2026-06-22 15:37:02 -06:00
Nora cb87456575 feat(auth): wire AuthProvider + RequireAuth into app shell 2026-06-22 15:37:02 -06:00
Nora 3d0c7766f0 feat(auth): RequireAuth route guard 2026-06-22 15:34:19 -06:00
Nora e76b514872 feat(auth): /login page 2026-06-22 15:34:19 -06:00
Nora 5bb9588f09 feat(auth): RoleGate component 2026-06-22 15:34:19 -06:00
Nora d895854dcc feat(auth): AuthProvider context + useAuth hook 2026-06-22 15:34:18 -06:00
Nora 55a298f05f feat(auth): fetch wrapper with 401 redirect + authApi 2026-06-22 15:34:18 -06:00
Nora 0aea0f64ac feat(types): add User type 2026-06-22 15:34:18 -06:00
Nora 6c80bf0512 feat(auth): CLI users subcommand 2026-06-22 15:34:18 -06:00
Nora 9c57b493a7 feat(audit): record user_id on log events 2026-06-22 15:34:18 -06:00
Nora 609499543e test(auth): login rate limit 2026-06-22 15:34:05 -06:00
Nora 768f7c6247 feat(auth): bootstrap first admin from env vars 2026-06-22 15:34:05 -06:00
Nora 86b635104c feat(auth): get_current_user + login/logout/me + admin user management 2026-06-22 15:34:05 -06:00
Nora e158871a9a feat(auth): sessions module + permissions matrix + rate limiter
SQLite drops tzinfo on DateTime roundtrip — normalize on read/write
so callers see tz-aware datetimes.
2026-06-22 15:34:05 -06:00
Nora 1ca50e2bc0 test(auth): tighten duplicate-username test + add update_password test 2026-06-22 15:32:56 -06:00
Nora 74d7056284 feat(auth): users module with bcrypt hashing + CRUD 2026-06-22 15:32:56 -06:00
Nora 0ba91040f1 feat(migration): 0010 users + sessions, 0011 audit_log.user_id 2026-06-22 15:32:56 -06:00
Nora dc83d7bef2 feat(db): add User and Session models 2026-06-22 15:32:56 -06:00
Nora 9d4798a124 feat(deps): add passlib[bcrypt] for password hashing
passlib 1.7.4 + bcrypt >= 4.1 are incompatible (passlib probes
bcrypt.__about__ which 4.x removed). Pin bcrypt<4.1.
2026-06-22 15:32:56 -06:00
Nora d552d3d3ec docs(plan): auth implementation plan — 29 tasks, ~40 files 2026-06-22 15:32:45 -06:00
Nora 42a826cb73 docs(spec): auth (admin/user/viewer) design — sessions, RBAC, login flow 2026-06-22 15:32:45 -06:00
Tyler 05b43078b9 chore(deps): consolidate dev deps into [project.optional-dependencies] dev
Follow-up to 7c1be58. pytest-randomly was added to a PEP 735
[dependency-groups] dev block by 'uv add --dev', leaving the
project with two 'dev' groups (the legacy [project.optional-
dependencies] dev and the new [dependency-groups] dev). To get
the full dev env someone had to run 'uv sync --extra dev --group
dev', which is easy to miss.

This commit moves pytest-randomly>=4.1 into the
[project.optional-dependencies] dev list (where pytest,
pytest-cov, pytest-asyncio, and httpx already live) and drops
the [dependency-groups] block. 'uv sync --extra dev' now
installs the full dev toolchain. uv.lock updated accordingly:
the [package.dev-dependencies] and [package.metadata.requires-
dev] sections are gone, and pytest-randomly is part of the
standard dev extra.
2026-06-22 11:44:39 -06:00
Tyler 7c1be58860 chore(deps): add cryptography dep, pytest-randomly dev dep
Two follow-ups to the v0.2.0 release:

1. Add cryptography>=49.0,<50 to the [project.dependencies] list.
   cyclone.backup / cyclone.backup_service import it at module top
   level, so it has to be a hard dep — not an extra — or the test
   suite fails to collect on a fresh 'uv sync'. A clean install
   (without this commit) gets 28 collection errors with no tracked
   file changes. This was latent because the original 99M venv
   had cryptography installed out-of-band and masked the missing
   dep declaration.

2. Add pytest-randomly>=4.1.0 to the dev dep group.
   Used to characterize the test suite's order-dependence: under
   alphabetic order the suite shows 7 failures; under seed=12345
   it shows 34. That variance is evidence of test-order state
   interactions, not venv issues. Keeping pytest-randomly in
   the dev deps so this can be re-verified on demand.

Note: pytest-randomly was added with 'uv add --dev', which uses
the PEP 735 [dependency-groups] syntax. pyproject.toml now has
two 'dev' groups — the legacy [project.optional-dependencies] dev
= [pytest, pytest-cov, pytest-asyncio, httpx] and the new
[dependency-groups] dev = [pytest-randomly]. To get the full dev
env, run: uv sync --extra dev --group dev. Worth consolidating
in a follow-up.
2026-06-22 11:43:32 -06:00
Tyler 9bca4b608a feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens
Backend:

- New POST /api/batches/{id}/export-837: regenerate X12 837 files
  for a list of claim_ids into a ZIP using HCPF file naming standards,
  with a unique interchange/group control number per export. Wire
  the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
  (NM1*40) blocks so the serializer no longer falls back to
  CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
  batch_id in both JSON and NDJSON response shapes so the frontend
  can hit batch-scoped endpoints without an extra listBatches
  round-trip.
- Filename helpers and the 837 serializer updated to match the new
  HCPF envelope; tests cover batch export, parse batch_id, and the
  serializer's control-number uniqueness guarantee.

Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
  EditorialNote, ExportBar, TickerTape, and a charts/ set
  (BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
  the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
  colors to Tailwind theme tokens (bg-card, text-foreground,
  border/60, etc.) for consistency with the rest of the instrument
  chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
  reworked to compose the new shared components and consume the new
  batch-scoped API surface (notably ExportBar wired into Batches).

Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
  components and the useBatchExport hook.

Rolls up into the v0.2.0 release tag.
v0.2.0
2026-06-22 11:01:58 -06:00
cyclone 35298907bc docs(spec): add Ubuntu Docker deployment design
Production deployment of Cyclone on a single Ubuntu Linux server:
- Two-container Docker Compose (backend + nginx frontend)
- App-layer auth: username/password, argon2id, session cookies
- 3 fixed roles: admin / operator / viewer with documented action matrix
- SQLCipher encryption at rest, key as Docker secret
- Existing BackupService autostarts (24h interval, 14-day retention)
- LAN-only bind, single operator, VPN for outside access
- Manual update via 'docker compose pull', env-var rollback
- SFTP stays stub (download ZIP + MFT UI); listed as v2

Builds on the parent 2026-06-19 production-readiness spec which
shipped local-only in-memory store + react-query wiring.
2026-06-22 10:43:26 -06:00
Tyler 22720168c4 test(claims): wrap page in DrillStackProvider, switch to DrillDrawerHeader testids
Phase 5 Task 5.8 (PartiesGrid) and 5.9 (ValidationPanel) added useDrillStack()
calls, so Claims.test.tsx needs a DrillStackProvider wrapper around the
page render to keep the hook happy. Task 5.10 refactored the ClaimDrawer
header onto the shared DrillDrawerHeader, so the deep-link and close-button
tests need to find the title via <h2> and the close button via
aria-label='Close drawer' instead of the now-removed header-id /
header-close testids.

Without this fix the page-level Claims tests report 3 failures
(deep link, URL clear after close, ?-mark with drawer closed) that
were not failing on the Phase 4 base (9a313d2).
sp21-complete
2026-06-21 18:10:43 -06:00
Tyler 1c0d855b8e refactor(claim-drawer): mount on shared DrillDrawerHeader shell 2026-06-21 18:10:43 -06:00
Tyler 8db5db7610 feat(claim-drawer): validation rule opens peek with rule catalog 2026-06-21 18:10:43 -06:00
Tyler 786ead8c94 feat(claim-drawer): payer name opens PeekModal on top of drawer 2026-06-21 18:10:43 -06:00
Tyler 6c773c1159 feat(upload): streamed claim cards offer drill to persisted entity 2026-06-21 18:10:43 -06:00
Tyler 5c7e9b6168 feat(batch-diff): claim ids drillable to /claims?claim=ID 2026-06-21 18:10:43 -06:00
Tyler ac87ed4908 feat(reconciliation): card body drillable, select button split 2026-06-21 18:10:43 -06:00
Tyler 4a8ce1a524 feat(inbox): rejected + payer_rejected + done_today rows drillable 2026-06-21 18:10:43 -06:00