v1.6.0 — multi-static (users + statics + N:M memberships)
User picked **N:M users-to-statics** + **scope-only-user-curated-data** (2026-05-25). The rest of the design is documented inline in [api/auth.py](api/auth.py) + the migration doc…
MINOR per CLAUDE.md §4 — first real scope expansion past PLAN §1's single-static premise.
User picked N:M users-to-statics + scope-only-user-curated-data (2026-05-25). The rest of the design is documented inline in api/auth.py + the migration docstring at alembic/versions/11cd54903d42_multi_static.py.
Schema (migration 11cd54903d42_multi_static)
- New tables:
users(id, username unique, current_static_id, created_at),statics(id, name, created_at),static_memberships(user_id, static_id PK, joined_at). static_idadded towatched_reports,members,strat_config,prog_points,fault_scores. All existing rows migrated to the seeded Default Static (id=1) so a pre-1.6.0 install upgrades without data loss.- PK changes:
watched_reports(code) → (static_id, code). Two statics can watch the same report; the ingestion ledger short-circuits redundant raw-data work.strat_config(encounter_id, mechanic_ref) → (static_id, encounter_id, mechanic_ref).fault_scores(fight_id, player_id) → (static_id, fight_id, player_id).membersunique(name) → unique(static_id, name).character_aliases: global unique(character_name, server) dropped — scoping is via member → static.
Auth + identity (api/auth.py)
- Existing HTTP Basic middleware loosened:
AUTH_PASSWORDstays a single shared password, but the USERNAME is now free-form. The middleware no longer enforcesprovided_user == AUTH_USERNAME— anyone with the password can log in as any username, and that becomes their user record on first request. - New
Contextdataclass +get_context(request)FastAPI dependency. The dependency auto-provisions the user record on first sighting and auto-joins them to the Default Static so a fresh deploy withAUTH_USERNAME=aoikeeps working out of the box. - Authorization via
require_static_membership(session, user_id, static_id). Cross-static access returns 404 (not 403) to avoid leaking existence of statics the user can't see. - Dev/test mode (no
AUTH_*env): username extracted from the Authorization header if present; otherwise falls back to adevuser. Tests use this hook to simulate N users without enabling the password layer.
API
- New endpoints:
GET /api/me— user + current_static_id + statics list (drives the switcher).GET /api/statics,POST /api/statics(creator auto-joins + auto-switches if they were on Default Static).PATCH /api/me/current-static(404 if not a member).GET /api/statics/{id}/members,POST /api/statics/{id}/members(404 if username doesn't exist — they must log in once first),DELETE /api/statics/{id}/members/{user_id}(409 on last-member removal).
- Existing endpoints scoped — all ~21 endpoints that touch
watched_reports/members/character_aliases/strat_config/prog_points/fault_scoresnow takectx: Context = Depends(get_context)and filter byctx.current_static_id. Analytical endpoints (prog-curve,consistency,post-clear-targets,mit-audit,fault-scores/{compute,disambiguate},session-report,fault-aggregate) pass the static_id through to their analysis modules. - Shared raw-data endpoints untouched (reports, fights, events, fight_model, abilities, ability_labels, dps_check, gate-diagnostic, cartography, recovery, wipe-type — all static-agnostic since the underlying tables aren't scoped).
Analysis modules updated to take static_id
- analysis/strat_config.py, analysis/prog_trajectory.py, analysis/consistency.py, analysis/optimization.py, analysis/mit_audit.py, analysis/fault_attribution.py, analysis/fault_disambiguation.py, analysis/session_report.py.
classify_wipe_typeleft static-agnostic (reads fight_model + Event only).jobs/poll_watched.py::poll_one_by_codegrew an optionalstatic_idkwarg so the API path scopes the lookup; the CLI path (no kwarg) picks the first watch across any static for ad-hoc use.
React
- New web/src/StaticSwitcher.jsx in the App header: shows
{username} · <static dropdown>+ amembersbutton (opens modal: list + add by username + remove) + a+ staticbutton (inline create-and-switch). - App tabs remount on static switch via a
staticKeyprop so they refetch scoped data without page reload.
Tests
- 10 new in tests/test_multi_static.py:
/api/meshape, cross-static isolation for watched_reports / members / strat_config, 404 on access to other-static rows, 404 on switching to non-member static, add-member then they see the data, unknown-username add returns 404, last-member-removal blocked, member listing. Each test uses unique disposable usernames + tears down its own users/statics. - Updated existing tests (test_consistency, test_fault_attribution, test_fault_disambiguation, test_mit_audit, test_optimization, test_prog_trajectory, test_session_report, test_resolve_members, test_schema, test_roster_api, test_poll_watched, test_auth_middleware) to seed
static_id=1and passstatic_idthrough to updated function signatures.test_api_route_rejects_wrong_usernameretired; replaced withtest_api_route_accepts_any_username_with_right_passworddocumenting the new multi-user-shared-password model. - 396 tests passing (379 → 396, +17).
Migration ergonomics
- Pre-1.6.0 installs upgrade with no manual steps. Existing single-user
AUTH_USERNAME=aoideploys: first request creates user "aoi", joins Default Static (which now holds all migrated data), sets current_static_id = 1. Existing behavior preserved. - Single shared password is conscious tech debt — adequate for the friend-group case. For a real public deploy this should move to Cloudflare Access (which the named-tunnel runbook in README already covers) and the HTTP Basic middleware unsets.