Raxx · internal docs

internal · gated ↑ index

raxx-console — Operator Admin Console Architecture

Status: Draft Owner: software-architect Last updated: 2026-04-22 Related ADRs: 0001, 0002, 0003, 0004


1. Goal + North Star

console.raxx.app is the internal operator surface for the Raxx platform. It gives the operations team visibility and control over user accounts, feature flags, paper-trade audit, infrastructure health, and maintenance mode — without giving operators direct database access or raw Heroku access.

North star: any admin action that affects money, user access, or data must produce an audit row attributable to the named admin, not the system. Impersonation, flag flips, account disables — all attributed, all timestamped, all queryable.


2. Non-Goals (v1)

The following are deliberately out of scope and must not slip in:


3. System Context

graph TD
    subgraph "Public internet"
        AdminBrowser["Admin browser\n(passkey + TOTP)"]
    end

    subgraph "console.raxx.app (Heroku: raxx-console)"
        ConsoleApp["raxx-console\nFlask + Jinja2 + HTMX"]
        ConsoleDB[("Console Postgres\n(admin identities,\nsessions, audit_log)")]
    end

    subgraph "api.raxx.app (Heroku: raxx-api-prod)"
        Raptor["Raptor\n(Flask backend_v2)"]
        RaptorDB[("Raptor Postgres\n(user data,\ntrade history)")]
    end

    subgraph "Cloudflare"
        CFPages["Antlers\n(raxx.app)"]
    end

    subgraph "External"
        HerokuAPI["Heroku Platform API"]
        CFApi["Cloudflare API"]
        Sentry["Sentry"]
        Mail["Mail provider"]
    end

    AdminBrowser -->|HTTPS, session cookie| ConsoleApp
    ConsoleApp -->|reads/writes| ConsoleDB
    ConsoleApp -->|short-lived JWT, HTTPS| Raptor
    Raptor -->|reads/writes| RaptorDB
    ConsoleApp -->|GET deploy status| HerokuAPI
    ConsoleApp -->|GET Pages deploy| CFApi
    ConsoleApp -->|GET error counts| Sentry
    ConsoleApp -->|send bootstrap / invite emails| Mail
    Raptor -->|honor MAINTENANCE_MODE flag| CFPages

4. Invariants

All platform-level invariants apply. Console-specific notes:

  1. No stored credentials, ever. Console stores passkey public keys and TOTP seeds. TOTP seeds are written once at enrollment, stored encrypted at rest (AES-256-GCM, key in CONSOLE_TOTP_ENCRYPTION_KEY env var). The seed is never returned via any API response after enrollment. This is the one exception to "no secrets stored": TOTP seeds must persist across process restarts, and server-side TOTP is an accepted pattern in combination with passkey as the primary factor. The seed is not a replayable user-secret the way a password is; it cannot impersonate without also possessing the enrolled passkey.
  2. Passkey is primary factor. TOTP is mandatory second factor. Both must be presented at login. This is stricter than the user app (passkey-only there). Rationale: operator access has higher stakes.
  3. No direct DB peering with Raptor. Console calls Raptor admin endpoints over HTTPS. The Raptor database URL is never present in console's env.
  4. Every state-changing admin action writes to console.audit_log. This is not optional and is enforced at the middleware layer, not per-route.
  5. Impersonation is always audit-attributed to the admin. Impersonation actions in Raptor carry the X-Console-Admin-Id header; Raptor logs this. The user being impersonated never appears as the actor.
  6. Paper-first gating is Raptor's responsibility. Console does not gate live trading; it can read the flag state but not bypass it.

5. Auth + Session Flow

5.1 Role Matrix

Permission superadmin ops support readonly
View dashboard (infra health) Y Y Y Y
View user list (minimal PII) Y Y Y Y
View user audit log Y Y Y Y
Disable / re-enable user account Y Y N N
Trigger manual recovery email Y Y Y N
Impersonate user (audited) Y N N N
View paper-trade audit browser Y Y Y Y
Toggle feature flags Y Y N N
Toggle maintenance-mode banner Y Y N N
Invite new admin (any role) Y N N N
Elevate or demote admin role Y N N N
View console audit log Y Y N N

Roles are stored as an enum in console_admins.role. No bare boolean is_admin column exists anywhere.

5.2 First-Admin Bootstrap

Bootstrap is a one-shot CLI operation that must be run from within the Heroku dyno context:

heroku run --app raxx-console python -m console.bootstrap --email <addr>

Sequence:

