Raxx · internal docs

internal · gated

Beta Phase 2 — Invite-Token-Gated Real-Account Signup

Status: Accepted Date: 2026-06-12 UTC Author: software-architect agent ADR: ADR-0115 Refs: Parent epic filed as part of this design run.


1. Context

Phase 1 delivered a screenshot-tour NDA gate (/beta/preview/<token>/screen/1-5) for a closed cohort of NDA'd testers. Phase 2 puts those same testers into the real app: full signup, passkey enrollment, onboarding wizard, IC builder, paper trading, and dashboard.

The entire raxx.app domain sits behind a Cloudflare Access pre-launch gate. The existing bypass set covers Phase 1 paths. Phase 2 requires extending that bypass set and adding a new token-gated join route that creates a real account without lifting the CF gate for anyone else.

The token type, verification path, and NDA gate already exist: Console mints cohort='beta' HMAC tokens; Raptor verifies via POST /internal/api/verify-walkthrough-token (see backend_v2/api/services/beta_token_verifier.py). This design adds:

  1. A new /beta/join/<token> frontend route — the door from invite link to real account.
  2. GET /api/beta/join/<token>/state and POST /api/beta/join/<token>/claim Raptor endpoints.
  3. A POST /internal/api/beta-join/consume Console internal endpoint.
  4. Schema deltas on Console (join_consumed_at) and Raptor (users.beta_cohort).
  5. CF bypass additions for all anonymous dependency paths.
  6. Detection entries for token sharing and enumeration.
  7. Abuse model for token sharing, enumeration, and geo-block bypass attempts.

2. Invariants

These constraints are non-negotiable. Any implementation that violates one must stop and escalate.

# Invariant Source
I-1 No stored credentials — token verification never stores or replays a secret ADR-0002
I-2 Passkeys / WebAuthn only — no password flow ADR-0001
I-3 Email is the sole contact channel; verified before use GDPR default
I-4 GDPR by default — data subject rights apply to beta tester accounts ADR-0003
I-5 NDA ack must precede account creation product requirement
I-6 Geo-blocks (Quebec + EU/EEA) apply even on invite-token paths project_quebec_geoblock_decision, project_eu_geoblock_decision
I-7 Paper-first gating for live trading — Phase 2 is paper-only product invariant
I-8 Audit trail for every state change touching account creation or permissions ADR-0022
I-9 Secrets in env/secret stores only — never in code feedback_no_inline_secrets_in_repo
I-10 Queue service owns customers/sessions/RBAC — signup must follow existing path project_queue_identity_service
I-11 Join token is single-use — consumed at first successful claim, before ceremony this design §5.3
I-12 Tester accounts carry a cohort marker for scoping and cleanup this design §3.2

3. Data Model Deltas

3.1 Console DB — beta_walkthrough_tokens (add join_consumed_at)

The existing beta_walkthrough_tokens table (Console migration 0172) tracks issuance and revocation. Phase 2 needs a join consumption marker separate from walkthrough-progress usage, so a tester can still open their activation walkthrough after account creation.

-- POSTGRES-ONLY
ALTER TABLE beta_walkthrough_tokens
  ADD COLUMN join_consumed_at TIMESTAMPTZ NULL;

CREATE INDEX ix_bwt_join_consumed_at
  ON beta_walkthrough_tokens (join_consumed_at)
  WHERE join_consumed_at IS NOT NULL;

NULL = not yet consumed as a join token. Non-null = timestamp of first successful /claim call. Console migration: 0186 (see §8.1).

3.2 Raptor DB — users (add beta_cohort)

After WebAuthn registration completes for a beta-join user, Raptor writes a beta_cohort marker. This enables feature scoping, separate anomaly baselines, and post-beta cleanup.

-- POSTGRES-ONLY
ALTER TABLE users
  ADD COLUMN beta_cohort TEXT NULL;
  -- NULL = GA user (default, no change to existing rows)
  -- 'beta' = Phase 2 beta tester

CREATE INDEX ix_users_beta_cohort
  ON users (beta_cohort)
  WHERE beta_cohort IS NOT NULL;

The marker is set by the /api/auth/register/verify-with-token handler after successful attestation, keyed on a beta_cohort claim in the bootstrap token payload (see §4.3). Raptor migration: 0034 (see §8.2).

3.3 No new Queue DB changes

Queue owns the customer record. The beta_cohort marker lives in Raptor's users table (auth layer). If Queue needs cohort visibility post-Phase-2, that is a separate migration.


