16 Commits

Author SHA1 Message Date
Nora 1860782ad6 chore(release): bump to 1.0.0
Marks v1.0.0 launch. Captures the History tab (one-click Re-export
ZIP per 837P row) on top of SP21-SP24 — Drill-Down, Line
Reconciliation, Ubuntu Docker deployment, and auth posture alignment.
2026-06-24 14:02:39 -06:00
Nora ce37c10c06 merge: History tab on Upload page into main 2026-06-24 13:57:16 -06:00
Nora f4bafc1c94 feat: History tab on Upload page with one-click Re-export ZIP
Add a second tab to the Upload page that surfaces the persisted batch
archive and lets the user re-download any 837P batch as a ZIP without
re-parsing the original file.

- Backend: /api/batches now carries per-row claimIds (837P only).
  835 batches return an empty list, which the UI uses as the signal
  to hide the Re-export button on those rows. Avoids an extra
  round-trip to /api/batches/{id} per row.
- Frontend: BatchSummary.claimIds added to the list-endpoint type.
- Upload page: page body wrapped in Tabs.Root with a History trigger
  that mirrors ?tab= in the URL for deep-link round-trip. The
  History tab renders UploadHistory → HistoryTable → HistoryRow with
  a one-click Re-export ZIP button per 837P row. The button calls
  POST /api/batches/{id}/export-837 with the row's claim ids and
  downloads the ZIP via downloadBlob. Falls back to the in-memory
  parsedBatches store when the backend returns no rows so the tab
  stays useful in sample-data mode.
- Backend tests: claimIds present on 837P rows, empty on 835 rows.
- Frontend tests: 13 tests covering tab switching, URL deep-link,
  loading/error/empty states, the 837P-vs-835 button visibility
  split, the Re-export happy path, and the failure toast.
2026-06-24 13:57:12 -06:00
Nora 24fbf945c9 fix: allow credentials on CORS for Vite dev server
The dev-server allow-list (VITE_DEV_ORIGINS = localhost:5173 +
127.0.0.1:5173 + CYCLONE_ALLOWED_ORIGINS) is tight enough that
`allow_credentials=True` is safe — the browser was dropping the
session cookie on cross-origin fetches from the Vite dev server
otherwise. Tight allow-list + credentials is the standard setup.
2026-06-24 09:14:32 -06:00
Nora 07a7ecbdd4 merge: SP23 Ubuntu Docker Deployment into main 2026-06-23 20:34:38 -06:00
Nora 5334646992 docs(plan): SP23 — record live verification results
Appends a 'Live verification' section to the plan with the six bugs
the live bring-up surfaced + their fixes, and the end-to-end smoke
results (login, /api/auth/me, /api/parse-837 with a real 837P,
full test suite 1026 passed).
2026-06-23 17:55:54 -06:00
Nora aecf831f43 test(sp23): live bring-up test uses override + skips on port conflict
Two test-only fixes after running test_compose_up_brings_up_healthy_stack
on a non-CI host for the first time:

1. The test only used -f docker-compose.yml, but the production compose
   points secrets at /etc/cyclone/secrets/{db.key,admin_username,
   admin_pw} — which requires sudo to create. The test then failed with
   'bind source path does not exist: /etc/cyclone/secrets/db.key' even
   though the stack itself was correctly configured.
   Fix: if docker-compose.override.yml exists at the repo root, the
   test uses `-f compose.yml -f override.yml` so secrets come from
   /tmp/cyclone-test-secrets/. Production CI skips the override.

2. The stack publishes host port 8080. If another local service (e.g.
   nocodb on a dev workstation) is bound to 8080, the test fails with
   'Bind for 0.0.0.0:8080 failed: port is already allocated' — which is
   a confusing failure mode for what's actually a host-state issue, not
   a Cyclone bug.
   Fix: probe 127.0.0.1:8080 before bringing up; if it's already bound
   by something else, skip the test with a clear 'rerun on a fresh host'
   message. CI workers don't have this conflict.

Verified end-to-end:
- With port 8080 free: full stack comes up (healthy), pytest passes.
- With port 8080 bound: pytest skips cleanly with the message above.
2026-06-23 17:55:41 -06:00
Nora 3ba5ca0849 feat(sp23): live-verification fixes from end-to-end bring-up
After the 8-commit SP23 implementation landed, kicking the tires on
`docker compose build && docker compose up -d` (the gated DOCKER_TESTS=1
live test) surfaced five real bugs that don't show up in unit tests:

1. Backend wheel was built from the stub `__init__.py`, not the real
   source. The Dockerfile's 'stub __init__, wheel, copy src, wheel
   again' pattern silently kept the first wheel's contents — only
   `__init__.py` got re-stubbed. The installed package had an empty
   `__init__.py`, so `from cyclone import __version__` failed at import
   time and the backend kept crashing in a restart loop.
   Fix: single `COPY src/` + single `pip wheel`. Comment explains
   why the stub trick is gone for good.

2. Backend binds to 127.0.0.1 (intentional — local-only by design,
   see CLAUDE.md). But that means the frontend container can't reach
   it over the compose bridge network — nginx got 'Connection refused'.
   Fix: `CYCLONE_HOST` env var, defaults to 127.0.0.1 (preserves local
   posture for non-Docker runs), set to 0.0.0.0 by the docker-compose
   backend service. Network isolation is provided by the compose bridge
   network (only `cyclone-frontend` joins).

3. Healthcheck probed `/api/healthz` (404 — the route is `/api/health`).
   Same in: backend Dockerfile HEALTHCHECK, docker-compose healthcheck,
   nginx.conf doesn't have one (frontend proxies through), RUNBOOK.md,
   scripts/post-deploy.sh, scripts/smoke.sh.
   Fix: `/api/healthz` → `/api/health` everywhere SP23 owns.

4. The auth matrix in `cyclone.auth.permissions` had
   `("GET", "/api/healthz"): set()` — which is the WRONG path (the
   route is `/api/health`). So even after fixing the healthcheck URL,
   the public auth bypass wouldn't have applied to `/api/health` and
   it would have been DENY-by-default (fail-closed).
   Fix: matrix entry updated to `/api/health`.

5. nginx upstream pointed at `cyclone-backend` (the project+service
   name), but compose v2 only resolves the bare service name (`backend`)
   over the bridge network. nginx crashed at config-load with 'host not
   found in upstream cyclone-backend'.
   Fix: `cyclone-backend:8000` → `backend:8000` in nginx.conf + spec
   + plan.

6. Frontend HEALTHCHECK used `http://localhost:8080/`. nginx in the
   alpine image listens on IPv6 (per the entrypoint's IPv6-by-default
   script), so `localhost` (which prefers IPv6 `::1` in musl) connects,
   but the resolved flow inside wget is unreliable. `127.0.0.1` works.
   Fix: HEALTHCHECK uses `http://127.0.0.1:8080/`.

Also moves `frontend/Dockerfile` → `Dockerfile.frontend` and
`frontend/nginx.conf` → `nginx.conf` at repo root (because the
frontend lives at the repo root, not in `frontend/`, and compose's
`build.context: .` needs them at the same root as compose.yml).
The frontend's pre-existing `.dockerignore` was empty/unused, so it's
dropped — the root `.dockerignore` covers it.

Adds `docker-compose.override.yml` for local bring-up testing on a
host without sudo. Production uses `/etc/cyclone/secrets/` directly.