sequenceDiagram
    participant CLI as bootstrap CLI
    participant DB as Console Postgres
    participant Mail as Mail provider
    participant Admin as First admin (browser)

    CLI->>CLI: Verify no admins exist in DB (abort if any)
    CLI->>DB: Insert admin stub (email, role=superadmin, status=pending)
    CLI->>CLI: Generate signed 24h one-shot token (HMAC-SHA256, CONSOLE_BOOTSTRAP_SECRET)
    CLI->>CLI: Print token URL to stdout
    CLI->>Mail: Send token URL to ADMIN_RECOVERY_EMAIL
    Admin->>ConsoleApp: GET /bootstrap/claim?token=<tok>
    ConsoleApp->>ConsoleApp: Verify token (sig + expiry + not consumed)
    ConsoleApp->>Admin: Render passkey registration + TOTP seed QR
    Admin->>ConsoleApp: POST /bootstrap/complete {attestation, totp_code}
    ConsoleApp->>ConsoleApp: Verify passkey attestation + first TOTP code
    ConsoleApp->>DB: Store webauthn_credential, encrypted TOTP seed, mark token consumed
    ConsoleApp->>Admin: Redirect to /dashboard

Token is consumed on first successful /bootstrap/complete. A second call with the same token returns 410 Gone. The CONSOLE_BOOTSTRAP_SECRET is an env var; rotating it invalidates any outstanding bootstrap tokens (acceptable — you would just re-run the CLI).

5.3 Post-Bootstrap Admin Invite

Once at least one admin exists, new admins are added via invite:

  1. superadmin submits invite form: target email + role.
  2. Console sends a 48h signed invite token to the target email (same HMAC pattern, purpose=admin_invite).
  3. Target clicks link, registers passkey + TOTP.
  4. On completion, an existing admin (any superadmin) receives an email: "New admin X has registered. Confirm their access." — contains an approve/reject link.
  5. Approval sets console_admins.status = active. Rejection soft-deletes the pending record.

This contingent-authorization step means no admin is active without a second human confirming it.

5.4 Normal Login

sequenceDiagram
    participant Admin as Admin browser
    participant Console as raxx-console

    Admin->>Console: GET /login
    Console-->>Admin: Render passkey prompt
    Admin->>Console: POST /auth/passkey/begin
    Console-->>Admin: WebAuthn challenge
    Admin->>Console: POST /auth/passkey/complete {assertion}
    Console->>Console: Verify assertion, check admin status=active
    Console-->>Admin: Render TOTP prompt (no session yet)
    Admin->>Console: POST /auth/totp/verify {code}
    Console->>Console: Verify TOTP (±1 window), issue session cookie
    Console-->>Admin: Redirect to /dashboard

Session is issued only after both factors succeed. Session cookie is HttpOnly, Secure, SameSite=Strict, 8-hour fixed window (no rolling — admin sessions are shorter than user sessions). Every subsequent request re-checks console_admins.status to handle mid-session revocations.


6. Data Model (Console Postgres)

Console has its own Postgres database. It stores only admin identity data, sessions, and the console-side audit log. It never replicates Raptor user data.

console_admins
  id                TEXT PK           (uuid v4)
  email             TEXT UNIQUE NOT NULL
  role              TEXT NOT NULL      -- 'superadmin'|'ops'|'support'|'readonly'
  status            TEXT NOT NULL      -- 'pending'|'active'|'suspended'
  invited_by        TEXT NULL FK -> console_admins.id
  created_at        TIMESTAMP NOT NULL
  activated_at      TIMESTAMP NULL
  suspended_at      TIMESTAMP NULL
  CHECK (role IN ('superadmin','ops','support','readonly'))
  CHECK (status IN ('pending','active','suspended'))

console_webauthn_credentials
  id                TEXT PK           (credential_id, base64url)
  admin_id          TEXT FK -> console_admins.id ON DELETE CASCADE
  public_key        BLOB NOT NULL
  sign_count        INTEGER NOT NULL DEFAULT 0
  transports        TEXT NULL
  aaguid            TEXT NULL
  device_label      TEXT NULL
  created_at        TIMESTAMP NOT NULL
  last_used_at      TIMESTAMP NULL

console_totp_seeds
  admin_id          TEXT PK FK -> console_admins.id ON DELETE CASCADE
  encrypted_seed    BLOB NOT NULL     -- AES-256-GCM, key=CONSOLE_TOTP_ENCRYPTION_KEY
  enrolled_at       TIMESTAMP NOT NULL
  last_verified_at  TIMESTAMP NULL

console_sessions
  id                TEXT PK           -- SHA-256(random 256-bit token)
  admin_id          TEXT FK -> console_admins.id ON DELETE CASCADE
  issued_at         TIMESTAMP NOT NULL
  expires_at        TIMESTAMP NOT NULL  -- issued_at + 8h, no rolling
  revoked_at        TIMESTAMP NULL
  ip_prefix         TEXT NULL           -- /24 IPv4, /48 IPv6
  user_agent        TEXT NULL

