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:
- A new
/beta/join/<token>frontend route — the door from invite link to real account. GET /api/beta/join/<token>/stateandPOST /api/beta/join/<token>/claimRaptor endpoints.- A
POST /internal/api/beta-join/consumeConsole internal endpoint. - Schema deltas on Console (
join_consumed_at) and Raptor (users.beta_cohort). - CF bypass additions for all anonymous dependency paths.
- Detection entries for token sharing and enumeration.
- 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-token → POST /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) |