Auth & onboarding¶
Status: Decided 2026-09-26; login and account-standing rules decided 2026-09-27. X follow import is deferred past MVP. The backend shape (modules, events, errors, rollout) follows backend-platform.md, which wins where the two differ.
Decision¶
- Login is Apple or Google only, through Privy. No email or SMS OTP login in the product. Dev builds also allow SMS OTP login, so agents and simulators can sign in without an Apple or Google account; production never does (decided 2026-09-27). Dev uses its own Privy app with SMS login on; the production Privy app has SMS login disabled, so production cannot accept an SMS login even if backend config is wrong (default 2026-09-27).
- After first login, onboarding asks for three things on separate screens:
- Username (handle), unique across Monaco and required (decided 2026-09-27). It lives on
users.handleandidentityowns it. See Handle. - Phone number, verified by SMS code and linked to the same Privy user.
- Twitter / X account, linked to the same Privy user through OAuth.
- Phone and X are used to build the user's social graph: match their phone contacts and X follows against Monaco users, recommend people to follow, and seed an internal graph.
- The
usersrow carries anauth_statecolumn for onboarding progress (CREATED,AWAITING_PHONE,AWAITING_SOCIALS,ONBOARDING_COMPLETED) and a separateaccount_statuscolumn for standing (active,suspended,banned,deleted) (default 2026-09-27). The app reads both to decide what to show. Notifications and in-app banners readauth_stateto nudge users to finish giving phone and X. Analytics read its history to see how users engage socially. - Suspended and banned users can still withdraw and cash out (decided 2026-09-27). The money is theirs.
- No migration for existing users (decided 2026-09-27). All user data is wiped at cutover and the new backend starts on an empty database. A returning user signs in with Apple or Google and gets a fresh
usersrow. Their Privy wallet lives on-chain and in Privy, so sign-in reuses it and never recreates it.
Why¶
- Apple and Google are one-tap on iOS, need no code entry, and give a verified email. Apple login is also an App Store requirement once any third-party social login is offered (guideline 4.8).
- Phone and X are for the graph, not for login. Separating them from login keeps sign-in to one tap and lets users skip the data asks, with the app nudging them later.
- Stored state columns give the app, notifications and analytics a single, indexable answer to "where is this user", instead of each re-deriving it from linked accounts. Two columns, not one, because onboarding progress and account standing change independently: a banned user keeps their onboarding state, and "banned users who never gave a phone" stays answerable.
Login¶
Privy Swift SDK:
try await privy.oAuth.login(with: .apple, appUrlScheme: "monaco") // native Sign in with Apple
try await privy.oAuth.login(with: .google, appUrlScheme: "monaco")
Then, unchanged from today, the app calls POST /v1/auth/session with the Privy access token. The backend verifies the token, finds or creates the users row by privy_user_id, reuses or creates the one member wallet (never recreates), and returns the user including auth_state.
In the rewrite this is flow 1 in flows.tsv, owned by the identity module. A first sign-in appends user.created; every state change appends user.auth_state_changed. Both go through uow.Do in the same transaction as the row (Patterns). The analytics, referrals and social consumers react. Flow 1 in the RFC reads Apple / Google, with SMS OTP in dev builds only.
Privy dashboard: enable Apple and Google, disable email as a login method. SMS stays enabled for linking in every environment, and for login in dev only. Bundle com.monaco.app stays on the Privy iOS client.
Onboarding¶
Apple / Google login, then handle picked (required)
│
▼
┌─────────┐ phone verified ┌────────────────────┐ X linked ┌───────────────────────┐
│ CREATED │ ──────────────────► │ AWAITING_SOCIALS │ ────────────► │ ONBOARDING_COMPLETED │
└─────────┘ └────────────────────┘ └───────────────────────┘
│ skip phone ▲ ▲
▼ │ phone verified later │
┌────────────────┐ ──────────────────────┘ │
│ AWAITING_PHONE │ ────────── X linked, phone still missing: stays AWAITING_PHONE
└────────────────┘
Screen 1: handle. User types a handle. The app checks it live with GET /v1/handles/{handle}/availability, then calls PUT /v1/me/handle (SetHandle). This screen has no Skip. Until users.handle is set, the app shows only this screen, and the phone and socials commands refuse with HandleRequired. Rules are in Handle.
Screen 2: phone. User enters a number. App calls privy.sms.sendCode(to:), then privy.sms.linkWithCode(_:). Linking through Privy means the number is verified before Monaco trusts it; this matters because contact matching on an unverified number would let anyone claim someone else's number. App then calls POST /v1/me/onboarding/phone; the backend reads the verified phone from the Privy user (server-side), never from the request body, stores it, and advances state.
Screen 3: X. App calls privy.oAuth.link(with: .twitter, appUrlScheme:). Then POST /v1/me/onboarding/socials; backend reads the linked X account (username and numeric X user id) from Privy server-side and stores it.
Skip. The phone and X screens have Skip. The handle screen does not. Skipping phone sets AWAITING_PHONE; skipping X (with phone done) sets AWAITING_SOCIALS. The user lands in the app either way.
Already linked elsewhere. Privy allows one account per type per user. If the phone or X account is linked to a different Privy user, linking fails; the screen says so and offers Skip.
All three onboarding routes are identity commands. Like every mutating call, they take an Idempotency-Key header (Thin client). A refusal returns a stable code from the errs table as application/problem+json (Errors), and the app shows its message in a toast.
Handle¶
Every user has exactly one active handle (decided 2026-09-27). It shows as @handle on the profile, and after the first-deposit unlock it also works as the user's referral code in monacolabs.xyz/r/<handle> (referrals.md). The display name stays a separate, non-unique label (product.md).
- 3 to 20 characters: letters, digits, underscore. Stored lowercase in
users.handle; matching is case-insensitive, soKaiCenatandkaicenatare the same handle. - Unique across every
usersrow, deleted ones included. The unique index onusers.handlehas nodeleted_atfilter, so a deleted user's handle is never reused. That one index settles every race: a claim and a rename each write the same column, so two writers of one handle collide on it and one getstaken. - Blocked: a reserved list (
admin,monaco,monacolabs,support,help,api,app,r,official,team, …), a profanity list, and anything that looks like a random referral code (exactly 8 characters from the random-code alphabet). That last rule keeps handles and random codes in disjoint namespaces, so/r/<code>never matches both. - Impersonation. A handle equal to another user's linked X handle is refused. Your own X handle is always allowed once X is linked, even if it matches a reserved or popular string.
- Change limit: once per 30 days. Changing frees the old handle immediately. Links that used it stop resolving to this user, and anyone can claim it.
- Admin. Moderators can revoke a handle (the user must pick a new one on next launch) or reassign it to a verified owner, with a reason, audited through
admin.action(analytics-admin.md).
GET /v1/handles/{handle}/availability returns available, or why not (taken, reserved, invalid, too_soon). It is rate-limited. Each reason is an errs code, so the availability body and the SetHandle refusal share one closed list (Errors). referrals and social read handles through identity's query port.
auth_state¶
| State | Meaning | App shows |
|---|---|---|
CREATED |
Logged in, onboarding not yet finished or not yet reached. handle may still be null. |
Onboarding flow, from the handle screen if handle is null, else from the phone screen. |
AWAITING_PHONE |
Finished onboarding but skipped phone. | App, plus "Add your number to find friends" banner. |
AWAITING_SOCIALS |
Phone verified, skipped X. | App, plus "Connect X to find people you follow" banner. |
ONBOARDING_COMPLETED |
Phone and X both linked. | App, no nudges. |
account_status¶
| Status | Meaning | App shows | Allowed |
|---|---|---|---|
active |
Normal account. | App. | Everything. |
suspended |
Temporarily blocked (e.g. under review). | Read-only app with a notice. | Reads, withdraw, cash out. |
banned |
Permanently blocked. | A notice with only the withdraw and cash-out screens. | Withdraw, cash out. |
deleted |
User requested deletion. PII scrubbed; row kept for ledger integrity. | Nothing; login creates no new row for the same Privy id without ops action. | Nothing. |
Rules:
- Only the backend writes
auth_stateandaccount_status, from verified Privy data or ops actions. The client never sends either. - Each column is a state machine in
identity/domain: a Go type with atransitionstable and a pureNext(from, event)(Patterns). The adapter applies it as a guarded update (WHERE auth_state = $expected, oraccount_status) insideuow.Do. Anauth_statechange appendsuser.auth_state_changedwith from, to, and cause (onboarding,link,unlink). That history is what analytics reads. Anaccount_statuschange is an admin command and appends its own event plusadmin.action(analytics-admin.md). suspendedandbannedare enforced in the auth middleware on every request, not only in the app. They block funding, voting, proposing and commenting with aKindForbiddencode (403). Withdraw (flow 15) and cash out (flow 14) stay open to them (decided 2026-09-27).- If a user unlinks phone or X in Privy, the next session call moves them back to the matching
AWAITING_*state.
Deletion¶
"Delete my account" requires a zero position first (default 2026-09-27). The app sends the user through cash out for every cabal and withdraw for the platform balance. Deletion is refused with a KindBlocked code until nothing is left. Then identity sets deleted_at and account_status = deleted, and scrubs PII (email, phone columns, X columns, display name, photo). The users row keeps its handle, so no one else can claim it. This is a soft delete (decided 2026-09-27): reads filter deleted_at IS NULL. Ledger rows and the users row stay, so every cabal_txns and user_txns entry still points at a real id. Ledger rows are never deleted.
Nudges¶
A daily poller in the identity module selects users in AWAITING_PHONE / AWAITING_SOCIALS and appends user.nudge_due events that the notify consumer turns into pushes ("3 of your contacts are on Monaco"). Like every poller it holds a Postgres advisory lock per tick, so it runs on one worker (Deploy rule 1). Banners are rendered by the app from auth_state directly. Copy and frequency caps belong to notifications.md. There is no incentive for completing: referrals carry no reward for now (decided 2026-09-27, referrals.md).
Social graph¶
The social module owns contact matching, contact_matches and the /v1/me/contacts/* routes (Repository layout). It reads verified phone hashes and X user ids through the read-only query port identity exports in module.go, never through identity's packages (Dependency rules).
Phone contacts¶
- With the user's permission (iOS Contacts prompt, with Monaco's own explainer screen first), the app reads phone numbers from the address book.
- The app normalises each to E.164 and hashes it with SHA-256 before upload. Raw numbers and names never leave the device.
POST /v1/me/contacts/matchwith the hash list. The backend compares against the hashes of verified phone numbers of Monaco users (users.phone_hash) and stores matches incontact_matches (user_id, matched_user_id, source = 'phone').- The app shows "Friends on Monaco" with Follow buttons.
- Unmatched hashes are not stored (default 2026-09-27). When a new user verifies their phone later, existing users who had them in contacts are not told. Less growth, less stored PII.
Phone number hashes are brute-forceable (the number space is small), so hashing is a courtesy, not a privacy guarantee. Treat phone_hash as PII. The slog redaction list already covers phone; phone_hash and x_user_id join it in the PR that adds them (Logs as evidence rule 7).
X follows¶
Deferred past MVP (default 2026-09-27). Phone-contact matching ships first; X follows follow once the paid X API tier is worth it. X linking stays in onboarding, so x_user_id is ready when matching ships. The design for then:
The Privy Swift SDK does not return the user's X OAuth tokens to the app ("not yet supported in the Swift SDK"), so the app cannot call the X API as the user. Instead:
- Backend reads the linked X user id from Privy.
- Backend calls the X API through a
socialadapter (anti-corruption layer, circuit breaker and retry, per Patterns) with Monaco's own app credentials,GET /2/users/{id}/following, and matches returned ids againstusers.x_user_id. - Matches go into
contact_matcheswithsource = 'x'. - Re-run weekly and on user request, rate-limited.
The following endpoint is on a paid X API tier with low monthly caps, which is why it waits.
Internal graph¶
contact_matchesis the "people you may know" seed. Accepting a recommendation creates a row infollows(followers.md) withsourceset tophoneorx.- Further recommendations come from inside Monaco: members of the same cabals, people who comment on the same feed items, followers of followers.
- Engagement analytics read
eventsfor follows, contact-match accepts, invites sent, andauth_statetransitions, joined to the source of each follow (phone,x,cabal,feed,suggested).
Data¶
users belongs to identity; contact_matches belongs to social. users gains:
| Column | Notes |
|---|---|
auth_state |
Enum above, default CREATED, indexed. |
account_status |
Enum above, default active, indexed. |
auth_state_changed_at |
For "stuck in onboarding for 7 days" queries. |
login_provider |
apple or google; sms only in dev. |
email |
From the OAuth provider. Apple may give a private relay address. |
phone_e164, phone_hash, phone_verified_at |
Verified via Privy only. phone_hash unique. |
x_user_id, x_username, x_linked_at |
From Privy's linked X account. x_user_id unique. |
handle |
Unique username picked in onboarding (Handle). Lowercase, unique index WHERE handle IS NOT NULL with no deleted_at filter, so deleted users keep their handle. Null only before onboarding picks it or after an admin revoke. Decided 2026-09-27. |
first_deposit_at |
Set by identity's consumer of deposit.credited (flow 5) on the first deposit of at least $10, with a guarded update WHERE first_deposit_at IS NULL. referrals reads it through the identity query port to unlock the handle as a referral code (referrals.md). Decided 2026-09-27. |
deleted_at |
Soft delete (decided 2026-09-27). Set with account_status = deleted. Reads filter deleted_at IS NULL. |
New tables: contact_matches (user_id, matched_user_id, source, created_at, dismissed_at), owned by social. Followers is its own decision.
Alternatives considered¶
| Alternative | Why not |
|---|---|
| Keep email / SMS OTP login | More steps than one-tap Apple/Google, and SMS login costs money per send. |
| Phone number collected in a text field without verification | Lets a user claim someone else's number and harvest their contacts' graph. |
| Upload raw contacts | Privacy and App Store risk. Hash on device; upload numbers only. |
| X follow graph via the user's OAuth token | Not available in the Privy Swift SDK. Would require running a separate X OAuth flow outside Privy. Kept as a fallback if the app-credential route is too expensive. |
| Derive onboarding state from linked accounts on every read | Every screen, notification query and analytics query repeats the logic. One stored column, written in one place, is simpler. |
Gap between this and the code¶
Today: email OTP and SMS OTP login through Privy; users has id, privy_user_id, display_name, profile_photo_url, created_at. No state column, no phone, no X, no contacts.
There is no migration path for existing users (decided 2026-09-27). The new backend starts on an empty database, so there is no backfill of auth_state and no dual login period.
The rewrite builds this in Rollout step 3 (identity, flow 1) and step 6 (social contact matching, notify nudges).
Open questions¶
None.
Log¶
- 2026-09-27: Default 2026-09-27: dropped
retired_handles. The unique index onusers.handlecovers deleted rows, so a deleted user's handle is never reused. A rename frees the old handle immediately. The advisory lock is dropped too; the single unique index settles races. - 2026-09-27: Decided 2026-09-27: every user picks a unique handle as the first onboarding screen, with no Skip.
users.handlebelongs toidentity, with the naming rules that used to govern custom referral codes. Changing a handle retires the old one intoretired_handles.referralsreads handles throughidentity's query port, and a handle works as a referral code after the first-deposit unlock. - 2026-09-27: Decided:
userssoft deletes throughdeleted_at, set withaccount_status = deletedon account deletion. Addedfirst_deposit_at, set byidentityfrom the firstdeposit.creditedand read byreferrals. - 2026-09-27: Closed the last open question (default 2026-09-27): dev-only SMS login is enforced by a separate Privy app for dev; the production Privy app has SMS login disabled.
- 2026-09-27: Applied decisions. Decided: Apple / Google only with SMS OTP in dev builds (RFC flow 1 fixed to match); no migration, user data wiped at cutover, Privy wallets reused on sign-in; suspended and banned users can withdraw and cash out. Defaults:
account_statussplit fromauth_state; phone contacts first, X follows deferred; unmatched hashes not kept; deletion requires cash out first, then scrubs PII and keeps ledger rows. Open question left: how dev-only SMS login is enforced. - 2026-09-27: Reconciled with backend-platform.md:
identityowns sessions, users andauth_state(a state machine applied inuow.Do);socialowns contact matching throughidentity's query port;user.createdadded;notifyconsumesuser.nudge_due; ban refusals are aKindForbiddencode; idempotency keys on onboarding routes; PII redaction; rollout steps 3 and 6. Flagged the login-method conflict with RFC flow 1. - 2026-09-26: Outbox rows replaced by
eventsrows delivered over the NATS event bus (event-bus.md). - 2026-09-26: Initial decision. Apple/Google-only login via Privy; phone and X linked during onboarding;
auth_stateon users; contact and X graph matching. Noted Privy Swift cannot return X OAuth tokens.