console_bootstrap_tokens
  id                TEXT PK
  email             TEXT NOT NULL
  token_hash        TEXT NOT NULL      -- SHA-256 of raw token
  purpose           TEXT NOT NULL      -- 'bootstrap'|'admin_invite'
  expires_at        TIMESTAMP NOT NULL
  consumed_at       TIMESTAMP NULL
  created_at        TIMESTAMP NOT NULL

console_audit_log
  id                BIGSERIAL PK
  actor_admin_id    TEXT NOT NULL FK -> console_admins.id
  action            TEXT NOT NULL      -- 'user.disable', 'flag.toggle', 'impersonate.begin', ...
  target_kind       TEXT NULL          -- 'raptor_user'|'feature_flag'|'admin'|'maintenance_mode'
  target_id         TEXT NULL
  context           JSONB NULL         -- redacted; no PII in context values
  raptor_user_id    TEXT NULL          -- denormalized for impersonation rows; NOT a FK to Raptor
  at                TIMESTAMP NOT NULL
  -- retention: 2 years

What is deliberately absent: any column named password, password_hash, recovery_code. The CI grep from ADR-0002 extends to cover the console/ directory tree.


7. Data Access Pattern — Console to Raptor

Console calls Raptor admin endpoints via authenticated HTTPS. Authentication uses a short-lived (15-minute) JWT signed with CONSOLE_RAPTOR_JWT_SECRET (shared env var, rotatable). The JWT carries {"sub": "<admin_id>", "role": "<role>", "iat": ..., "exp": ...}. Raptor validates this JWT on every request to /api/admin/* routes.

Console never reads Raptor's database URL. If Raptor is unreachable, console degrades gracefully (shows cached last-known status where available, shows "Raptor unreachable" badge elsewhere).

Required Raptor admin endpoints (Raptor must implement these in separate sub-cards):

Method Path Console usage Role gate (console-side)
GET /api/admin/users User list page, paginated ops, support, readonly
GET /api/admin/users/{id} User detail ops, support, readonly
GET /api/admin/users/{id}/audit User audit log ops, support, readonly
POST /api/admin/users/{id}/disable Disable account ops
POST /api/admin/users/{id}/enable Re-enable account ops
POST /api/admin/users/{id}/recovery-email Kick recovery email ops, support
POST /api/admin/users/{id}/impersonate Begin impersonation session superadmin
DELETE /api/admin/users/{id}/impersonate End impersonation session superadmin
GET /api/admin/paper-trades Paper trade audit browser ops, support, readonly
GET /api/admin/feature-flags Read flag state ops, readonly
PUT /api/admin/feature-flags/{name} Write flag state ops
GET /api/system/status Dashboard health all
POST /api/admin/maintenance Set maintenance mode ops

Raptor must log every inbound admin call to its own audit_log with the X-Console-Admin-Id header value captured as the actor. This creates a double audit trail: console logs the outbound action, Raptor logs the inbound execution.


8. Deployment Topology

console.raxx.app
  → Cloudflare DNS (CNAME -> raxx-console.<heroku-app-domain>)
  → Heroku app: raxx-console
      buildpack: heroku/python
      Procfile: web: gunicorn --chdir console --bind 0.0.0.0:$PORT --workers 1 --timeout 60 app:app
      formation: 1x eco dyno (upgrade to basic at launch)
      addons: heroku-postgresql:essential-0

Environment variables (Heroku config vars):
  CONSOLE_ENV                  = production
  DATABASE_URL                 (injected by Heroku Postgres addon)
  CONSOLE_BOOTSTRAP_SECRET     (HMAC key for bootstrap + invite tokens; rotatable)
  CONSOLE_TOTP_ENCRYPTION_KEY  (AES-256-GCM key for TOTP seeds; rotatable without data loss if re-encrypted on rotation)
  CONSOLE_RAPTOR_JWT_SECRET    (shared with raxx-api-prod; rotatable)
  CONSOLE_SESSION_SECRET       (Flask session signing key)
  ADMIN_RECOVERY_EMAIL         (bootstrap failsafe email destination)
  MAIL_PROVIDER_KEY            (separate sender domain from user app — see §13)
  SENTRY_DSN_CONSOLE           (separate project from raxx-api; Python/Flask platform)
  WEBAUTHN_RP_ID               = console.raxx.app       # NOT raxx.app — see ADR 0012
  WEBAUTHN_ORIGIN              = https://console.raxx.app

CONSOLE_RAPTOR_JWT_SECRET rotation: update config var on both raxx-console and raxx-api-prod simultaneously. In-flight JWTs (15 min max) will fail until they expire; acceptable for an operator console.

CONSOLE_TOTP_ENCRYPTION_KEY rotation: requires a one-time re-encryption migration (decrypt all seeds with old key, re-encrypt with new key) run as a Heroku one-off dyno before the new key is set.

Staging mirrors prod: raxx-console-staging, console-staging.raxx.app (or internal access only). Staging shares no data with prod.


9. Observability


10. Failure Modes

Failure Behavior
Raptor is down Console shows "Raptor unreachable" banner. User management and flag pages show last-known cached state (TTL 5 min) or error state. Console itself remains available.
Console Postgres is down Console returns 503 for all pages. Health endpoint returns {"db": "error"}.
Admin loses passkey Admin emails another superadmin to trigger /admin-recovery. A superadmin can email a new bootstrap token to the locked-out admin's registered email, scoped to purpose=passkey_reset. This re-runs the passkey registration flow, revokes all prior passkeys for that admin, and writes audit_log.action = admin.passkey_reset. If no other superadmin exists (single-admin situation), fall back to heroku run python -m console.bootstrap --reset-email <addr> which requires Heroku access (i.e. you already have infra access).
Admin loses TOTP device Same recovery path as passkey loss — contingent authorization from another superadmin. TOTP seed is re-enrolled alongside passkey in the same recovery flow.
CONSOLE_TOTP_ENCRYPTION_KEY lost TOTP seeds are unreadable. All admins must re-enroll TOTP. This is operationally disruptive but not a security incident (seeds were encrypted, not exposed). Trigger: re-enrollment flow on next login.
Bootstrap token expires unused Re-run heroku run python -m console.bootstrap --email <addr>. Prior pending admin stub is cleaned up first.
Maintenance-mode flag stuck on Any ops or superadmin can toggle it off. If console is unreachable, the flag can be cleared via heroku config:set MAINTENANCE_MODE=false --app raxx-api-prod as a last resort (the env var is the kill-switch; Raptor checks it on every request).

11. Migrations

Console database is standalone. Migrations live in console/db/migrations/ using the same Alembic-or-raw-SQL pattern established in backend_v2/db/.

Migration 0001: creates all tables in §6 above.

Rollback: console/db/migrations/0001_down.sql drops all console tables. There is no Raptor data in console — rollback is clean.

Schema changes to Raptor admin endpoints are independent. Console degrades gracefully on unexpected Raptor response shapes (strict response parsing with fallback to error display, never a crash).


12. Rollout Plan

  1. Bootstrap phase: Scaffold app, deploy to raxx-console-staging, run heroku run python -m console.bootstrap --email kris@moosequest.net on staging. First admin registered. No feature flags needed — the app itself is gated by not existing yet.
  2. Internal alpha: All v1 features enabled, access restricted to registered admins only (the subdomain is not publicly linked). Exercise each feature against staging Raptor.
  3. Raptor admin endpoint phase: Implement Raptor's /api/admin/* endpoints (separate sub-cards) and wire them to console.
  4. Prod launch: DNS cutover for console.raxx.app, deploy raxx-console prod app, bootstrap first prod admin.
  5. Post-launch: Invite remaining ops/support admins via the in-app invite flow.

13. Security Considerations


14. Decisions locked (2026-04-22)

All open questions resolved by user on 2026-04-22. Recorded for traceability.

  1. Mail provider: separate sender domain. Console mail sends from no-reply@console.raxx.app (or similar under the console subdomain) — NOT the user-app sender. Rationale: if the console sender domain gets flooded / abused / blacklisted, we can null-route it without affecting user-facing raxx.app email deliverability. Requires separate SPF / DKIM / DMARC records on console.raxx.app. MAIL_PROVIDER_KEY may or may not be the same Google Workspace / SendGrid account; what matters is the sender domain is isolated.
  2. Heroku Platform API credentials: v1 manual rotation. Kris owns the tokens, rotates them quarterly, console displays their last-rotation date as a dashboard signal. The full automated rotation service (containerized, cron-scheduled, console-invokable, with pre/post smoke tests and rollback) is a v2 feature with its own epic (filed separately). Not in console v1 scope.
  3. Cloudflare API token scope: v1 manual rotation, read-only for dashboard. Same pattern as Heroku — read-only token minimally scoped for the Pages deploy status widget. Rotation handled manually for v1; automated in the v2 rotation service.
  4. WEBAUTHN_RP_ID for console: separate — console.raxx.app. NOT shared with raxx.app. Per ADR 0012. Browser-level origin matching prevents user credentials from being offered at console login and vice versa. See ADR 0012 for rationale (hardware-key role binding, defense in depth, zero pre-launch migration cost).
  5. Sentry project: separate. Platform: Python / Flask (matches console stack per ADR 0004). DSN stored as SENTRY_DSN_CONSOLE in Heroku config + GitHub secrets. Billing: confirmed acceptable under the current tier.

14a. Deferred to v2 / own epic


End of doc. ADR 0004 records the stack choice. ADR 0012 records the separate-RP-ID decision for console WebAuthn. Sub-cards listed in the PR description.