4. APIs / Contracts

4.1 Frontend route: GET /beta/join/<token> (Antlers)

Fetches GET /api/beta/join/<token>/state on load. Branching behavior:

State condition Render
valid: false Static "This invite has expired." — link to https://getraxx.com
valid: true, nda_acked: false Redirect to /beta/walk/<token>?next=/beta/join/<token>
valid: true, nda_acked: true, consumed: false "Create your account" — email prefilled read-only, passkey CTA
valid: true, consumed: true "Account already created." — link to /login

The redirect to /beta/walk reuses the ?next= return-path mechanism already in place (#3539).

4.2 Raptor: GET /api/beta/join/<token>/state

Exempt from session auth (add "/api/beta/join/" to _EXEMPT_PREFIXES).

Always returns HTTP 200 (anti-enumeration). Body:

// valid token
{"valid": true, "email": "<tester email>", "nda_acked": true, "consumed": false}

// invalid/expired/revoked
{"valid": false}

email is only present when valid == true. The raw token is never echoed in any response.

Implementation steps: 1. Call verify_walkthrough_token(token)tester_info or None. 2. If None or tester_info['cohort'] != 'beta': return {"valid": false}. 3. Check NDA ack: SELECT 1 FROM beta_nda_acknowledgements WHERE tester_email = :email LIMIT 1. 4. Check join consumption: call Console POST /internal/api/beta-join/consume with check_only: true and the SHA-256(token) hash. 5. Return combined state object.

4.3 Raptor: POST /api/beta/join/<token>/claim

Exempt from session auth (covered by "/api/beta/join/" prefix).

Request body:

{"country": "US", "province": ""}

Processing: 1. Re-verify token (avoids TOCTOU vs state check). 2. Enforce NDA ack — if not acked: 403 {"error": "nda_required"}. 3. Enforce geo-block — apply FLAG_SIGNUP_GEOBLOCK_EU + FLAG_QUEBEC_GEOBLOCK checks identical to /api/auth/register/options (see open question OQ-2). 4. Call Console POST /internal/api/beta-join/consume {token_hash, check_only: false}: - 409: return 409 {"error": "already_claimed"}. - Console unreachable: return 503 {"error": "service_unavailable"} — do NOT proceed. Consumption must be confirmed before any bootstrap token is issued; prevents double-claim on retry. 5. Mint a beta-scoped bootstrap token: same BOOTSTRAP_TOKEN_SIGNING_KEY structure as the existing flow, plus "beta_cohort": "beta" in the payload JSON. 6. Return 200 {"bootstrap_token": "...", "email": "<tester email>"}. 7. Write audit event: beta.join.claimed with tester_email_hash, jti, ip_prefix.

The returned bootstrap_token feeds directly into the existing POST /api/auth/register/begin-with-tokenPOST /api/auth/register/verify-with-token flow. The verify-with-token handler must detect the beta_cohort claim and write users.beta_cohort = 'beta' after successful attestation.

4.4 Console internal: POST /internal/api/beta-join/consume

HMAC-authenticated via X-Internal-Auth + X-Internal-Timestamp (same scheme as verify-walkthrough-token). Raptor is the only caller.

Request: {"token_hash": "<SHA-256 hex>", "check_only": false}

check_only Condition Response
true Not consumed 200 {"ok": true, "consumed_at": null}
true Already consumed 200 {"ok": true, "consumed_at": "<ISO8601>"}
false First call 200 {"ok": true} (sets join_consumed_at = now())
false Already consumed 409 {"error": "already_consumed"}
any Token hash not found 404 {"error": "not_found"}

Write path: UPDATE beta_walkthrough_tokens SET join_consumed_at = now() WHERE token_hash = :h AND join_consumed_at IS NULL. Rowcount == 0 → 409.


5. State Machines / Sequences

5.1 Full join flow

sequenceDiagram
    participant B as Browser (anonymous)
    participant A as Antlers /beta/join/<token>
    participant R as Raptor /api/beta/join/<token>/*
    participant C as Console /internal/api/*
    participant W as Antlers /beta/walk/<token>

    B->>A: GET /beta/join/<token>
    A->>R: GET /api/beta/join/<token>/state
    R->>C: POST verify-walkthrough-token {token}
    C-->>R: 200 {tester_email, cohort='beta', jti}
    R->>R: SELECT beta_nda_acknowledgements WHERE tester_email — none
    R->>C: POST beta-join/consume {token_hash, check_only:true}
    C-->>R: 200 {consumed_at: null}
    R-->>A: {valid:true, email:..., nda_acked:false, consumed:false}
    A-->>B: 302 /beta/walk/<token>?next=/beta/join/<token>

    Note over B,W: Tester completes NDA ack on /beta/walk/<token>

    B->>A: GET /beta/join/<token>  (return via ?next=)
    A->>R: GET /api/beta/join/<token>/state
    R->>R: nda_acked=true (row exists)
    R-->>A: {valid:true, email:..., nda_acked:true, consumed:false}
    A-->>B: Render "Create your account"

    B->>A: POST /api/beta/join/<token>/claim {country, province}
    A->>R: POST /api/beta/join/<token>/claim
    R->>R: verify_walkthrough_token (re-verify)
    R->>R: geo-block check passes
    R->>C: POST beta-join/consume {token_hash, check_only:false}
    C-->>R: 200 {ok:true}
    R-->>A: 200 {bootstrap_token:"...", email:"..."}

    A->>R: POST /api/auth/register/begin-with-token {bootstrap_token, email}
    R-->>A: 200 {challenge, ...}
    Note over B,A: Platform authenticator gesture
    A->>R: POST /api/auth/register/verify-with-token {...}
    R->>R: verify attestation; INSERT users (beta_cohort='beta')
    R-->>A: 200 + Set-Cookie: raxx_session=...
    A-->>B: 302 /onboarding

5.2 Token state machine (join semantics)

stateDiagram-v2
    [*] --> Valid : Console mints token (cohort=beta)
    Valid --> Expired : TTL elapses
    Valid --> Revoked : operator revokes
    Valid --> NdaAcked : tester completes NDA on /beta/walk
    NdaAcked --> Consumed : POST /claim succeeds (Console write)
    NdaAcked --> Expired : TTL elapses
    NdaAcked --> Revoked : operator revokes
    Consumed --> AccountCreated : WebAuthn ceremony succeeds
    Consumed --> ConsumedNoAccount : Ceremony fails (support case)
    AccountCreated --> [*] : walkthrough still accessible via token
    Expired --> [*]
    Revoked --> [*]

5.3 One-time-use semantics

The join token is consumed at POST /claim time (before the WebAuthn ceremony). If the ceremony subsequently fails, that is a manual support case — the user emails support@raxx.app. Re-click after consumption renders a static page: "Your account invitation has already been used. If you need help, email support@raxx.app." No further API calls on that path.


6. CF Bypass Paths Required

Lesson from docs/incidents/2026-06-12-beta-invite-cf-access-blocks.md: every path an anonymous browser touches needs a separate CF Access bypass app (decision=bypass, include=everyone).

Domain Path Status Notes
raxx.app /beta/join/* NEW Page route
raxx.app /api/beta/join/* NEW Raptor API proxied via raxx.app
api.raxx.app /api/beta/join/* NEW Raptor API direct (SSR fetch target)
raxx.app /_next/* Exists (3ac0d097) Verify covers new JS chunks
raxx.app /api/auth/register/* Verify existing begin-with-token + verify-with-token
api.raxx.app /api/auth/register/* Verify existing SSR fetch from Antlers

Pre-ship gate: anonymous curl probe must confirm all six paths return non-302 before any external tester is invited (failure = SEV-1 per incident action item 1).


7. _EXEMPT_PREFIXES Addition (Failure Class #3305)

Add to backend_v2/api/middleware/session_auth.py _EXEMPT_PREFIXES in the same PR as route registration:

"/api/beta/join/",    # Phase 2 beta join: state check + claim (pre-auth, token-gated)

The /api/auth/register/ prefix is already exempt. No change needed there.


8. Migrations

8.1 Console migration 0186 — beta_walkthrough_tokens.join_consumed_at

Real-PG smoke on staging required before prod deploy (per feedback_postgres_enum_migrations_need_real_pg_test). Mark migration file with -- POSTGRES-ONLY sentinel (per feedback_postgres_only_migration_sentinel).

# -- POSTGRES-ONLY
op.add_column(
    "beta_walkthrough_tokens",
    sa.Column("join_consumed_at", sa.TIMESTAMP(timezone=True), nullable=True),
)
if bind.dialect.name == "postgresql":
    op.execute(sa.text(
        "CREATE INDEX ix_bwt_join_consumed_at"
        " ON beta_walkthrough_tokens (join_consumed_at)"
        " WHERE join_consumed_at IS NOT NULL"
    ))

Down: drop index + drop column.

8.2 Raptor migration 0034 — users.beta_cohort

# -- POSTGRES-ONLY
op.add_column(
    "users",
    sa.Column("beta_cohort", sa.Text(), nullable=True),
)
if bind.dialect.name == "postgresql":
    op.execute(sa.text(
        "CREATE INDEX ix_users_beta_cohort"
        " ON users (beta_cohort)"
        " WHERE beta_cohort IS NOT NULL"
    ))

Down: drop index + drop column. Column is nullable; default NULL preserves all existing rows.

8.3 Console migration 0187 — B1 promotion for FLAG_BETA_PHASE2_ACCESS

Per feedback_new_flag_needs_b1_migration_same_pr: every new feature_flags.yaml entry requires a console_flag_promotions row in the same PR.


9. Feature Flag: beta_phase2_access

beta_phase2_access:
  default: false
  category: release
  surface: multi           # raptor (API) + antlers (page route)
  soak_period_hours: 48
  risk: high               # customer-facing; real passkey enrollment; account creation
  description: >
    default OFF; Phase 2 invite-token-gated real-account signup via /beta/join/<token>.
    When ON: /beta/join/* page route + /api/beta/join/* API routes active.
    When OFF: all join routes return 404.

10. Rollout Plan

Stage Config Prerequisite
Dark FLAG_BETA_PHASE2_ACCESS=0 (default)
Internal smoke =1 on staging Migrations 0186 + 0034 applied on staging; synthetic-credential smoke passes
Pre-ship gate Anonymous curl probe: all 6 CF paths return non-302 on prod
Beta =1 on prod All above; operator confirms invites ready
GA Remove flag; open signup Post-beta launch (out of scope)

Synthetic-credential smoke must pass before any operator-on-phone testing (per feedback_smoke_before_mobile_retest).


11. Security Considerations

Token sharing: Token is email-bound. Sharing the link creates an account for the original tester's email, which the sharer cannot use (passkey is device-local). After consumption, re-sharing is harmless (409 on re-claim). Detection: §12.

Token enumeration: HMAC token has sufficient entropy. State endpoint returns identical {"valid": false} for all invalid/expired/revoked tokens. Rate limit: 10 req/min per IP.

Double-claim race: Console's conditional UPDATE (WHERE join_consumed_at IS NULL) is the serialization point. Second concurrent caller receives 409.

Post-action redirect: Antlers redirects to /onboarding (frontend route) after enrollment. Raptor never issues a redirect — JSON only. Prevents the #3539 class of bug.

GDPR: - PII collected: email (from tester record), IP at claim time, user-agent. - Retention: same 90-day inactive-user policy as GA; beta_cohort='beta' enables batch cleanup. - Deletion on DSR: standard users row deletion; no separate delete step for beta_cohort. - Audit: beta.join.claimed event — tester_email_hash, jti, ip_prefix (ADR-0022 retention). - No stored credentials: HMAC verification only; WebAuthn stores public key only. - Breach path: follows existing account breach path; no special handling. - Secrets: INTERNAL_API_SECRET in Infisical; rotatable without redeploy. - Kill-switch: FLAG_BETA_PHASE2_ACCESS=0 disables all join routes on next dyno restart.


12. Abuse Detection Catalog

File under docs/detections/beta/:

File Trigger
join_token_sharing.md Account created; session context email (if any) differs from tester_email
join_token_enumeration.md >10 invalid-token /state requests from one IP in 5 minutes
join_geoblock_bypass.md Repeated /claim attempts from geo-blocked country/province

13. Open Questions

# Question Blocks
OQ-1 Should FLAG_BETA_PHASE2_ACCESS gate the Antlers page route AND Raptor API, or only Raptor? Gating Antlers too gives 404 at the page level. SC-1 flag definition
OQ-2 Geo-block exception for invited testers: if an NDA'd tester is in the EU, do they get a bypass? Current design: no exception. SC-2 claim endpoint
OQ-3 If a tester's token is revoked after they created an account, should the account be deactivated? Current design: revocation stops new claims; existing accounts remain active. SC-3 Console internal endpoint
OQ-4 Should a "re-send invite" Console admin action exist for testers whose token was shared+consumed before they used it? SC-4 (scope optional)