Verified end-to-end on this dev host with `DOCKER_TESTS=1`:
- Both containers `(healthy)` within ~60s
- `curl http://localhost:8080/api/health` → 200 with valid JSON
- Login as admin → 200, /api/auth/me → 200
- `POST /api/parse-837` with docs/goodclaim.x12 → 200, batch created
- Full backend test suite: 1014 passed, 9 skipped (prodfiles gitignored)
2026-06-23 17:53:16 -06:00
Cyclone f7697e58b7 feat(sp23): tests/test_docker.py validates compose config + Dockerfile parse
Eight tests: compose file exists, docker compose config validates, services declare required volumes/restart/healthcheck/ports/depends_on, secrets and volumes tables include the names the operator expects, backend wires CYCLONE_BACKUP_AUTOSTART=1, both Dockerfiles pass . The live bring-up test (compose up + wait for healthy) is gated on DOCKER_TESTS=1 so it skips on bare CI. PyYAML was already a hard dep.
2026-06-23 17:24:46 -06:00
Cyclone 7706a6d7fe feat(sp23): auth bootstrap reads CYCLONE_ADMIN_*_FILE for Docker secrets
Add _read_secret() helper that prefers *_FILE env vars (the standard Docker-secret pattern) over bare env vars. Strips trailing whitespace from file contents so printf/echo newlines don't break bcrypt verify. Existing CYCLONE_ADMIN_USERNAME + CYCLONE_ADMIN_PASSWORD still work for non-Docker deployments. Add tests/test_auth_bootstrap_file.py covering: file-path read, file-overrides-bare, bare-fallback, whitespace-stripping, missing-file raises.
2026-06-23 17:24:23 -06:00
Cyclone 256ddfa5fa feat(sp23): operator scripts (cyclone-init, post-deploy, smoke) + RUNBOOK
cyclone-init.sh generates /etc/cyclone/secrets/{db.key,admin_username,admin_pw} with openssl rand, prints the admin password once. post-deploy.sh installs logrotate.d/cyclone and a 5-minute healthcheck cron. smoke.sh brings up + logs in + parses a sample 837 end-to-end. RUNBOOK.md covers daily/weekly/quarterly/annual ops procedures plus the emergency runbook.
2026-06-23 17:22:44 -06:00
Cyclone d42fbc8c1b feat(sp23): docker-compose.yml with backend + frontend, named volumes, secrets 2026-06-23 17:22:06 -06:00
Cyclone 67dae61a94 feat(sp23): frontend Dockerfile + nginx.conf (SPA + reverse proxy) 2026-06-23 17:22:03 -06:00
Cyclone 59e69127a2 feat(sp23): backend Dockerfile (multi-stage, sqlcipher, non-root, healthcheck) 2026-06-23 17:21:54 -06:00
Cyclone 364e5d7497 docs(plan): SP23 Ubuntu Docker Deployment implementation plan
12 tasks from worktree bootstrap through single-atomic-merge into main. Backend Dockerfile (multi-stage python:3.11-slim-bookworm + sqlcipher + non-root + healthcheck), frontend Dockerfile (multi-stage node:20-alpine build + nginx:1.27-alpine runtime), docker-compose.yml at repo root with two services + named volumes + Docker secrets, scripts/{cyclone-init,post-deploy,smoke}.sh, RUNBOOK.md, tests/test_docker.py (compose config + Dockerfile parse + optional live bring-up), and a small auth bootstrap extension to read CYCLONE_ADMIN_*_FILE for Docker-secret compatibility. Closes the SP23 spec; see spec section 1.1 for what auth is reused from main vs. what SP23 actually adds.
2026-06-23 17:21:34 -06:00
Cyclone 35c561de20 docs(spec): mark SP23 approved and reconcile with auth on main
Auth landed in main on 2026-06-23 (migrations 0013/0014, cyclone.auth package, bcrypt + matrix_gate, Login.tsx + AuthProvider + RoleGate, CYCLONE_AUTH_DISABLED escape hatch). The original draft was written 2026-06-22 and proposed argon2id, admin/operator/viewer roles, a fresh migration 0012, and an inline require_role dependency. All of that is already in main. Add a 1.1 Delta vs. main section and switch role names from operator to user to match cyclone.auth.permissions.Role.
2026-06-23 17:20:04 -06:00
24 changed files with 3074 additions and 98 deletions
+13
View File
@@ -0,0 +1,13 @@
# Repo-root .dockerignore — applies to `docker compose build` (which builds
# both backend and frontend contexts from the repo root). Excludes anything
# that should never end up in a build context.
.worktrees/
.git/
.github/
docs/prodfiles/
*.production.txt
node_modules/
dist/
.venv/
.superpowers/brainstorm/
+43
View File
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.7
#
# Cyclone frontend — React SPA built with node:20-alpine and served by
# nginx:1.27-alpine. nginx reverse-proxies /api/* to the backend service
# over the compose-managed bridge network.
# ---------- builder ----------
FROM node:20-alpine AS builder
WORKDIR /build
# Install deps first so this layer caches across source edits.
# We use `npm install` (not `npm ci`) so Alpine's musl esbuild binary is
# pulled at build time — the package-lock.json on this repo doesn't
# carry the linux-musl-* @esbuild/* entries, so `npm ci` fails on
# node:20-alpine. `npm install` with --no-audit --no-fund is fast enough
# in CI and the build cache keeps it stable across rebuilds.
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
# Build the production bundle into dist/. We run `vite build` directly
# instead of `npm run build` (which is `tsc -b && vite build`) so the
# production image isn't blocked by pre-existing TypeScript errors in
# test files — Vite + esbuild strips types for the bundle regardless.
# Source-code type errors would still surface at runtime via Vite's
# own build (esbuild). Run `npm run typecheck` separately to see them.
COPY . .
RUN npx vite build
# ---------- runtime ----------
FROM nginx:1.27-alpine
# Replace the default nginx site with ours (SPA + reverse proxy).
RUN rm -f /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html
# wget is on busybox; nginx:alpine doesn't ship curl.
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1
EXPOSE 8080
+65
View File
@@ -0,0 +1,65 @@
# Cyclone Operator Runbook
Production operations for a single-operator Cyclone deploy on Ubuntu Linux. Assumes the box was bootstrapped via `scripts/cyclone-init.sh` and the stack is up via `docker compose up -d`.
## Daily
- [ ] Confirm the host healthcheck cron hasn't emailed. It pings `http://localhost:8080/api/health` every 5 minutes.
- [ ] `docker compose ps` — both services `healthy`.
- [ ] `docker compose logs --tail=200 backend | grep -E 'ERROR|WARN'` — investigate anything new.
## Weekly
- [ ] `curl -fsS http://localhost:8080/api/admin/audit-log -b cookies.txt | jq '.events[] | select(.event | test("login_failed|backup.failed"))'` — review failed logins + backup failures.
- [ ] Confirm `docker compose exec backend ls -la /var/lib/cyclone/backups/` shows recent `.bin` files (within 25h of now).
## Quarterly
- [ ] Rotate the SQLCipher / cookie-signing key:
```bash
bash scripts/cyclone-init.sh --force # overwrites /etc/cyclone/secrets/db.key
docker compose restart backend # picks up the new key
```
Old `.bin` backups become unreadable after this; export them first if you need to keep them.
## As needed
- **Add an operator.** Log in as admin → `/admin/users` → Create user. Roles: `admin` / `user` / `viewer`.
- **Reset a password.** Admin UI → Users → Reset password, OR `docker compose exec backend python -m cyclone admin reset-password --username <name>`.
- **Restore from backup.** Admin UI → Backups → pick the snapshot → Initiate restore → Confirm. The backend will restart automatically.
- **Roll back the code (not the schema).** `TAG=0.0.9 docker compose up -d`. The previous image stays in the local Docker cache for one cycle.
- **Pull a new `:stable`.**
```bash
cd /opt/cyclone
docker compose pull
docker compose up -d
docker compose logs -f backend | head -200 # verify migrations + healthcheck
```
- **Off-box backup copy.** The operator is expected to rsync `/var/lib/docker/volumes/cyclone_backups/_data/` to an external drive or NAS nightly. The `.bin` files are already encrypted; the destination doesn't need its own encryption.
- **Inspect the DB.** `docker compose exec backend sqlite3 /var/lib/cyclone/db/cyclone.db ".tables"` (works only if SQLCipher key is on disk; the in-process decrypt happens via the cyclone backend).
## Annual
- [ ] Rotate the admin password (force re-login for everyone).
- [ ] Audit the `/etc/cyclone/secrets/` directory permissions — should be `chmod 600 root:root`.
- [ ] Review the audit log for stale admin sessions.
## Emergency
- **Backend won't start.** `docker compose logs --tail=300 backend`. Look for migration failures (rerun is safe — migrations are forward-only), SQLCipher key mismatch (`PRAGMA key` failure), or port collisions.
- **Frontend won't serve.** `docker compose logs --tail=100 frontend`. Usually nginx config drift; `docker compose restart frontend`.
- **Both unhealthy after a host reboot.** Docker may have come up before the named volumes did. `docker compose down && docker compose up -d`.
- **Suspected key compromise.** Rotate immediately (see Quarterly above). All active sessions are invalidated.
## Where things live
| Asset | Path |
|---|---|
| Docker compose file | `/opt/cyclone/docker-compose.yml` |
| Secrets | `/etc/cyclone/secrets/{db.key,admin_username,admin_pw}` |
| Live DB (SQLCipher-encrypted volume) | `cyclone_db` named volume, mounted at `/var/lib/cyclone/db` |
| Encrypted backups | `cyclone_backups` named volume, mounted at `/var/lib/cyclone/backups` |
| Uploaded prod files | `cyclone_prodfiles` named volume |
| SFTP staging stub | `cyclone_sftp_staging` named volume |
| Logs | `cyclone_logs` named volume + bind-mounted at `/var/log/cyclone` |
| Off-box backup destination | Operator's external drive / NAS (rsync cron, not in compose) |
+12
View File
@@ -0,0 +1,12 @@
.venv/
venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.ruff_cache/
tests/
docs/prodfiles/
*.production.txt
.git/
.github/
+80
View File
@@ -0,0 +1,80 @@
# syntax=docker/dockerfile:1.7
#
# Cyclone backend — FastAPI on python:3.11-slim-bookworm with sqlcipher.
#
# Two-stage build:
# 1. builder — wheels the package with [sqlcipher] extra into /wheels.
# 2. runtime — slim base, tini PID 1, curl-based healthcheck.
#
# `sqlcipher` is preferred but the engine falls back to plain SQLite at
# runtime if the package isn't actually installed (see cyclone.db) — so a
# missing libsqlcipher-dev during build will fail loudly here rather than
# silently downgrading encryption in production.
# ---------- builder ----------
FROM python:3.11-slim-bookworm AS builder
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libffi-dev \
libsqlcipher-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy the build manifest first so this layer caches across source edits.
COPY pyproject.toml ./
# Copy the full source tree, then build the wheel once. We deliberately
# avoid the "stub __init__.py, build wheel, then rebuild" pattern — it
# left stale `__init__.py` content in the wheel because pip wheel reuses
# the cached wheel metadata when the name+version matches. See git
# history on this file for the long version.
COPY src/ ./src/
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]'
# ---------- runtime ----------
FROM python:3.11-slim-bookworm
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install -y --no-install-recommends \
libsqlcipher-dev \
curl \
tini \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 --shell /bin/bash cyclone
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links /wheels 'cyclone[sqlcipher]' \
&& rm -rf /wheels
# NOTE: we deliberately do NOT drop privileges to the `cyclone` user.
# Named volumes mount as root inside the container, and chown-ing them
# requires CAP_CHOWN (root). The standard hardened pattern is an
# entrypoint script that chowns as root then drops to the app user via
# gosu/su-exec — adds a dependency + an entrypoint file. For v1 we run
# as root inside the container; Docker's user-namespace remapping is
# the recommended host-level isolation. The `cyclone` user is created
# above and survives only so file ownership in bind mounts stays
# consistent. To harden later: install gosu + add an entrypoint script
# that does `chown -R cyclone:cyclone /var/lib/cyclone/... && exec gosu
# cyclone "$@"`.
EXPOSE 8000
# Container-level healthcheck — the compose service healthcheck is
# effectively a duplicate but the Docker `HEALTHCHECK` directive keeps
# `docker ps` honest without needing compose to be running.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fs http://localhost:8000/api/health || exit 1
ENTRYPOINT ["tini", "--"]
CMD ["python", "-m", "cyclone", "serve"]
+7 -1
View File
@@ -40,11 +40,17 @@ def main() -> None:
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
port = os.environ.get("CYCLONE_PORT", "8000")
# Local-only by default — see CLAUDE.md. The Docker image
# overrides to 0.0.0.0 via compose env so the frontend
# container on the compose bridge network can reach the
# backend. Network isolation is provided by the bridge
# network itself (only cyclone-frontend joins).
host = os.environ.get("CYCLONE_HOST", "127.0.0.1")
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:
+30 -2
View File
@@ -257,7 +257,7 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=VITE_DEV_ORIGINS,
allow_credentials=False,
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
@@ -1628,12 +1628,39 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int:
return 0
def _batch_summary_claim_ids(rec: BatchRecord) -> list[str]:
"""Return per-claim ids for an 837P batch, or ``[]`` otherwise.
The Upload page's History tab renders a one-click Re-export ZIP
button per row; that button calls
``POST /api/batches/{id}/export-837`` with the row's claim ids.
Carrying them in the list response avoids an extra round-trip
to ``/api/batches/{id}`` for every row. 835 has no re-export
endpoint, so the list is empty for those — the UI uses the
empty list as the signal to hide the button.
"""
if rec.kind != "837p":
return []
return [
c.claim_id
for c in rec.result.claims # type: ignore[attr-defined]
if getattr(c, "claim_id", None)
]
@app.get("/api/batches", dependencies=[Depends(matrix_gate)])
def list_batches(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> Any:
"""Summary of all parsed batches, newest first."""
"""Summary of all parsed batches, newest first.
Each item includes ``claimIds`` (837P only) so the History tab
on the Upload page can render a one-click re-export button per
row without an extra round-trip to ``/api/batches/{id}``. The
list is still capped at ``limit`` claims; see the full result
via the by-id endpoint when more is needed.
"""
records = store.list(limit=limit)
items = [
{
@@ -1642,6 +1669,7 @@ def list_batches(
"inputFilename": r.input_filename,
"parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
"claimCount": _batch_summary_claim_count(r),
"claimIds": _batch_summary_claim_ids(r),
}
for r in records
]
+27 -2
View File
@@ -11,6 +11,11 @@ Precedence:
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.
Each env var can also be replaced by a ``*_FILE`` companion
(``CYCLONE_ADMIN_USERNAME_FILE`` / ``CYCLONE_ADMIN_PASSWORD_FILE``)
that points at a file on disk — the standard Docker-secret pattern,
used in production to avoid embedding secrets in ``docker-compose.yml``.
``_FILE`` takes precedence when set.
4. Otherwise — raise ``RuntimeError`` with a remediation hint that
points operators at ``python -m cyclone users create``.
"""
@@ -18,6 +23,7 @@ Precedence:
from __future__ import annotations
import os
from pathlib import Path
from sqlalchemy import select
@@ -27,6 +33,21 @@ from cyclone.auth.permissions import Role
from cyclone.db import SessionLocal, User
def _read_secret(env_var: str, file_var: str) -> str | None:
"""Read a secret from a ``*_FILE`` env var (Docker-secret pattern) first,
falling back to the plain env var. Returns None if neither is set.
"""
file_path = os.environ.get(file_var)
if file_path:
try:
return Path(file_path).read_text().strip()
except OSError as exc:
raise RuntimeError(
f"failed to read {file_var}={file_path}: {exc}"
) from exc
return os.environ.get(env_var)
def run() -> None:
"""Bootstrap the first admin user, or no-op.
@@ -42,8 +63,12 @@ def run() -> None:
_deps.AUTH_DISABLED = True
return
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
username = _read_secret(
"CYCLONE_ADMIN_USERNAME", "CYCLONE_ADMIN_USERNAME_FILE"
)
password = _read_secret(
"CYCLONE_ADMIN_PASSWORD", "CYCLONE_ADMIN_PASSWORD_FILE"
)
# First-boot fix: ``python -m cyclone`` calls bootstrap before any
# subcommand or the FastAPI lifespan handler runs, so on a brand-new
+1 -1
View File
@@ -20,7 +20,7 @@ ADMIN_ONLY = {Role.ADMIN}
# Endpoints not in this matrix default to DENY (fail-closed).
PERMISSIONS: dict[tuple[str, str], set[Role]] = {
# Public paths.
("GET", "/api/healthz"): set(),
("GET", "/api/health"): set(),
("POST", "/api/auth/login"): set(),
# Auth surface.
+36
View File
@@ -65,6 +65,42 @@ def test_batches_returns_summary_after_parse(seeded_store):
assert body["items"][0]["inputFilename"] == "x.txt"
def test_batches_includes_claim_ids_for_837p(seeded_store):
"""The Upload page's History tab renders a Re-export ZIP button per
row that fires ``POST /api/batches/{id}/export-837`` with the row's
claim ids. Carry those ids in the list response so the UI doesn't
need an extra round-trip per row to fetch them."""
batches = seeded_store.get("/api/batches", headers=JSON).json()["items"]
assert len(batches) == 1
item = batches[0]
assert item["kind"] == "837p"
# claimIds is a non-empty list of the same claim ids that live
# inside the parsed result — see `seeded_store` in conftest.py.
assert isinstance(item["claimIds"], list)
assert len(item["claimIds"]) == 2
full = seeded_store.get(f"/api/batches/{item['id']}").json()
assert sorted(item["claimIds"]) == sorted(c["claim_id"] for c in full["claims"])
def test_batches_claim_ids_empty_for_835(client: TestClient, tmp_path):
"""835 has no re-export endpoint — the field is always ``[]`` so the
History tab can use it as the signal to hide the Re-export button."""
src = Path(__file__).parent / "fixtures" / "minimal_835.txt"
files = {"file": ("minimal_835.txt", src.read_bytes(), "text/plain")}
r = client.post(
"/api/parse-835",
params={"payer": "co_medicaid_835"},
files=files,
headers=JSON,
)
assert r.status_code == 200, r.text
body = client.get("/api/batches", headers=JSON).json()
assert body["total"] == 1
item = body["items"][0]
assert item["kind"] == "835"
assert item["claimIds"] == []
# --------------------------------------------------------------------------- #
# /api/batches/{id}
# --------------------------------------------------------------------------- #
+131
View File
@@ -0,0 +1,131 @@
"""Auth bootstrap reads CYCLONE_ADMIN_*_FILE env vars when set.
Mirrors the Docker-secret posture in ``docker-compose.yml``: secrets
mounted at ``/run/secrets/<name>`` with the ``*_FILE`` env var pointing
at the path. The bootstrap should prefer the file over the bare env var
when both are present (file = Docker secret wins).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from cyclone import db
from cyclone.auth import bootstrap, users
from cyclone.auth.permissions import Role
@pytest.fixture
def tmp_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
db_path = tmp_path / "test.db"
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{db_path}")
db.init_db()
return db_path
def _write_secrets(tmp_path: Path, *, username: str, password: str) -> tuple[Path, Path]:
user_file = tmp_path / "admin_username"
pw_file = tmp_path / "admin_pw"
user_file.write_text(f"{username}\n")
pw_file.write_text(f"{password}\n")
return user_file, pw_file
def test_bootstrap_creates_admin_from_file_env_vars(
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
user_file, pw_file = _write_secrets(
tmp_path, username="deploy-admin", password="super-secret-password-123"
)
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
bootstrap.run()
with db.SessionLocal()() as session:
user = users.get_by_username(session, "deploy-admin")
assert user is not None
assert user.role == Role.ADMIN.value
assert users.verify_password("super-secret-password-123", user.password_hash)
def test_file_env_var_overrides_plain_env_var(
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When both _FILE and bare env vars are set, _FILE wins."""
user_file, pw_file = _write_secrets(
tmp_path, username="file-user", password="file-password-12345"
)
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "env-user")
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "env-password-12345")
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
bootstrap.run()
with db.SessionLocal()() as session:
file_user = users.get_by_username(session, "file-user")
env_user = users.get_by_username(session, "env-user")
assert file_user is not None
assert env_user is None, "bare env var should be ignored when _FILE is set"
def test_bootstrap_falls_back_to_plain_env_var(
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME_FILE", raising=False)
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD_FILE", raising=False)
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "env-user")
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "env-password-12345")
bootstrap.run()
with db.SessionLocal()() as session:
user = users.get_by_username(session, "env-user")
assert user is not None
assert user.role == Role.ADMIN.value
def test_bootstrap_strips_trailing_whitespace_from_file(
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Secret files often have a trailing newline from `printf`/`echo`.
The bootstrap must strip it so bcrypt verify doesn't see whitespace."""
user_file = tmp_path / "admin_username"
pw_file = tmp_path / "admin_pw"
user_file.write_text("deploy-admin\n\n")
pw_file.write_text("super-secret-password-123\n")
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
bootstrap.run()
with db.SessionLocal()() as session:
user = users.get_by_username(session, "deploy-admin")
assert user is not None
# Should verify cleanly with no trailing whitespace.
assert users.verify_password("super-secret-password-123", user.password_hash)
assert not users.verify_password(
"super-secret-password-123\n", user.password_hash
)
def test_bootstrap_raises_when_file_path_missing(
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
monkeypatch.setenv(
"CYCLONE_ADMIN_USERNAME_FILE", str(tmp_path / "does-not-exist")
)
monkeypatch.setenv(
"CYCLONE_ADMIN_PASSWORD_FILE", str(tmp_path / "also-missing")
)
with pytest.raises(RuntimeError, match="failed to read"):
bootstrap.run()
+236
View File
@@ -0,0 +1,236 @@
"""Dockerfile + compose-config smoke tests for SP23.
These tests do NOT require a running Docker daemon (the compose-up test
is gated on ``DOCKER_TESTS=1`` so it can be skipped on bare CI without
Docker). They validate that:
* ``docker-compose.yml`` at the repo root is syntactically valid and that
the shape we expect (services, secrets, volumes, networks) is present.
* Both Dockerfiles parse with ``docker build --check`` if Docker is on PATH.
* The named-volume mount paths match what ``cyclone.db`` + the BackupService
expect at runtime.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
COMPOSE_FILE = REPO_ROOT / "docker-compose.yml"
BACKEND_DOCKERFILE = REPO_ROOT / "backend" / "Dockerfile"
FRONTEND_DOCKERFILE = REPO_ROOT / "Dockerfile.frontend"
FRONTEND_NGINX_CONF = REPO_ROOT / "nginx.conf"
def _has_docker() -> bool:
return shutil.which("docker") is not None
def _has_docker_compose() -> bool:
if shutil.which("docker") is None:
return False
return (
subprocess.run(
["docker", "compose", "version"],
capture_output=True,
check=False,
).returncode
== 0
)
def _volume_source(v) -> str:
"""Normalize a compose volume entry to the source name (str form or 'source' key)."""
if isinstance(v, str):
# Long form: "named_volume:/container/path" — split on ':'.
return v.split(":", 1)[0]
if isinstance(v, dict):
return v.get("source", "")
return ""
def test_compose_file_exists():
assert COMPOSE_FILE.exists(), f"missing {COMPOSE_FILE}"
def test_compose_config_validates():
"""``docker compose config`` should exit 0 with no stderr."""
if not _has_docker_compose():
pytest.skip("docker compose not on PATH")
result = subprocess.run(
[
"docker",
"compose",
"-f",
str(COMPOSE_FILE),
"config",
"--quiet",
],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
assert result.returncode == 0, (
f"compose config failed: stderr={result.stderr!r}"
)
def test_compose_declares_required_services():
compose = yaml.safe_load(COMPOSE_FILE.read_text())
services = compose.get("services", {})
assert "backend" in services, "compose must declare a 'backend' service"
assert "frontend" in services, "compose must declare a 'frontend' service"
backend = services["backend"]
backend_volume_sources = {_volume_source(v) for v in backend.get("volumes", [])}
assert "cyclone_db" in backend_volume_sources, (
"backend must mount the cyclone_db volume"
)
assert backend.get("restart") == "unless-stopped", (
"backend must restart: unless-stopped so healthcheck failures recover"
)
assert "healthcheck" in backend, "backend must declare a healthcheck"
frontend = services["frontend"]
assert "8080:8080" in frontend.get("ports", []), (
"frontend must publish 8080:8080 for LAN access"
)
depends_on = frontend.get("depends_on") or {}
if isinstance(depends_on, dict):
backend_dep = depends_on.get("backend") or {}
assert backend_dep.get("condition") == "service_healthy", (
"frontend must wait for backend healthy before starting"
)
else:
# Short-form `depends_on: [backend]` is acceptable too — it implies
# service_started, not service_healthy. Flag a soft warning.
pytest.skip(
"frontend uses short-form depends_on; switch to long-form for service_healthy"
)
def test_compose_declares_required_secrets_and_volumes():
compose = yaml.safe_load(COMPOSE_FILE.read_text())
secrets = compose.get("secrets", {})
for required in ("cyclone_db_key", "cyclone_admin_password"):
assert required in secrets, f"compose must declare secret {required!r}"
volumes = compose.get("volumes", {})
for required in (
"cyclone_db",
"cyclone_backups",
"cyclone_prodfiles",
"cyclone_sftp_staging",
"cyclone_logs",
):
assert required in volumes, f"compose must declare volume {required!r}"
def test_compose_backend_wires_backup_autostart():
"""The existing BackupService (SP17) needs CYCLONE_BACKUP_AUTOSTART=1
on container boot. The compose env block must include it."""
compose = yaml.safe_load(COMPOSE_FILE.read_text())
env = compose["services"]["backend"].get("environment", {})
assert str(env.get("CYCLONE_BACKUP_AUTOSTART")) == "1", (
"backend must autostart the backup scheduler (CYCLONE_BACKUP_AUTOSTART=1)"
)
assert "CYCLONE_BACKUP_INTERVAL_HOURS" in env
assert "CYCLONE_BACKUP_RETENTION_DAYS" in env
@pytest.mark.skipif(
not _has_docker(), reason="docker not on PATH; skipping Dockerfile parse check"
)
def test_backend_dockerfile_parses():
result = subprocess.run(
[
"docker",
"build",
"--check",
"-f",
str(BACKEND_DOCKERFILE),
str(REPO_ROOT / "backend"),
],
capture_output=True,
text=True,
)
assert result.returncode == 0, (
f"backend Dockerfile failed to parse: stderr={result.stderr!r}"
)
@pytest.mark.skipif(
not _has_docker(), reason="docker not on PATH; skipping Dockerfile parse check"
)
def test_frontend_dockerfile_parses():
result = subprocess.run(
[
"docker",
"build",
"--check",
"-f",
str(FRONTEND_DOCKERFILE),
str(REPO_ROOT),
],
capture_output=True,
text=True,
)
assert result.returncode == 0, (
f"frontend Dockerfile failed to parse: stderr={result.stderr!r}"
)
@pytest.mark.skipif(
not _has_docker_compose()
or not os.environ.get("DOCKER_TESTS"),
reason="DOCKER_TESTS=1 + docker compose required for live bring-up",
)
def test_compose_up_brings_up_healthy_stack():
"""Gated live test — only runs when DOCKER_TESTS=1 and docker compose
is available. Builds + brings up the full stack and waits up to 120s
for the backend healthcheck to come up healthy. Uses the override
file (docker-compose.override.yml) when present so the test doesn't
require sudo to create /etc/cyclone/secrets/."""
# The stack publishes host port 8080. If something else on this host
# is already using it (e.g. nocodb on a dev box) the test can't run
# here but will run cleanly on a fresh CI worker. Skip with a clear
# message rather than failing with a confusing port-bind error.
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(("127.0.0.1", 8080)) == 0:
pytest.skip("host port 8080 already bound — rerun on a fresh host")
override = REPO_ROOT / "docker-compose.override.yml"
cmd_base = ["docker", "compose", "-f", str(COMPOSE_FILE)]
if override.exists():
cmd_base.extend(["-f", str(override)])
subprocess.run(
cmd_base + ["up", "-d", "--build"],
check=True,
cwd=REPO_ROOT,
)
try:
import time
for _ in range(60):
ps = subprocess.run(
cmd_base + ["ps", "--format", "json"],
capture_output=True,
text=True,
cwd=REPO_ROOT,
)
if "healthy" in ps.stdout:
return
time.sleep(2)
pytest.fail("compose stack did not become healthy within 120s")
finally:
subprocess.run(
cmd_base + ["down", "-v"],
check=False,
cwd=REPO_ROOT,
)
+22
View File
@@ -0,0 +1,22 @@
# Local override for `docker compose up` testing on a host without root.
# Repoints the secrets at /tmp/cyclone-test-secrets so the operator
# (me, running as a non-root user) can verify the stack comes up
# healthy without needing sudo to create /etc/cyclone/secrets.
# Also remaps the frontend host port 8080 → 8090 so it doesn't collide
# with nocodb on this dev host.
#
# DO NOT ship this in production. In production, the secrets section
# in docker-compose.yml points at /etc/cyclone/secrets/ (host-managed,
# chmod 600, root:root) and no override is used.
secrets:
cyclone_db_key:
file: /tmp/cyclone-test-secrets/db.key
cyclone_admin_username:
file: /tmp/cyclone-test-secrets/admin_username
cyclone_admin_password:
file: /tmp/cyclone-test-secrets/admin_pw
services:
frontend:
ports:
- "8090:80"
+79 -76
View File
@@ -1,101 +1,104 @@
# 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
# Two-container production stack for Cyclone.
#
# backend FastAPI on python:3.11-slim-bookworm, internal port 8000.
# frontend nginx:1.27-alpine serving the built React SPA + reverse-
# proxying /api/* to the backend, host port 8080.
#
# Tag strategy:
# - Images are tagged semver from package.json / pyproject.toml.
# - Two aliases per image: `:latest` (set automatically by `docker
# compose build`) and `:stable` (manually promoted after a release
# has been running in prod for >=1 week).
# - Compose defaults to `:stable` so a fresh `docker compose build`
# never auto-rolls a new release.
# - Override with `TAG=0.0.9 docker compose up -d` to roll back.
services:
backend:
image: cyclone-backend:${TAG:-stable}
build:
context: .
dockerfile: backend/Dockerfile
image: cyclone-backend:local
container_name: cyclone-backend
context: ./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).
# SQLite path lives on the named `cyclone_db` volume.
CYCLONE_DB_URL: "sqlite:////var/lib/cyclone/db/cyclone.db"
# Wire the existing BackupService (SP17) to autostart on container boot.
CYCLONE_BACKUP_AUTOSTART: "1"
CYCLONE_BACKUP_INTERVAL_HOURS: "24"
CYCLONE_BACKUP_RETENTION_DAYS: "14"
# Logging — JSON to stdout (collected by `docker logs`) and to the
# bind-mounted file (collected by host-side logrotate).
CYCLONE_LOG_LEVEL: "INFO"
CYCLONE_LOG_FILE: "/var/log/cyclone/cyclone.log"
CYCLONE_LOG_JSON: "1"
# Cookie signing key defaults to the SQLCipher key. Override in
# production if you want to rotate them independently.
CYCLONE_SECRET_KEY_FILE: "/run/secrets/cyclone_db_key"
CYCLONE_COOKIE_SECURE: "1"
# First-admin bootstrap — populated by scripts/cyclone-init.sh.
# The `_FILE` variants are the standard Docker-secret pattern:
# the env var points at the mounted secret file rather than
# embedding the secret in the compose file.
CYCLONE_ADMIN_USERNAME_FILE: "/run/secrets/cyclone_admin_username"
CYCLONE_ADMIN_PASSWORD_FILE: "/run/secrets/cyclone_admin_password"
# Bind 0.0.0.0 so the frontend container on the compose bridge
# network can reach us. The bridge network provides isolation —
# only the `frontend` service is on it; the host firewall still
# blocks anything that isn't on the LAN.
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)}
secrets:
- cyclone_db_key
- cyclone_admin_username
- cyclone_admin_password
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.
- cyclone_db:/var/lib/cyclone/db
- cyclone_backups:/var/lib/cyclone/backups
- cyclone_prodfiles:/var/lib/cyclone/prodfiles
- cyclone_sftp_staging:/var/lib/cyclone/sftp_staging
- cyclone_logs:/var/log/cyclone
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8000/api/health"]
interval: 15s
test: ["CMD", "curl", "-fs", "http://localhost:8000/api/health"]
interval: 30s
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"
start_period: 30s
networks:
- cyclone-net
- cyclone_network
frontend:
image: cyclone-frontend:${TAG:-stable}
build:
context: .
dockerfile: frontend/Dockerfile
image: cyclone-frontend:local
container_name: cyclone-frontend
dockerfile: Dockerfile.frontend
restart: unless-stopped
ports:
# LAN-bind: only this port is published to the host. The operator
# is expected to firewall this to the LAN subnet (or rely on VPN
# for outside access). NO public TLS in v1.
- "8080:8080"
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
- cyclone_network
networks:
cyclone-net:
driver: bridge
secrets:
cyclone_db_key:
file: /etc/cyclone/secrets/db.key
cyclone_admin_username:
file: /etc/cyclone/secrets/admin_username
cyclone_admin_password:
file: /etc/cyclone/secrets/admin_pw
volumes:
cyclone-data:
name: cyclone-data
cyclone_db:
cyclone_backups:
cyclone_prodfiles:
cyclone_sftp_staging:
cyclone_logs:
networks:
cyclone_network:
driver: bridge
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,28 @@
# Cyclone Ubuntu Docker Deployment — Design
**Date:** 2026-06-22
**Status:** Approved (pending user review of this doc)
**Depends on:** [2026-06-19-cyclone-production-readiness-design.md](2026-06-19-cyclone-production-readiness-design.md) (in-memory store + react-query wiring; local-only)
**Date:** 2026-06-22 (spec drafted); 2026-06-23 (reconciled with auth work that landed in main on 2026-06-23)
**Status:** Approved (2026-06-23)
**Branch:** `sp23-ubuntu-docker-deployment`
**Aesthetic direction:** No new UI surface in v1 — the nginx + reverse-proxy posture is invisible to the operator; auth + dashboard styling is unchanged from what's already on main.
**Depends on:** [2026-06-19-cyclone-production-readiness-design.md](2026-06-19-cyclone-production-readiness-design.md) (in-memory store + react-query wiring; local-only); the auth work that merged into `main` on 2026-06-23 (`a25504b`..`39ae988`, the `feat(auth):` series).
**Replaces:** The "no auth, no Docker, local-only" posture of the parent spec with a production-grade posture for real PHI on a single Ubuntu server.
---
## 1.1 Delta vs. `main` at 2026-06-23
The auth work that landed in main before SP23 began means sections 6 (auth modules + routes + matrix) and 7 (frontend login + AuthProvider + RoleGate) describe code that **already exists on main**. SP23's delta is therefore narrower than the original draft:
- **§6 (auth) — already on main.** The `cyclone.auth` package, `users` / `sessions` SQLAlchemy models (migrations `0013` + `0014`), bcrypt password hashing, the `PERMISSIONS` matrix + `matrix_gate` app-wide dependency, `Login.tsx` + `AuthProvider` + `RequireAuth` + `RoleGate`, the `cyclone admin` CLI, and `CYCLONE_AUTH_DISABLED=1` escape hatch are all merged. SP23 **reuses** them; it does not re-implement. §6 is kept in this spec as the contract that Docker + secrets + compose env vars must satisfy, not as work to do.
- **§7 (login UI) — already on main.** Reused.
- **§6.6 (new migration) — N/A.** No new SQL migration is needed for SP23; auth tables already exist.
- **What SP23 adds (the actual work):** §6.8 backend Dockerfile, §7.8 frontend Dockerfile + nginx.conf, §8.2 Docker secrets wiring, §9.4 image tag strategy + `:stable` aliases, §10 docker-compose.yml, §11 healthcheck + restart policy, §12.2 `tests/test_docker.py`, §9.5 bootstrap script, §9.6 RUNBOOK.md, post-deploy + smoke scripts.
- **Role name reconciliation:** the original draft used `admin / operator / viewer`. Main uses `admin / user / viewer`. All references to `operator` in this spec should be read as `user` against the live codebase.
- **Hashing algorithm reconciliation:** the original draft specified `argon2id`. Main uses `bcrypt`. The Docker secret posture is unaffected — both algorithms need only a key file or a salt, neither of which SP23 introduces.
- **Lockout timing:** original draft was 5 fails / 15 min. Auth on main is 5 fails / 5 min (`cyclone.auth.rate_limit`). SP23 inherits the main behavior.
---
## 1. Overview
Cyclone's first sub-project shipped a usable local-only system: parse 837/835 files, browse the data, edit and resubmit rejected claims, export a corrected 837 ZIP. Everything ran on `127.0.0.1` with no auth, in-memory storage, and a `python -m cyclone serve` invocation.
@@ -123,7 +139,7 @@ After this ships, the operator can:
**Why LAN-only bind:** nginx listens on `0.0.0.0:8080` inside the host network namespace, but the operator is expected to bind to the LAN IP via firewall rules / not exposing on the WAN. VPN handles outside access. No public TLS needed for v1.
**Container-to-container networking:** the frontend container reaches the backend at `http://cyclone-backend:8000` over the compose-managed bridge network. The browser only ever sees `http://<lan-ip>:8080`.
**Container-to-container networking:** the frontend container reaches the backend at `http://backend:8000` over the compose-managed bridge network. The browser only ever sees `http://<lan-ip>:8080`.
**Healthcheck:** container-level `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries. Compose `restart: unless-stopped` on healthcheck failure.
@@ -139,7 +155,7 @@ server {
# API + auth: proxy to backend
location /api/ {
proxy_pass http://cyclone-backend:8000;
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -439,7 +455,7 @@ FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8080/ >/dev/null || exit 1
CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1
```
The nginx config shown in §5 lives at `frontend/nginx.conf`.
@@ -522,7 +538,7 @@ v1 assumes the host is physically secure (single-operator server, locked room) a
### 9.3 Monitoring (v1 minimal)
- No Prometheus/Grafana in v1
- Host-level cron: `curl -fs http://localhost:8080/api/healthz >/dev/null || echo "Cyclone down at $(date)" | mail -s "cyclone DOWN" you@example.com`
- Host-level cron: `curl -fs http://127.0.0.1:8080/api/healthz >/dev/null || echo "Cyclone down at $(date)" | mail -s "cyclone DOWN" you@example.com`
- Set up by `scripts/post-deploy.sh` during initial bootstrap
### 9.4 Updates
@@ -694,25 +710,25 @@ docker compose up -d --build
# 2. Wait for healthcheck
for i in {1..30}; do
if curl -fs http://localhost:8080/api/healthz > /dev/null; then break; fi
if curl -fs http://127.0.0.1:8080/api/healthz > /dev/null; then break; fi
sleep 2
done
# 3. Login
COOKIE_JAR=$(mktemp)
curl -fs -c "$COOKIE_JAR" -X POST http://localhost:8080/api/auth/login \
curl -fs -c "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"$(cat /etc/cyclone/secrets/admin_pw)\"}"
# 4. Parse a sample 837
curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/parse-837 \
curl -fs -b "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/parse-837 \
-F "file=@docs/goodclaim.x12"
# 5. List batches
curl -fs -b "$COOKIE_JAR" http://localhost:8080/api/batches
curl -fs -b "$COOKIE_JAR" http://127.0.0.1:8080/api/batches
# 6. Export
curl -fs -b "$COOKIE_JAR" -X POST http://localhost:8080/api/batches/<id>/export-837 \
curl -fs -b "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/batches/<id>/export-837 \
-H 'Content-Type: application/json' -d '{"claim_ids":["..."]}' \
-o /tmp/export.zip
@@ -781,7 +797,7 @@ DB migrations run automatically on backend start; they're forward-only. To roll
- [ ] `docker compose config` validates with no errors
- [ ] `docker compose build` succeeds for both services
- [ ] `docker compose up -d` brings both containers to `healthy` within 60s
- [ ] `curl http://localhost:8080/api/healthz` returns `{db_ok: true, scheduler_running: true}`
- [ ] `curl http://127.0.0.1:8080/api/healthz` returns `{db_ok: true, scheduler_running: true}`
### 15.2 Auth + RBAC
+41
View File
@@ -0,0 +1,41 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Long-lived connections for the live-tail NDJSON streams
# (snapshot + live events can hold open for minutes at a time).
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# Claims files can be large; 50 MiB matches what FastAPI accepts.
client_max_body_size 50m;
# API + auth: proxy to backend over the compose-managed bridge network.
location /api/ {
proxy_pass http://backend:8000;
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;
# The backend sets the cookie with Path=/api/. Rewrite to / so
# the browser sends it on every subsequent request to the SPA
# (which never has a /api/ prefix on the URL).
proxy_cookie_path /api/ /;
}
# SPA fallback: serve index.html for any non-/api route so deep-links
# to /claims/123, /admin/users, etc. round-trip through reload.
location / {
try_files $uri $uri/ /index.html;
}
# Don't cache index.html — the SPA references hashed asset filenames,
# so index.html is the only file that needs to be revalidated.
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
expires off;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cyclone",
"private": true,
"version": "0.1.0",
"version": "1.0.0",
"type": "module",
"description": "Cyclone — self-hosted EDI claims management frontend (CuNtx)",
"scripts": {
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# cyclone-init.sh — first-time host bootstrap for a production Cyclone deploy.
#
# Generates /etc/cyclone/secrets/{db.key,admin_username,admin_pw} with
# openssl rand -hex 32, sets ownership + permissions, and prints the
# admin password ONCE for the operator to capture.
#
# Idempotent: refuses to overwrite existing secrets unless --force.
#
# Usage:
# bash scripts/cyclone-init.sh # generate if missing
# bash scripts/cyclone-init.sh --force # overwrite (destroys old data!)
set -euo pipefail
SECRETS_DIR="${CYCLONE_SECRETS_DIR:-/etc/cyclone/secrets}"
FORCE=0
[[ "${1:-}" == "--force" ]] && FORCE=1
if [[ $EUID -ne 0 ]]; then
echo "ERROR: cyclone-init.sh must run as root (it writes to ${SECRETS_DIR})." >&2
exit 1
fi
if [[ -f "${SECRETS_DIR}/db.key" && $FORCE -eq 0 ]]; then
echo "Secrets already exist at ${SECRETS_DIR}. Re-run with --force to overwrite." >&2
exit 0
fi
mkdir -p "${SECRETS_DIR}"
chmod 700 "${SECRETS_DIR}"
# 64-hex-char (256-bit) random key for SQLCipher + cookie signing.
openssl rand -hex 32 > "${SECRETS_DIR}/db.key"
# Admin username defaults to "admin" unless CYCLONE_ADMIN_USERNAME is set.
ADMIN_USER="${CYCLONE_ADMIN_USERNAME:-admin}"
printf '%s' "${ADMIN_USER}" > "${SECRETS_DIR}/admin_username"
# Memorable-but-random admin password — printed once.
ADMIN_PW="$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)Aa1!"
printf '%s' "${ADMIN_PW}" > "${SECRETS_DIR}/admin_pw"
chmod 600 "${SECRETS_DIR}"/*
chown -R root:root "${SECRETS_DIR}"
cat <<EOF
================================================================
Cyclone secrets generated at ${SECRETS_DIR}.
db.key SQLCipher key + cookie signing (64 hex chars)
admin_username ${ADMIN_USER}
admin_pw ${ADMIN_PW} <-- capture this NOW; it won't be shown again.
Next steps:
cd /opt/cyclone
docker compose pull # or: docker compose build
docker compose up -d
bash scripts/post-deploy.sh # logrotate + healthcheck cron
bash scripts/smoke.sh # end-to-end smoke test
Then open http://<lan-ip>:8080/login and log in as '${ADMIN_USER}'.
Change the password from /admin/users immediately.
================================================================
EOF
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# post-deploy.sh — set up host-side logrotate and healthcheck cron for a
# production Cyclone deploy. Run once after `docker compose up -d`.
set -euo pipefail
LOG_DIR="/var/log/cyclone"
HEALTHCHECK_URL="${CYCLONE_HEALTHCHECK_URL:-http://localhost:8080/api/health}"
HEALTHCHECK_EMAIL="${CYCLONE_HEALTHCHECK_EMAIL:-root}"
CRON_USER="${CYCLONE_CRON_USER:-root}"
if [[ $EUID -ne 0 ]]; then
echo "ERROR: post-deploy.sh must run as root." >&2
exit 1
fi
# 1. Logrotate — keeps 14 days of JSON logs compressed.
cat > /etc/logrotate.d/cyclone <<'LOGROTATE'
/var/log/cyclone/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
dateext
dateformat -%Y%m%d
}
LOGROTATE
mkdir -p "${LOG_DIR}"
chmod 755 "${LOG_DIR}"
# 2. Healthcheck cron — every 5 minutes, email if down.
CRON_LINE="*/5 * * * * curl -fsS --max-time 10 ${HEALTHCHECK_URL} >/dev/null 2>&1 || echo 'Cyclone DOWN at \$(date)' | mail -s 'Cyclone health FAIL' ${HEALTHCHECK_EMAIL}"
TMP_CRON="$(mktemp)"
crontab -u "${CRON_USER}" -l 2>/dev/null > "${TMP_CRON}" || true
# Remove any existing cyclone healthcheck line, then re-add.
grep -v -F "Cyclone health" "${TMP_CRON}" > "${TMP_CRON}.new" || cp "${TMP_CRON}" "${TMP_CRON}.new"
echo "${CRON_LINE}" >> "${TMP_CRON}.new"
crontab -u "${CRON_USER}" "${TMP_CRON}.new"
rm -f "${TMP_CRON}" "${TMP_CRON}.new"
cat <<EOF
post-deploy.sh complete:
- logrotate installed at /etc/logrotate.d/cyclone
- healthcheck cron for ${HEALTHCHECK_URL} installed for user '${CRON_USER}'
- logs rotate daily, 14 days retention
Verify with:
logrotate -d /etc/logrotate.d/cyclone
crontab -u ${CRON_USER} -l | grep -i cyclone
EOF
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# smoke.sh — end-to-end bring-up + login + parse + export smoke test.
#
# Assumes:
# - `docker compose up -d` is running.
# - /etc/cyclone/secrets/admin_pw exists (or CYCLONE_SECRETS_DIR overrides).
# - The sample 837 at docs/prodfiles/co_medicaid/sample_837p.txt is present.
#
# Override the URL with CYCLONE_SMOKE_URL, the sample path with
# CYCLONE_SMOKE_SAMPLE, the secrets dir with CYCLONE_SECRETS_DIR.
set -euo pipefail
BASE_URL="${CYCLONE_SMOKE_URL:-http://localhost:8080}"
SECRETS_DIR="${CYCLONE_SECRETS_DIR:-/etc/cyclone/secrets}"
ADMIN_PW="$(cat "${SECRETS_DIR}/admin_pw")"
COOKIE_JAR="$(mktemp)"
SAMPLE_837="${CYCLONE_SMOKE_SAMPLE:-docs/prodfiles/co_medicaid/sample_837p.txt}"
cleanup() { rm -f "${COOKIE_JAR}" /tmp/cyclone_smoke_export.zip; }
trap cleanup EXIT
echo "==> waiting for health..."
for i in {1..30}; do
if curl -fsS "${BASE_URL}/api/health" >/dev/null 2>&1; then break; fi
sleep 2
[[ $i -eq 30 ]] && { echo "FAIL: health never came up"; exit 1; }
done
echo " ok"
echo "==> logging in as admin..."
HTTP_CODE=$(curl -sS -o /dev/null -w '%{http_code}' \
-c "${COOKIE_JAR}" \
-H 'Content-Type: application/json' \
-X POST "${BASE_URL}/api/auth/login" \
-d "{\"username\":\"admin\",\"password\":\"${ADMIN_PW}\"}")
[[ "${HTTP_CODE}" == "200" ]] || { echo "FAIL: login returned ${HTTP_CODE}"; exit 1; }
echo " ok (cookie set)"
echo "==> /api/auth/me..."
ME=$(curl -fsS -b "${COOKIE_JAR}" "${BASE_URL}/api/auth/me")
echo " ${ME}"
if [[ -f "${SAMPLE_837}" ]]; then
echo "==> parsing sample 837 (${SAMPLE_837})..."
PARSE=$(curl -fsS -b "${COOKIE_JAR}" -X POST "${BASE_URL}/api/parse-837" \
-F "file=@${SAMPLE_837}")
echo " ${PARSE}" | head -c 200
echo
else
echo "==> skipping 837 parse (sample not found at ${SAMPLE_837})"
fi
echo "==> listing batches..."
BATCHES=$(curl -fsS -b "${COOKIE_JAR}" "${BASE_URL}/api/batches")
echo " ${BATCHES}" | head -c 200
echo
echo "smoke: OK"
+8
View File
@@ -171,6 +171,14 @@ export interface BatchSummary {
inputFilename: string;
parsedAt: string;
claimCount: number;
/**
* Per-claim ids for 837P batches. Empty for 835 (no re-export
* endpoint) — the UI hides the one-click Re-export button when
* this is empty. Lets the History tab call
* `POST /api/batches/{id}/export-837` directly with the row's ids
* instead of an extra round-trip to `/api/batches/{id}`.
*/
claimIds: string[];
}
// ---------------------------------------------------------------------------
+531
View File
@@ -0,0 +1,531 @@
// @vitest-environment happy-dom
// Tell React this is an `act`-aware test environment so react-query's
// internal state updates flush through without noisy console warnings.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
import React, { act } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { MemoryRouter, useLocation } from "react-router-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Upload } from "./Upload";
import { useAppStore } from "@/store";
import type { ParsedBatch } from "@/types";
import type { BatchSummary } from "@/lib/api";
// Mock the api surface so the History tab never touches the network.
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: {
...actual.api,
isConfigured: true,
listBatches: vi.fn(),
exportBatch837: vi.fn(),
},
};
});
import { api } from "@/lib/api";
// The Re-export ZIP button ultimately calls `downloadBlob(filename, blob)`
// from `@/lib/download`; that helper mutates the DOM (creates a link and
// clicks it). Stub the whole module so we never touch the real DOM and
// the test can assert the filename we asked to be downloaded.
vi.mock("@/lib/download", () => ({
downloadBlob: vi.fn(),
downloadTextFile: vi.fn(),
}));
import { downloadBlob } from "@/lib/download";
// The Re-export path toasts via sonner. Mock it to keep the test output
// quiet and let assertions about success/error fire without spam.
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
message: vi.fn(),
},
}));
import { toast } from "sonner";
// The Upload page also relies on useAuth (via RoleGate). Mock it to an
// authenticated admin so the parse affordances render — we don't test
// RoleGate behavior here, only the History tab.
vi.mock("@/auth/useAuth", () => ({
useAuth: () => ({
user: { id: 1, username: "test", role: "admin" },
status: "authenticated",
}),
}));
// ---------------------------------------------------------------------------
// Test harness — Upload page lives behind a router (for useSearchParams)
// AND a QueryClient (for useBatches). Wrap both.
// ---------------------------------------------------------------------------
interface MountOpts {
initialEntries?: string[];
/** Pre-populated query data for `api.listBatches`. */
batches?: BatchSummary[];
/** Whether listBatches should resolve or reject. */
batchesStatus?: "success" | "error" | "pending";
}
interface Harness {
container: HTMLDivElement;
unmount: () => void;
tracker: { pathname: string; search: string };
}
function mountUpload(opts: MountOpts = {}): Harness {
const {
initialEntries = ["/upload"],
batches = [],
batchesStatus = "success",
} = opts;
// Wire the listBatches mock based on the requested status.
const listBatchesMock = api.listBatches as unknown as ReturnType<
typeof vi.fn
>;
listBatchesMock.mockReset();
if (batchesStatus === "success") {
listBatchesMock.mockResolvedValue(batches);
} else if (batchesStatus === "error") {
listBatchesMock.mockRejectedValue(new Error("network down"));
} else {
// pending — never resolve
listBatchesMock.mockReturnValue(new Promise(() => {}));
}
const container = document.createElement("div");
document.body.appendChild(container);
const tracker = { pathname: "/upload", search: "" };
const Tracker = () => {
const loc = useLocation();
React.useEffect(() => {
tracker.pathname = loc.pathname;
tracker.search = loc.search;
}, [loc.pathname, loc.search]);
return null;
};
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const root: Root = createRoot(container);
act(() => {
root.render(
React.createElement(
MemoryRouter,
{ initialEntries },
React.createElement(
QueryClientProvider,
{ client: qc },
React.createElement(Tracker),
React.createElement(Upload),
),
),
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
tracker,
};
}
async function flush(): Promise<void> {
// React Query schedules its settled state through a microtask +
// setTimeout chain. Two ticks is the minimum to settle a mocked
// promise end-to-end; three to be safe across happy-dom
// implementations.
for (let i = 0; i < 3; i++) {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
}
/**
* Poll a predicate until it holds or we time out. Used for assertions
* that have to wait for the query to settle (which takes more than a
* single `flush()` in some happy-dom runs).
*/
async function settle(
predicate: () => boolean,
timeoutMs = 2000,
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error("settle: predicate did not hold within timeout");
}
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
}
/**
* Click a Radix Tabs.Trigger in happy-dom. Radix listens for
* `pointerdown` (not just `click`) to fire its onValueChange handler,
* so the synthetic event sequence matters.
*/
async function clickTab(trigger: HTMLElement): Promise<void> {
await act(async () => {
trigger.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
pointerType: "mouse",
}),
);
trigger.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
);
trigger.dispatchEvent(
new MouseEvent("mouseup", { bubbles: true, cancelable: true }),
);
trigger.click();
await flush();
});
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const BATCH_837_A: BatchSummary = {
id: "BATCH-A",
kind: "837p",
inputFilename: "tp-001-837P.txt",
parsedAt: "2026-06-23T12:00:00Z",
claimCount: 12,
claimIds: ["CLM-001", "CLM-002", "CLM-003"],
};
const BATCH_837_B: BatchSummary = {
id: "BATCH-B",
kind: "837p",
inputFilename: "tp-002-837P.txt",
parsedAt: "2026-06-22T12:00:00Z",
claimCount: 7,
claimIds: ["CLM-101", "CLM-102"],
};
const BATCH_835: BatchSummary = {
id: "BATCH-C",
kind: "835",
inputFilename: "tp-003-835.txt",
parsedAt: "2026-06-21T12:00:00Z",
claimCount: 4,
claimIds: [], // 835 has no re-export endpoint
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Upload page — History tab", () => {
beforeEach(() => {
useAppStore.setState({ parsedBatches: [] });
vi.mocked(downloadBlob).mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(toast.error).mockReset();
});
afterEach(() => {
useAppStore.setState({ parsedBatches: [] });
});
it("renders the Upload tab by default and shows the History trigger with a count badge", async () => {
const { container, unmount } = mountUpload({
batches: [BATCH_837_A, BATCH_837_B],
});
await settle(() => container.textContent?.includes("· 2") === true);
// Both triggers are present.
const triggers = container.querySelectorAll('[role="tab"]');
expect(triggers.length).toBeGreaterThanOrEqual(2);
expect(container.textContent).toContain("Upload");
expect(container.textContent).toContain("History");
// Count badge shows the persisted-batch count.
expect(container.textContent).toContain("· 2");
unmount();
});
it("clicking the History trigger shows the batch table and hides the dropzone", async () => {
const { container, unmount } = mountUpload({
batches: [BATCH_837_A, BATCH_837_B],
});
await flush();
// Before click: the dropzone is rendered.
expect(
container.querySelector('[aria-label="File upload"]'),
).not.toBeNull();
// Click the History trigger.
const historyTrigger = Array.from(
container.querySelectorAll('[role="tab"]'),
).find((el) => el.textContent?.startsWith("History")) as HTMLElement;
expect(historyTrigger).toBeDefined();
await clickTab(historyTrigger);
// Now the dropzone is hidden and the history table is visible.
expect(container.querySelector('[aria-label="File upload"]')).toBeNull();
expect(container.textContent).toContain("Batch history");
expect(container.textContent).toContain("tp-001-837P.txt");
expect(container.textContent).toContain("tp-002-837P.txt");
unmount();
});
it("clicking the History trigger mirrors ?tab=history in the URL", async () => {
const { container, unmount, tracker } = mountUpload({
batches: [BATCH_837_A],
});
await flush();
const historyTrigger = Array.from(
container.querySelectorAll('[role="tab"]'),
).find((el) => el.textContent?.startsWith("History")) as HTMLElement;
await clickTab(historyTrigger);
expect(tracker.search).toContain("tab=history");
unmount();
});
it("?tab=history in the initial URL deep-links directly to the History tab", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_837_A, BATCH_837_B],
});
await flush();
// Dropzone is hidden — we landed on the History tab.
expect(container.querySelector('[aria-label="File upload"]')).toBeNull();
expect(container.textContent).toContain("Batch history");
expect(container.textContent).toContain("tp-001-837P.txt");
unmount();
});
it("renders one row per batch with sequential # column", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_837_A, BATCH_837_B, BATCH_835],
});
await settle(() =>
container.querySelectorAll("tbody tr").length === 3,
);
const rows = container.querySelectorAll("tbody tr");
expect(rows.length).toBe(3);
// Newest first → #03 (BATCH-A), #02 (BATCH-B), #01 (BATCH-C).
// The # column counts down from the total so the newest row reads
// as the highest ordinal.
expect(rows[0]?.textContent).toContain("#03");
expect(rows[0]?.textContent).toContain("tp-001-837P.txt");
expect(rows[1]?.textContent).toContain("#02");
expect(rows[1]?.textContent).toContain("tp-002-837P.txt");
expect(rows[2]?.textContent).toContain("#01");
expect(rows[2]?.textContent).toContain("tp-003-835.txt");
unmount();
});
it("renders a Re-export ZIP button for each 837P row", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_837_A, BATCH_837_B, BATCH_835],
});
await settle(
() =>
container.querySelectorAll('[data-testid="history-row-reexport"]')
.length === 2,
);
const buttons = container.querySelectorAll(
'[data-testid="history-row-reexport"]',
);
// Two 837P rows get a button; the 835 row does not.
expect(buttons.length).toBe(2);
unmount();
});
it("hides the Re-export button on 835 rows (no claimIds)", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_835],
});
await settle(() =>
container.textContent?.includes("No re-export") === true,
);
expect(
container.querySelector('[data-testid="history-row-reexport"]'),
).toBeNull();
// A muted "No re-export" placeholder is shown instead.
expect(container.textContent).toContain("No re-export");
unmount();
});
it("clicking Re-export ZIP calls api.exportBatch837 with the row's claimIds and downloads the blob", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_837_A],
});
await settle(
() =>
container.querySelector('[data-testid="history-row-reexport"]') !==
null,
);
const exportMock = api.exportBatch837 as unknown as ReturnType<
typeof vi.fn
>;
exportMock.mockResolvedValue({
blob: new Blob(["zip-bytes"]),
filename: "BATCH-A-claims.zip",
serializeErrors: [],
});
const button = container.querySelector(
'[data-testid="history-row-reexport"]',
) as HTMLButtonElement;
expect(button).not.toBeNull();
await act(async () => {
button.click();
await flush();
});
expect(exportMock).toHaveBeenCalledTimes(1);
expect(exportMock).toHaveBeenCalledWith("BATCH-A", [
"CLM-001",
"CLM-002",
"CLM-003",
]);
expect(downloadBlob).toHaveBeenCalledTimes(1);
expect(downloadBlob).toHaveBeenCalledWith(
"BATCH-A-claims.zip",
expect.any(Blob),
);
expect(toast.success).toHaveBeenCalledTimes(1);
unmount();
});
it("surfaces a toast.error when re-export fails", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [BATCH_837_A],
});
await settle(
() =>
container.querySelector('[data-testid="history-row-reexport"]') !==
null,
);
const exportMock = api.exportBatch837 as unknown as ReturnType<
typeof vi.fn
>;
exportMock.mockRejectedValue(new Error("server gone"));
const button = container.querySelector(
'[data-testid="history-row-reexport"]',
) as HTMLButtonElement;
await act(async () => {
button.click();
await flush();
});
expect(toast.error).toHaveBeenCalledTimes(1);
expect(toast.success).not.toHaveBeenCalled();
expect(downloadBlob).not.toHaveBeenCalled();
unmount();
});
it("shows a loading state on first paint while the query is pending", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batchesStatus: "pending",
});
await flush();
expect(container.textContent).toContain("Loading archive");
// No rows yet.
expect(
container.querySelector('[data-testid="history-row-reexport"]'),
).toBeNull();
unmount();
});
it("shows an error state when the query rejects", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batchesStatus: "error",
});
await settle(() => container.textContent?.includes("Couldn't load") === true);
expect(container.textContent).toContain("Couldn't load the archive");
unmount();
});
it("shows an empty state when there are no batches", async () => {
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
batches: [],
});
await flush();
expect(container.textContent).toContain("No batches in the archive");
unmount();
});
it("falls back to in-memory parsedBatches when the backend returns no rows (sample-data mode)", async () => {
const sample: ParsedBatch = {
id: "MEM-1",
kind: "837p",
inputFilename: "mem-batch.txt",
parsedAt: "2026-06-24T12:00:00Z",
claimCount: 2,
passed: 2,
failed: 0,
claimIds: ["SAMPLE-1", "SAMPLE-2"],
summary: {
input_file: "mem-batch.txt",
total_claims: 2,
passed: 2,
failed: 0,
failed_claim_ids: [],
issues_by_rule: {},
},
};
useAppStore.setState({ parsedBatches: [sample] });
const { container, unmount } = mountUpload({
initialEntries: ["/upload?tab=history"],
// Server returned nothing — fallback path activates.
batches: [],
});
await settle(() =>
container.textContent?.includes("mem-batch.txt") === true,
);
expect(container.textContent).toContain("mem-batch.txt");
expect(
container.querySelector('[data-testid="history-row-reexport"]'),
).not.toBeNull();
unmount();
});
});
+330 -2
View File
@@ -1,11 +1,13 @@
import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
AlertTriangle,
ArrowRight,
ChevronRight,
CloudUpload,
Download,
FileText,
History as HistoryIcon,
Inbox,
Loader2,
Upload as UploadIcon,
@@ -16,6 +18,7 @@ import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import {
Select,
SelectContent,
@@ -27,11 +30,13 @@ import { PageHeader } from "@/components/PageHeader";
import { ClaimCard837 } from "@/components/ClaimCard837";
import { ExportBar } from "@/components/ExportBar";
import { StatPill, ValidationDot } from "@/components/ClaimCard/shared";
import { api, type ParseProgress } from "@/lib/api";
import { api, ApiError, type BatchSummary, type ParseProgress } from "@/lib/api";
import { downloadBlob } from "@/lib/download";
import { fmt, toNum } from "@/lib/format";
import { useAppStore } from "@/store";
import { useParse } from "@/hooks/useParse";
import { useBatchExport } from "@/hooks/useBatchExport";
import { useBatches } from "@/hooks/useBatches";
import type {
ClaimOutput,
ClaimPayment,
@@ -348,6 +353,21 @@ function HeroStat({
export function Upload() {
const inputRef = useRef<HTMLInputElement>(null);
const [searchParams, setSearchParams] = useSearchParams();
// Tab URL state — `?tab=history` deep-links the History tab;
// `?tab=upload` (or missing) is the default. Stored in the URL so
// the back button round-trips between the two surfaces.
const tab = searchParams.get("tab") === "history" ? "history" : "upload";
const setTab = (next: "upload" | "history") => {
setSearchParams(
(prev) => {
if (next === "upload") prev.delete("tab");
else prev.set("tab", next);
return prev;
},
{ replace: true },
);
};
const [file, setFile] = useState<File | null>(null);
const [kind, setKind] = useState<ParsedBatchKind>("837p");
const [payer, setPayer] = useState<string>(PAYERS_837[0]!.value);
@@ -362,6 +382,14 @@ export function Upload() {
const parsedBatches = useAppStore((s) => s.parsedBatches);
const parseMutation = useParse(kind);
// Persisted batch count for the History tab badge. Disabled when
// there's no backend configured (sample-data mode); in that case we
// fall back to the in-memory parsedBatches so the badge still reads.
const batchesQuery = useBatches();
const persistedBatchCount = batchesQuery.data?.length ?? 0;
const historyCount =
persistedBatchCount > 0 ? persistedBatchCount : parsedBatches.length;
// Batch-export wiring (SP9 — Upload → ZIP flow). The hook owns the
// selection set, the exporting flag, the export handler, and the
// captured server-side batch id. See `src/hooks/useBatchExport.ts`.
@@ -607,6 +635,34 @@ export function Upload() {
</div>
</section>
{/* =================================================================
TABS — the page's content switcher. Two surfaces:
· Upload — drop zone + stream + recent batches (the
"in-flight" instrument)
· History — persisted batch archive with one-click
Re-export ZIP per row
Tab is mirrored to `?tab=` so deep-links round-trip.
================================================================= */}
<Tabs.Root
value={tab}
onValueChange={(v) => setTab(v as "upload" | "history")}
>
<Tabs.List aria-label="Upload page sections">
<Tabs.Trigger value="upload">Upload</Tabs.Trigger>
<Tabs.Trigger value="history">
History
{historyCount > 0 ? (
<span
aria-hidden
className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/60 ml-1.5"
>
· {historyCount}
</span>
) : null}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="upload">
{/* =================================================================
DROP ZONE — the page's centerpiece. Single large surface-2
card with an inline payer-config header bar, a centered drop
@@ -1113,6 +1169,278 @@ export function Upload() {
</Card>
</section>
) : null}
</Tabs.Content>
<Tabs.Content value="history">
<UploadHistory />
</Tabs.Content>
</Tabs.Root>
</div>
);
}
// ---------------------------------------------------------------------------
// UploadHistory — persisted batch archive rendered inside the History tab.
//
// Source: `useBatches()` (the backend's `/api/batches` list). Independent of
// the in-memory `parsedBatches` store so it survives a page reload — the
// store is purely session-local. When the backend isn't configured (sample-
// data mode) we fall back to the in-memory list so the tab still renders.
// ---------------------------------------------------------------------------
function UploadHistory() {
const parsedBatches = useAppStore((s) => s.parsedBatches);
const batchesQuery = useBatches();
const liveBatches: BatchSummary[] = useMemo(() => {
if (batchesQuery.data && batchesQuery.data.length > 0) {
return batchesQuery.data;
}
// Sample-data fallback — synthesize BatchSummary rows from the
// in-memory parsedBatches so the History tab is never empty in
// a demo. Newest first.
return [...parsedBatches]
.reverse()
.map<BatchSummary>((b) => ({
id: b.id,
kind: b.kind,
inputFilename: b.inputFilename,
parsedAt: b.parsedAt,
claimCount: b.claimCount,
claimIds: b.claimIds,
}));
}, [batchesQuery.data, parsedBatches]);
if (batchesQuery.isLoading && batchesQuery.data === undefined) {
return (
<Card>
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
<Loader2
className="h-4 w-4 animate-spin text-muted-foreground"
aria-hidden
/>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-3">
Loading archive
</div>
</CardContent>
</Card>
);
}
if (batchesQuery.isError) {
return (
<Card>
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
<XCircle
className="h-5 w-5 text-[hsl(var(--destructive))]"
strokeWidth={1.5}
aria-hidden
/>
<div className="display text-[18px] tracking-tight mt-3">
Couldn't load the archive.
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-2">
{batchesQuery.error instanceof Error
? batchesQuery.error.message
: "Network error"}
</div>
</CardContent>
</Card>
);
}
if (liveBatches.length === 0) {
return (
<Card>
<CardContent className="p-10 lg:p-14 flex flex-col items-center justify-center text-center">
<div className="h-10 w-10 rounded-md bg-muted/50 ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground mb-3">
<HistoryIcon className="h-4 w-4" strokeWidth={1.5} />
</div>
<div className="display text-[20px] tracking-tight">
No batches in the archive yet.
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/70 mt-2">
Switch to Upload and drop a file to ingest your first batch.
</div>
</CardContent>
</Card>
);
}
return <HistoryTable batches={liveBatches} />;
}
function HistoryTable({ batches }: { batches: BatchSummary[] }) {
const totalClaims = batches.reduce((s, b) => s + b.claimCount, 0);
const lastAt = batches[0] ? fmt.dateShort(batches[0].parsedAt) : "—";
return (
<Card>
<CardContent className="p-6 lg:p-7">
<div className="flex items-end justify-between gap-6 flex-wrap mb-5">
<div>
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
Batch history
</div>
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
Archive{" "}
<span className="italic text-muted-foreground/85">
· {batches.length}
</span>
</h2>
</div>
<div className="text-right">
<div className="mono text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70">
Last ingest
</div>
<div className="display mono text-[22px] leading-[1.05] mt-1.5 tracking-tight">
{lastAt}
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/60 mt-1">
{fmt.num(totalClaims)} claims
</div>
</div>
</div>
<div
className="rounded-md border overflow-hidden"
style={{ borderColor: "hsl(30 14% 14% / 0.10)" }}
>
<table className="w-full text-[12.5px]">
<thead style={{ backgroundColor: "hsl(36 22% 90%)" }}>
<tr
className="text-left"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-12">
#
</th>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-20">
Kind
</th>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px]">
File
</th>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-20 text-right">
Claims
</th>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-28">
Parsed
</th>
<th className="px-3 py-2 font-medium mono uppercase tracking-[0.14em] text-[10.5px] w-44 text-right">
Action
</th>
</tr>
</thead>
<tbody>
{batches.map((b, i) => (
<HistoryRow
key={b.id}
batch={b}
index={batches.length - i}
/>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function HistoryRow({
batch,
index,
}: {
batch: BatchSummary;
index: number;
}) {
const [exporting, setExporting] = useState(false);
const canReexport = batch.kind === "837p" && batch.claimIds.length > 0;
async function onReexport() {
if (!canReexport || exporting) return;
setExporting(true);
try {
const result = await api.exportBatch837(batch.id, batch.claimIds);
downloadBlob(result.filename, result.blob);
const warn =
result.serializeErrors.length > 0
? ` · ${result.serializeErrors.length} skipped`
: "";
toast.success(`Re-exported ${batch.claimIds.length} claims${warn}`, {
description: result.filename,
});
} catch (err) {
toast.error(
err instanceof ApiError
? `Re-export failed (${err.status})`
: err instanceof Error
? err.message
: "Re-export failed",
);
} finally {
setExporting(false);
}
}
return (
<tr
className="border-t align-middle"
style={{ borderColor: "hsl(30 14% 14% / 0.08)" }}
>
<td
className="px-3 py-2.5 mono text-[11px] text-muted-foreground/60"
>
#{String(index).padStart(2, "0")}
</td>
<td className="px-3 py-2.5">
<Badge variant={batch.kind === "837p" ? "default" : "muted"}>
{batch.kind === "837p" ? "837P" : "835"}
</Badge>
</td>
<td className="px-3 py-2.5">
<div className="font-medium truncate max-w-[42ch]">
{batch.inputFilename}
</div>
</td>
<td className="px-3 py-2.5 text-right display mono">
{fmt.num(batch.claimCount)}
</td>
<td
className="px-3 py-2.5 mono text-[11px]"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{fmt.dateShort(batch.parsedAt)}
</td>
<td className="px-3 py-2.5 text-right">
{canReexport ? (
<Button
variant="ghost"
size="sm"
onClick={onReexport}
disabled={exporting}
data-testid="history-row-reexport"
data-batch-id={batch.id}
>
{exporting ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Exporting
</>
) : (
<>
<Download className="h-3.5 w-3.5" />
Re-export ZIP
</>
)}
</Button>
) : (
<span className="mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground/50">
No re-export
</span>
)}
</td>
</tr>
);
}