Status: Draft Owner: software-architect Last updated: 2026-04-22 Related ADRs: 0001, 0002, 0003, 0004
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.
The following are deliberately out of scope and must not slip in:
/api/gdpr/erase)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
All platform-level invariants apply. Console-specific notes:
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.console.audit_log. This is not optional and is enforced at the middleware layer, not per-route.X-Console-Admin-Id header; Raptor logs this. The user being impersonated never appears as the actor.| 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.
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).
Once at least one admin exists, new admins are added via invite:
superadmin submits invite form: target email + role.purpose=admin_invite).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.
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.
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.
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.
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.
raxx-console (separate project from raxx-api), platform: Python / Flask (matches the console stack per ADR 0004). DSN stored as SENTRY_DSN_CONSOLE in Heroku config vars + GitHub secrets. Separate DSN so console errors do not inflate API error counts and vice versa. Confirmed on 2026-04-22.console_audit_log table) is the source of truth for admin actions. A read of the audit log itself writes a row (action: audit_log.read, actor: <admin_id>).GET /health returns {"status": "ok", "db": "ok"|"error"}. No auth required; used by Heroku health checks and the deploy pipeline smoke test./api/system/status, Sentry API for 24h error count. All four calls run concurrently with a 5s timeout each; partial failure shows "unavailable" badges, not a 500.| 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). |
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).
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./api/admin/* endpoints (separate sub-cards) and wire them to console.console.raxx.app, deploy raxx-console prod app, bootstrap first prod admin.ALPACA_* env vars.heroku run python -m console.admin delete --id <id>).console_totp_seeds is exfiltrated, seeds are encrypted. Decryption requires CONSOLE_TOTP_ENCRYPTION_KEY. Incident response: rotate key (triggers re-enrollment), revoke all active sessions, notify per ADR-0003 breach pipeline./auth/passkey/complete and /auth/totp/verify are rate-limited per IP: 10 attempts / 10 minutes, exponential backoff. Lockout after 20 failures in 1 hour writes audit_log.action = auth.lockout and emails the locked-out admin.console/ as well.CONSOLE_AUTH_DISABLED=1 shuts down all /auth/* routes to 503 and logs every attempt, for use during an active incident.All open questions resolved by user on 2026-04-22. Recorded for traceability.
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.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).SENTRY_DSN_CONSOLE in Heroku config + GitHub secrets. Billing: confirmed acceptable under the current tier.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.