Summary
Scout runs its own self-hosted authentication engine (backend/src/auth/) — ES256-signed
JWTs, a JWKS endpoint, Argon2id password hashing, and a Postgres-backed session
store — which replaced Supabase Auth in PR #95 (5ec5d340, "replace Supabase with
self-hosted auth engine + self-managed Postgres"). Every session, including a
guest's, is a real profiles row: there is no local-only or anonymous-device-only
mode. Four session-creation paths exist server-side (email/password, Google OAuth,
Apple OAuth, emailed 6-digit code, and device-scoped guest), but only three are
reachable from the shipped production UI — Apple, Google, and the email code. The
fourth, password sign-up/sign-in, is wired end-to-end but its form only renders in
dev builds or under Maestro (showDevForm = __DEV__ || getIsMaestro(),
mobile/app/auth/index.tsx:248); real users never see it. Guest ("Continue as
guest") is fully built and is a real backend identity, but as of a 2026-08-27
change it is hidden behind a feature flag (guest_mode) whose code-defined
default is off — see Status below, this is the single most consequential fact
for marketing copy.
Account and Profile add a self-serve DELETE /api/account that deletes most
per-user data but explicitly does not touch a user's community posts,
comments, votes, or patch photos (see "What this feature does NOT do"). A public,
opt-out web profile at /u/<slug> renders a shareable "credential" page with
collection stats and patch art, generated on the fly, that 404s for guests and
anyone who has turned it off (it's on by default).
Status (shipped / beta-badged / flagged off — name the flag and its default)
- Self-hosted auth engine: shipped, fully live in production (replaced Supabase in PR #95).
- Google sign-in, Apple sign-in, email 6-digit code: shipped, always visible on
/auth— not flag-gated. - Guest mode ("Continue as guest"): built and functionally complete
server-side (
AuthEngineService.anonymous,auth-engine.service.ts:196), but the entry point on the mobile auth screen is gated behind theguest_modefeature flag. The code-defined default isdefaultEnabled: falsein both the backend registry (backend/src/admin/feature-flag-definitions.ts:15-21) and the mobile mirror (mobile/src/config/feature-flags.ts:15-20) — "Off hides the row entirely so Apple, Google, and email are the only new-session paths." This flag and its default were introduced 2026-08-27 (commit14e33fe7, "flag guest mode entry point"), one day after guest mode's account-creation clarity fix. This document has no database access and cannot confirm whether an admin has since flipped the DB-stored value on in production — see Open Questions. Do not describe guest mode as a currently-visible option without verifying the live flag value first. - Password email/password sign-up/sign-in: fully implemented backend
endpoints (
POST /api/auth/signup,POST /api/auth/signin), but the mobile form that calls them only renders when__DEV__ || getIsMaestro()is true (mobile/app/auth/index.tsx:248, testIDsdev-email-input/dev-password-input/dev-sign-in-button). It exists for local development and Maestro E2E test provisioning (mobile/scripts/provision-account.js,mobile/maestro/lib/dev-sign-in.yaml), not as a real user-facing path. - "Signing in also creates an account" clarity: shipped 2026-08-26 (commit
1b6c505b, "fix(auth): make it obvious that signing in also creates an account" — board ticket 240a9344). Nothing was broken before this; every entry point was already find-or-create server-side, the screen just never said so (worse on iOS, where Apple/Google were icon-only and "Sign in with email" was the only visible verb). - Public web profile
/u/<slug>: shipped, default on (Profile.publicEnabled Boolean @default(true),backend/prisma/schema.prisma:630), with an opt-out toggle in Settings. - Account deletion: shipped (
DELETE /api/account). - Onboarding permission screens (location, then notifications): shipped.
User-facing surfaces (screens, routes, scout:// deep links, entry points)
Mobile (each row registered in mobile/src/dev/deepLinkRoutes.ts):
| Screen | File | Deep link |
|---|---|---|
| Sign-in landing | mobile/app/auth/index.tsx |
scout://auth |
| Email code sign-in | mobile/app/auth/email.tsx |
scout://auth/email |
| Onboarding — location | mobile/app/onboarding-location.tsx |
scout://onboarding-location |
| Onboarding — notifications | mobile/app/onboarding-notifications.tsx |
scout://onboarding-notifications |
| Onboarding — referral (out of scope) | mobile/app/onboarding-referral.tsx |
scout://onboarding-referral |
| Location permission (re-ask from Settings) | mobile/app/location-permission.tsx |
scout://location-permission |
| Profile | mobile/app/(drawer)/profile.tsx |
scout://profile |
| Settings | mobile/app/(drawer)/settings.tsx |
scout://settings |
| Blocked accounts (entry point only — the screen itself belongs to community and moderation) | mobile/app/(drawer)/blocked-accounts.tsx |
scout://blocked-accounts |
Web (no auth guard — intentionally open, public-profile.controller.ts:14-15):
GET /u/:slug— the shareable public profile page (public-profile.controller.ts:100).GET /u/:slug/card.png— the same profile rendered as a 1200×630 OG image (public-profile.controller.ts:91).GET /api/avatar/:seed.svg— a deterministic generated avatar, any seed string (public-profile.controller.ts:83).GET /get— UA-sniffing redirect to the App Store or Play Store (public-profile.controller.ts:29), the target of the profile page's own CTA.
How it works (the end-to-end mechanism: device → API → DB → response)
Token machinery
- Access token: a short-lived (
AUTH_ACCESS_TTL, default30m) ES256 JWT signed byAccessTokenService(backend/src/auth/token/access-token.service.ts), carryingsub(Profile id),email,role: 'authenticated',is_anonymous,display_name. Verified against a local JWKS built fromAuthKeyService(backend/src/auth/keys/auth-key.service.ts), which loads a current signing key pair (AUTH_JWT_KID_CURRENT/AUTH_JWT_PRIVATE_KEY_CURRENT) plus an optional previous one for rotation, and exposes them atGET /.well-known/jwks.json(jwks.controller.ts).JwtStrategy(backend/src/auth/jwt.strategy.ts) verifies the token, then does a fresh DB lookup of thesub— an unknownsub(e.g. a deleted or discarded guest profile) is rejected withUnauthorizedException('Unknown user')(jwt.strategy.ts:83-89) rather than being auto-provisioned. - Refresh token: opaque random bytes, only its SHA-256 hash stored
(
RefreshTokenService,backend/src/auth/token/refresh-token.service.ts). Default lifetime isAUTH_REFRESH_TTL_DAYS= 3650 days (10 years), and it slides forward on every use rather than expiring — an active session effectively never ages out. Refresh does not rotate: the same token keeps working on every use until it is explicitly revoked (sign-out) or genuinely unused past its TTL. This was a deliberate removal, documented in the code (refresh-token.service.ts:39-54): the old rotate-on-use scheme assumed a client reliably observes the successor token in the response, which a phone on weak signal doesn't — the request lands, the server rotates, the response never arrives, and the client re-presents the now-spent token on its next launch. The old reuse-detection logic couldn't tell that apart from theft and burned the whole session family, which produced 30 confirmed forced logouts in production before it was removed.
Sign-in methods
- Google / Apple OAuth (
POST /api/auth/oauth/:provider,AuthEngineService.oauthSignIn,auth-engine.service.ts:262-355). The client sends a provider ID token;OAuthProviderVerifier(backend/src/auth/oauth/oauth-provider.verifier.ts) verifies it against the provider's own remote JWKS (Googlehttps://www.googleapis.com/oauth2/v3/certs, Applehttps://appleid.apple.com/auth/keys) with a configured audience allowlist (GOOGLE_OAUTH_CLIENT_IDS/APPLE_OAUTH_CLIENT_IDS). The engine then branches four ways: (1) anAuthIdentityalready exists for that provider+account → sign into that Profile; (2) no identity, but the verified email matches an existing Profile → link the identity to it, only if the provider asserts the email is verified (an unverified-email match is rejected as an account-takeover vector,auth-engine.service.ts:305-313); (3) no identity, no email match, and the caller already holds an anonymous (guest) session → promote that guest Profile in place, same UUID; (4) otherwise → create a brand-new Profile + AuthIdentity. - Email one-time code (
POST /api/auth/magic-link/requestthenPOST /api/auth/magic-link/verify,mobile/app/auth/email.tsx). Despite the route name, this is a 6-digit code, not a clickable link. Requesting a code creates the Profile immediately, at request time, not at verify time (magicLinkRequest,auth-engine.service.ts:374-392) — so an account exists the moment someone types an email, whether or not they ever enter the code. The code is a random 6-digit number, its SHA-256 hash stored, 15-minute TTL, and issuing a new code invalidates any prior outstanding code for that user+purpose (one-time-token.service.ts:32-38) so only the newest is ever valid.sendMagicLinkCodeformats it as"123 456"specifically so iOS/macOS one-time-code autofill can offer it (auth-email.service.ts:27-29).AuthEngineServicecounts a verify as the actual "signup moment" only when the target Profile has never held a refresh token before (magic-link-verify,auth-engine.service.ts:420-431) — this is what drives an internal new-account alert without changing the client-visibleisNewAccountflag. - Guest (
POST /api/auth/anonymous,AuthEngineService.anonymous,auth-engine.service.ts:196-238). Tapping "Continue as guest" calls this endpoint with the device'sx-device-idheader (AndroidANDROID_ID, iOS IDFV, or a SecureStore UUID fallback — comment atauth-engine.service.ts:184) and finds-or-creates an anonymous Profile tied to that device. A partial unique index (profiles_device_id_anon_key, migration20260804120000) enforces at most one anonymous profile per device — a concurrent double-launch that races the create is handled by catching the resultingP2002and adopting the winner rather than failing (auth-engine.service.ts:222-236). A guest gets a generated readable handle likeanon-marten-4821as itsdisplayName(backend/src/auth/anon-handle.ts), purely so the admin Users table can trace one guest across sessions — not guaranteed unique. - Email/password (
POST /api/auth/signup,POST /api/auth/signin) — see Status; dev/test-only in practice. Password hashing is Argon2id (backend/src/auth/password.service.ts).
Guest → member: the merge rule
The rule, stated in the code, is: guest data only follows the user into a
member account on account CREATION, never on sign-in to a pre-existing
account (doc comment, auth-engine.service.ts:250-259). But "creation" is
implemented differently per method, and this is a real, non-obvious
asymmetry:
- OAuth (branch 3,
auth-engine.service.ts:312-323) and password signup (auth-engine.service.ts:44-71) both explicitly check whether the caller already holds an anonymous session and, if the identity/email is genuinely new, promote the guest Profile in place — same UUID. The guest's own row becomes the member's row, so every server-side table already keyed to that UUID (user_patches,user_purchases,trips, etc.) is already the new member's data with zero extra work. - The emailed 6-digit code path never does this.
magicLinkRequest(auth-engine.service.ts:374-392) finds-or-creates its Profile purely by email, with nocurrentUserIdparameter at all — it has no way to promote the caller's guest session even for a brand-new address.magicLinkVerifythen callsdiscardGuestIfAnonymous(currentUserId, targetId)(auth-engine.service.ts:356-364), and sincetargetIdis always a by-email profile distinct from the guest's own (null-email) id, this unconditionally discards the guest's data — even when the email address was brand new and a fresh account is being created for it. A guest who signs in via the email code always loses their guest-session data, full stop; only Google, Apple, and password sign-up preserve it, and only when creating a genuinely new account. - Signing into a different, pre-existing account by any method (OAuth
branches 1/2, or a magic-link email match) also discards the anonymous
caller's guest Profile the same way —
discardGuestIfAnonymousruns the same per-user delete helper used by account deletion (deleteUserData) and then deletes the guest Profile row. The guest's data is never merged into the account being signed into — the existing account's own data wins outright.
On top of the server-side promote-in-place mechanism, the mobile client
separately pushes anything that was only ever queued locally (not yet confirmed
server-side) forward on a successful account-creating sign-in:
afterIdentitySignIn in mobile/src/providers/AuthProvider.tsx:585-650 checks
the response's isNewAccount flag; if true and the caller was a guest, it calls
analytics.trackGuestConverted() and pushLocalGuestDataToServer(), which walks
the locally pending patch/purchase queues and pushes them under the new account
id, merging the result into the query cache and clearing only what the server
confirmed (a failed push is deliberately left in the queue for retry — clearing
it unconditionally was a prior data-loss bug, per the comment at
AuthProvider.tsx:296-303). Purchases specifically also go through
POST /purchases/migrate (backend/src/purchases/purchases.service.ts:87), which
additionally calls RevenueCat to transfer entitlements from the device's
anonymous RevenueCat id to the new account id.
One specific UI entry point defeats all of the above and always loses guest
data, regardless of which method the guest then picks. The Profile screen's
GuestBanner component (shown only to guests, mobile/app/(drawer)/profile.tsx:675)
offers a "Sign up with email" button whose handler is
await signOut(); router.replace('/auth');
(mobile/src/components/GuestBanner.tsx:14-17). AuthProvider.signOut
(AuthProvider.tsx:823-868) revokes the guest's refresh token server-side
and calls resetStore(), which wipes the entire local Zustand + MMKV store —
including the pending-patch and pending-purchase queues that
pushLocalGuestDataToServer would otherwise push forward — "everything goes
unless it is deliberately re-established" (comment at AuthProvider.tsx:848-865).
By the time the guest lands back on /auth and picks Apple, Google, or email,
isGuest is already false and there is no access token to send as
currentAccessToken, so the backend never receives a currentUserId and the
promote-in-place branch can't fire for any method — including Google/Apple,
which otherwise do preserve guest data. Any patches or purchases already
confirmed server-side under that guest's (now signed-out but not deleted)
Profile row become permanently unreachable — there is no way to sign back into
a specific guest Profile once its session is gone. This is a real gap between
what the merge-rule code guarantees and what this specific button in the
product actually does.
Separately, POST /api/sync/reconcile-device (sync.controller.ts:60,
sync.service.ts:757-) lets a still-anonymous caller merge forward any other
stale anonymous Profiles found for the same device id (e.g. left over from the
partial-unique-index race) — copying their patches/purchases/trips into the
current guest profile and deleting the stale ones, atomically per profile. It is
guarded to only run when the caller itself is anonymous, so an authenticated
member can never use a spoofed device id to absorb someone else's guest data
(sync.service.ts:765-772).
Account deletion
DELETE /api/account → AccountService.deleteAccount
(backend/src/account/account.service.ts:29-64), guarded by JwtAuthGuard. Order
matters and is enforced: (1) confirm the Profile exists; (2) delete the user's
cloud-album S3 objects via AlbumDataService.deleteAll before touching
Postgres rows — if S3 cleanup fails, the whole deletion aborts rather than
leaving orphaned rows with orphaned bytes; (3) run the shared deleteUserData
helper (backend/src/prisma/delete-user-data.ts:15-64); (4) delete the Profile
row itself, which cascades (via Prisma's onDelete: Cascade) to AuthCredential,
AuthIdentity rows, RefreshToken rows, AuthOneTimeToken rows, and
BroadcastMessageRead rows. See the Data Model and "What this does NOT do"
sections for exactly what does and doesn't get removed.
Client session handling (mobile)
AuthProvider (mobile/src/providers/AuthProvider.tsx) keeps the access token in
memory only (never written to disk) and persists the refresh token plus a
non-sensitive cached identity via expo-secure-store
(mobile/src/lib/auth/tokenStore.ts:1-16), using AFTER_FIRST_UNLOCK keychain
accessibility rather than the default WHEN_UNLOCKED — the app has background
location enabled, and iOS can relaunch it for a location update while the phone
is still locked, which throws under WHEN_UNLOCKED
(tokenStore.ts:19-33). A silent refresh is scheduled ~1 minute before the
current access token's exp (REFRESH_SKEW_MS = 60_000, AuthProvider.tsx:378),
and is also triggered reactively by any 401 from the API client and retried on
app foreground if a prior attempt failed transiently. All refresh triggers
(scheduled timer, every 401, foreground retry) are coalesced into one in-flight
network call (AuthProvider.tsx:282-346) so a burst of failures never becomes a
burst of refresh requests. Only an authoritative 401 (the server explicitly
rejecting the token) tears down the session; a network failure during refresh
does not.
Data model (Prisma models and key fields)
All in backend/prisma/schema.prisma.
Profile(:612-650, tableprofiles) — the one identity row for everyone, guest or member.id(uuid),email(nullable, unique — null for guests and for password accounts pre-verification is still non-null),displayName,isAdmin,isAnonymous(the guest flag),deviceId(guest device correlation, partial-unique onisAnonymous=true),slug(nullable, unique, lazily assigned for the public profile),referralCode(out of scope),publicEnabled(@default(true)),suspendedAt(a community ban — a suspended account can still sign in and keep its own collection; this is reversible moderation, distinct from deletion),createdAt/updatedAt.AuthCredential(:1372-1383) — one-to-one with Profile,passwordHash(nullable) andemailVerifiedAt. Only populated for accounts created through the password path.AuthIdentity(:1385-1400) — OAuth only.provider(enumAuthProvider { GOOGLE, APPLE },:1357-1362),providerAccountId,email,emailVerified, unique on(provider, providerAccountId). There is no row type for email-code or password sessions — those authenticate straight againstProfile/AuthCredential/AuthOneTimeToken, never through this table.RefreshToken(:1402-1419) —tokenHash(unique, SHA-256),familyId,expiresAt,revokedAt,replacedByTokenHash(legacy column from the removed rotation scheme, still used to "rescue" a token that was mid-rotation when rotation was retired),userAgent.AuthOneTimeToken(:1421-1436) —purpose(enumEMAIL_VERIFY,PASSWORD_RESET,MAGIC_LINK),tokenHash,code(nullable — only the magic-link/code path uses it),expiresAt,usedAt.UserPatch(:785-) —userId,patchId,collectedAt,source('gps' | 'import' | 'unknown'— only'gps'earns achievements). Central to what guest→member promotion carries forward automatically.UserPurchase(:903-915) —userId,collectionId,productId,purchasedAt.AlbumAccess,AlbumPhoto,AlbumShare— cloud album, out of this feature's scope but deleted/anonymized as part of account deletion (seedelete-user-data.ts).
API surface (endpoints, auth requirements)
All under backend/src/auth/auth.controller.ts unless noted. @UseGuards(ThrottlerGuard)
applies to the whole auth controller; individual routes add their own rate limits.
| Method & path | Guard | Purpose |
|---|---|---|
POST /api/auth/signup |
OptionalJwtAuthGuard (10/min) |
Password sign-up; dev/test only in practice |
POST /api/auth/signin |
none (10/min) | Password sign-in; dev/test only in practice |
POST /api/auth/anonymous |
none (5/min) | Create/reuse a device-scoped guest session |
POST /api/auth/oauth/:provider |
OptionalJwtAuthGuard (5/min) |
Google/Apple sign-in; provider ∈ google|apple |
POST /api/auth/refresh |
none | Exchange refresh token for a fresh access token (non-rotating) |
POST /api/auth/signout |
none | Revoke one refresh token |
POST /api/auth/signout-all |
JwtAuthGuard |
Revoke every session for the caller |
POST /api/auth/verify-email, GET /api/auth/verify-email |
none | Consume an email-verify token |
POST /api/auth/resend-verification |
none (3/min) | Generic response — no account enumeration |
POST /api/auth/password/forgot |
none (3/min) | Generic response — no account enumeration |
POST /api/auth/password/reset |
none | Consume a reset token, revokes all sessions |
POST /api/auth/magic-link/request |
OptionalJwtAuthGuard (5/min) |
Email a 6-digit code |
POST /api/auth/magic-link/verify |
OptionalJwtAuthGuard (5/min) |
Verify the code, issue a session |
GET /.well-known/jwks.json |
none | Public signing keys (jwks.controller.ts) |
GET /api/account/public-profile |
JwtAuthGuard |
Returns { slug, url, enabled }, lazily assigns a slug |
PATCH /api/account/public-profile |
JwtAuthGuard |
Body { enabled: boolean } — toggles publicEnabled |
DELETE /api/account |
JwtAuthGuard |
Full account deletion |
GET /u/:slug |
none | Public profile HTML page |
GET /u/:slug/card.png |
none | Public profile as a PNG OG card |
GET /api/avatar/:seed.svg |
none | Deterministic generated avatar |
POST /api/sync/reconcile-device |
JwtAuthGuard |
Merge stale same-device guest profiles (anonymous caller only) |
POST /api/sync/reset-progress |
JwtAuthGuard |
Deletes patches/trips/achievements/progression (not purchases) |
POST /purchases/migrate |
JwtAuthGuard |
Push locally-queued guest purchases to the now-signed-in account |
JwtAuthGuard accepts a guest's token exactly like a member's — a guest is
not blocked at the HTTP layer from any of these routes it's authorized for by
role. What actually differs guest vs. member is enforced client-side (see next
section) or by data-shape checks like public-profile.service.ts's
if (!profile || profile.isAnonymous || !profile.publicEnabled) return null;
(:26) — a guest can call PATCH /api/account/public-profile and set
enabled: true, but their /u/<slug> page will still 404 unconditionally.
Key files (annotated path:line list)
Backend:
backend/src/auth/auth-engine.service.ts— the whole sign-in/sign-up/guest/oauth/magic-link state machine (421 lines, extensively commented; start here).backend/src/auth/auth.controller.ts— route wiring + throttles.backend/src/auth/token/{access-token,refresh-token,one-time-token}.service.ts— token lifecycle.backend/src/auth/keys/auth-key.service.ts— ES256 key loading + JWKS export.backend/src/auth/jwt.strategy.ts— request-time verification, rejects unknownsub.backend/src/auth/oauth/oauth-provider.verifier.ts— Google/Apple ID-token verification.backend/src/auth/anon-handle.ts,anon-cleanup.service.ts— guest display handles and admin-triggered stale-guest sweep.backend/src/account/account.service.ts— deletion + public-profile meta.backend/src/public-profile/public-profile.service.ts,public-profile.controller.ts,slug.util.ts,avatar.ts,og-card.ts— the/u/<slug>surface.backend/views/profile/page.hbs— the actual HTML/CSS of the public profile "credential" page.backend/src/prisma/delete-user-data.ts— the shared per-user delete helper (and its explicitly-not-called sibling,deleteCommunityData).backend/src/admin/feature-flag-definitions.ts:15-21—guest_modeflag definition and default.
Mobile:
mobile/app/auth/index.tsx— the sign-in screen (Apple/Google/email/guest). Split into the house view-model pattern:AuthLandingScreenViewModel(:150),AuthLandingScreenViewModelImpl(:202, owns every hook) and the pureAuthLandingScreenLayout(:374).src/dev/mocks/auth.tsxrenders that layout in the DEV Screen mocks gallery atscout://dev-screen-mock/auth— anindexroute takes its slug from the DIRECTORY it sits in, not its basename (seescreenSlugFromFileinscreen-tests/screen-mocks.test.tsx).mobile/app/auth/email.tsx— the two-step email code flow. Split into the same house view-model pattern:EmailAuthScreenViewModel(:106),EmailAuthScreenViewModelImpl(:151, owns every hook) and the pureEmailAuthScreenLayout(:277). The flow's state is DATA —step('email' | 'code', the screen's only discriminant), the typed address, the code digits,isLoading,errorandresendCooldownas a NUMBER of seconds. Validation is the exported pureemailAuthFormState(email, code, isLoading)(:55), which returnstrimmedEmail/canSendCode/canVerifyCodeand is called by both the screen and its mock so the two cannot disagree about what a sendable address or a complete code is.src/dev/mocks/email.tsxrenders that layout atscout://dev-screen-mock/email(11 states). Addresses and codes in the mock are synthetic on purpose — they are user data — but every error string it stages is one this stack really produces, cited in the file.mobile/src/providers/AuthProvider.tsx— session state, token storage, refresh scheduling, guest-conversion data push.mobile/src/lib/auth/authClient.ts,tokenStore.ts— the/api/auth/*HTTP client and SecureStore wrapper.mobile/src/providers/authRedirect.ts— the pure routing decision function forAuthGate(resolveAuthRedirect).mobile/src/config/feature-flags.ts:15-20— mobile mirror of theguest_modeflag.mobile/src/components/permissions/PERMISSION_SCREENS.ts— exact copy for both onboarding permission screens, including the declined-location alternative.mobile/src/components/permissions/permissionRouting.ts:32-42—resolveOnboardingRoute, the location→notifications→referral→home sequencing (guests skip referral).mobile/app/(drawer)/profile.tsx— Profile screen. Split intoProfileScreenViewModel(what it shows),ProfileScreenViewModelImpl(every hook) and a pureProfileScreenLayout, per the house pattern — see screen-mocks.md. The split replaced five overlappingisSignedIn/isGuestconditions with oneProfileAccountStatusdiscriminant ('member' | 'guest' | 'signed-out'), which made a standing behaviour visible: a guest gets no Account section at all. Sign out and Delete account both sit behindisSignedIn, andderiveAuthStatesetsisSignedIn = !isGuest(mobile/src/providers/authState.ts:44), so a guest's only way off the screen isGuestBanner's "Sign up with email" — which signs them out and loses their local data (above).mobile/app/(drawer)/settings.tsx— Settings screen. Split intoSettingsScreenViewModel(what it shows),SettingsScreenViewModelImpl(every hook) and a pureSettingsScreenLayout, per the house pattern — see screen-mocks.md. The split removed a block of dead notification code: ahandleNotificationTogglethat nothing called and anotificationsEnabledlocal state that two effects wrote and nothing read, left behind when the row became a link to/notification-settings. The live writer of the persistednotificationsEnabledpreference ismobile/app/notification-settings.tsx, which has since been split the same way —NotificationSettingsScreenViewModel/…ViewModelImpl/NotificationSettingsScreenLayout, with the preference exposed asunlockBannerEnabled/setUnlockBannerEnabled. That split moved no behaviour either; it did surface that the screen never consults the OS notification permission, so the toggle can read ON on a handset that has notifications denied. See messages-and-notifications.md.
Configuration and flags
guest_mode— feature flag,defaultEnabled: false. Off hides "Continue as guest" entirely on/auth.AUTH_JWT_ISSUER,AUTH_JWT_KID_CURRENT/AUTH_JWT_PRIVATE_KEY_CURRENT, optionalAUTH_JWT_KID_PREVIOUS/AUTH_JWT_PRIVATE_KEY_PREVIOUS— signing key material; required at boot or the process throws.AUTH_ACCESS_TTL— access-token lifetime, default30m.AUTH_REFRESH_TTL_DAYS— refresh-token lifetime, default3650(10 years).MAGIC_LINK_DEV_ECHO— when'true',magicLinkRequestechoes the plaintext code in the API response for local testing. Anything else (including unset) is fail-closed.ADMIN_EMAILS— comma-separated allowlist; matched emails getisAdmin: trueon account creation across every sign-in path.GOOGLE_OAUTH_CLIENT_IDS,APPLE_OAUTH_CLIENT_IDS— comma-separated accepted audiences for provider token verification; sign-in throws if unset.AUTH_APP_URL— base URL used to build the verify-email / reset-password links sent by email.
Edge cases and known limits
- Stale guest cleanup is manual, not automatic.
AnonCleanupService.sweep(default 90-day TTL, skips any guest with a patch collected or purchase made on/after the cutoff) only runs when an admin callsPOST /api/admin/anon-cleanup/sweep— there is no cron job invoking it. Guest profiles do not expire on their own. - Refresh does not rotate, by design (see "How it works"). This trades a theoretically stronger reuse-detection guarantee for eliminating a real class of forced-logout bugs.
- Anon handles are not guaranteed unique (
anon-handle.ts:11-14) — two guests can display asanon-otter-4821. Harmless; it's a display convenience, not an identifier. guest_modeoff does not disable the backend guest endpoint.POST /api/auth/anonymousstill works if called directly; the flag only hides the button in the shipped mobile UI.- Guest data only migrates on account creation, never merge-on-sign-in. If
a guest signs into an account that already exists, their guest data is
discarded outright (
discardGuestIfAnonymous) — there is no "combine both" option anywhere in this flow. Profile.suspendedAtis a community moderation ban, separate from account deletion — a suspended account can still authenticate and keeps its own collection; only community surfaces are affected (out of this feature's scope, but worth not confusing with deletion).Reset Progress(Settings) does not delete purchases. It deletesuserPatch,trip,userActiveTrip,userAchievement, anduserProgression(sync.service.ts:672-701) but leavesuserPurchaseuntouched — a reset user keeps anything they bought.- "Clear Cache" (Settings) never calls the network. It's a purely local Zustand store reset that forces the next sync to re-pull content; the account stays signed in and nothing server-side changes.
- A guest can set
publicEnabled: trueviaPATCH /api/account/public-profile(the endpoint only requiresJwtAuthGuard, which a guest satisfies), but their/u/<slug>page will 404 regardless, becausegetBySlugfilters outisAnonymousprofiles unconditionally. - The
homefield on the public profile model is alwaysnull— reserved for a future city/state field, not currently populated (public-profile.service.ts:64). - A failed Apple or Google sign-in is INVISIBLE on a release build. Both
handlers set
error(app/auth/index.tsx,handleAppleSignIn/handleGoogleSignIn), but the alert that renderserroris nested inside the DEV sign-in card, which only exists when__DEV__ || getIsMaestro(). In a shipped build the provider call fails, the spinner clears, and the user is shown nothing at all — no message, no retry prompt, no change of any kind. The state is set and never rendered. Confirmed by reading the JSX, not inferred; left unchanged in the 2026-09-04 view-model migration, which was explicitly a no-behaviour-change move. - "Continue as guest" has no error path at all.
handleContinueAsGuesthas afinallybut nocatch, so a rejectedcontinueAsGuest()clears the spinner, tells the user nothing, and escapes as an unhandled promise rejection. It is the only entrance on the screen that never writes toerror— the other four all do (even if, per the item above, a release build cannot show it). Reachable only whenguest_modeis on, which it is not in production. animEnabledon the auth landing is dead code. The screen subscribes toAccessibilityInfo.reduceMotionChangedfor the lifetime of the mount and derivesanimEnabledfrom it; nothing reads the result. ESLint reports it as an unused variable. Harmless, but the reduce-motion listener is doing no work.- Auto-submitting the 6-digit code has never worked.
CodeInput'shandleChange(mobile/app/auth/email.tsx:510-514) callsonChange(digits)and then, on the sixth digit,onComplete()— in the same handler, in the same tick.onCompleteisverifyCode, captured from the render that ran BEFORE the sixth digit landed, so it reads a five-charactercode, fails its owncanVerifyCodeguard and returns without calling anything. The wiring is there and does nothing; every real verification comes from pressing "Verify & continue". Found while splitting the screen into a view model on 2026-09-04 and left alone, because that move was explicitly no-behaviour-change. - An invalid email address is never named. The client-side
EMAIL_RE(mobile/app/auth/email.tsx:34) only decides whether "Send code" is enabled. There is no message, no red field and no hint — a typo'd address leaves the user with a dim button and no stated reason, because the field's error styling is driven byerror, which only a rejected REQUEST can set. - The resend path runs without a loading state.
resend(mobile/app/auth/email.tsx:223-235) readsisLoadingas a guard but never sets it, so during a resend the Verify CTA stays live and the screen looks idle. Only the 30-second cooldown stops a second resend. - A failed sign-in email is invisible to the user.
AuthEmailService.sendLOGS a Resend failure and returns normally (backend/src/auth/auth-email.service.ts:45-47), somagicLinkRequeststill answers{ ok: true }, the app still advances to the code step, and the user waits for a code that was never delivered. There is no error to show and no way for the client to know. - What the user reads on a failure is not copy anyone wrote. The screen
renders
err.messageverbatim, so a rate limit showsThrottlerException: Too Many Requests(@nestjs/throttler's own string, five requests a minute —backend/src/auth/auth.controller.ts:104and:111), a DTO rejection showsHTTP 400(the globalValidationPipeanswers with amessageARRAY, whichauthClientwill not accept as a string and falls back from —mobile/src/lib/auth/authClient.ts:169-171), and a dead network shows React Native'sNetwork request failed. The three friendly fallbacks inemail.tsx(Could not send a code. Please try again.and its two siblings) only fire on a FALSYerr.message, which nothing inauthClientcan produce — they appear to be dead. - A wrong code and an expired code are the same screen. The backend answers
both with the single generic
Invalid or expired code(backend/src/auth/token/one-time-token.service.ts:69), deliberately, so a caller cannot enumerate accounts. There is no separate expired-code state to design for. useFeatureFlags().isLoadingis deliberately ignored here. While the server flags are still in flight,isEnabled('guest_mode')returns the client-side default (false) and the guest row is simply absent — no placeholder, no flicker. That is the intended behaviour for a positive flag with a false default, not a dropped loading state: an offline or failed fetch hides the row rather than briefly showing it.- Slug generation deliberately never derives from a guest's anon handle,
even after promotion — a slug is permanent, but a guest's
displayNameis cleared on promotion, so seeding a slug from it would leak a session-scoped value into a permanent, shareable URL (public-profile.service.ts:74-79).
What this feature does NOT do
- Account deletion does NOT delete community content.
AccountService.deleteAccountcalls onlydeleteUserData, neverdeleteCommunityData(backend/src/prisma/delete-user-data.ts:82-97, which deletespostCommentLike,postComment,postVote,post,boardFavorite,patchPhoto). That helper is only invoked byTestAccountCleanupService(backend/src/auth/test-account-cleanup.service.ts:82), which exists for purging Maestro-provisioned test accounts — never by a real user's account deletion. A deleted user's posts, comments, votes, board favorites, and uploaded patch photos remain live and attributed to their (now-dead) user id. Feedback submissions similarly survive (Feedback.userIdisSetNull, not cascaded) — the message text stays, just anonymized. Content reports filed about a deleted user (as opposed to filed by them) are also kept deliberately, as the moderation record of an incident. - There is no bulk "delete my content" option distinct from full account deletion.
- The public profile page never shows an email address, exact GPS
coordinates, or a home city/state (
homeis reserved but unpopulated; the page-model spec explicitly asserts this,public-profile.page.spec.ts:117). - Guest mode does not currently appear to real users unless the
guest_modeflag has been switched on in the live database — the code ships it off. - "Sign up" from the Profile screen's guest banner does NOT preserve guest
data, even though converting via the main
/authscreen normally does for Google, Apple, and password sign-up. That specific button signs the guest out (wiping local state and the session) before routing to/auth, which defeats the promote-in-place mechanism for every method. Do not claim guest data "always" or "automatically" carries forward — it depends on which UI path the guest used to start signing up. - There is no cross-account merge. The only "combine" case that exists is guest→brand-new-account promotion (same UUID, not a merge of two distinct data sets). Signing into an existing account from a guest session always discards the guest's data; there is no UI or endpoint to merge two already-existing member accounts.
- Password reset/forgot-password has no reachable mobile UI in production — the endpoints exist and are tested, but the only client that calls them is the dev-only password form.
- Refresh tokens are not device-bound and there is no "devices" management
screen —
signout-allrevokes every session for the user in one call; there is no per-device list or per-session revoke exposed to the user. - The
/u/<slug>"Places" stat panel is not a full leaderboard or public directory — there is no way to browse or search other users' public profiles from within the app; each is only reachable by its own shared link.
Tests that cover it
Backend (describe/it counts from a direct grep, not run in this session):
backend/src/auth/auth-engine.service.spec.ts— 56 cases covering every branch described above (signup/signin, refresh non-rotation, oauth's four branches including the unverified-email takeover guard, guest device reuse/collision/ concurrent-create-race, magic-link request/verify including the first-verify-vs-returning-user alert distinction).backend/src/auth/auth.controller.spec.ts— 13 cases (route wiring/guards).backend/src/auth/auth-throttle.spec.ts— 15 cases pinning every per-route rate limit read off the@Throttlereflect metadata, including the routes deliberately left unthrottled.auth.controller.spec.tsstubsThrottlerGuardout entirely, so until this spec the limits were unasserted and a typo in one was invisible.backend/src/auth/token/refresh-token.service.spec.ts— 10 cases, including explicit non-rotation assertions ("validates without rotating: the SAME token comes back, still live"; "accepts the same token repeatedly — a lost response can never log a user out").backend/src/auth/token/one-time-token.service.spec.ts— 10 cases.backend/src/auth/token/access-token.service.spec.ts— 4 cases.backend/src/auth/oauth/oauth-provider.verifier.spec.ts,backend/src/auth/keys/auth-key.service.spec.ts,backend/src/auth/jwks.controller.spec.ts,backend/src/auth/password.service.spec.ts,backend/src/auth/anon-handle.spec.ts(5),backend/src/auth/test-account-cleanup.service.spec.ts(4).backend/src/account/account.service.spec.ts— 5 cases, including "ABORTS the deletion when the S3 cleanup fails" and "deletes the profile last".backend/src/public-profile/public-profile.service.spec.ts— 9 cases (null for unknown/opted-out/anonymous, stats never leak email/coords, slug assignment + collision widening, never derives a slug from a guest handle).backend/src/public-profile/public-profile.page.spec.ts— 8 cases (404 for unknown/opted-out, tile capping/overflow math, "never leaks an email, coordinates, or a home town").backend/src/public-profile/{avatar,og-card,card-link,card-link.controller, slug.util,smart-link}.spec.ts— 29 more cases across the rest of the public-profile surface.
Mobile (screen-tests/, each also present in the screen-test drift registry):
mobile/screen-tests/auth-index.test.tsx— 8 cases, including the accessibility- label assertions added by the 2026-08-26 "Continue with…" copy fix.mobile/screen-tests/auth-email.test.tsx— 4 cases. It also passes UNCHANGED across the 2026-09-04 view-model split ofapp/auth/email.tsx, which is the evidence that split preserved behaviour.mobile/screen-tests/auth-index.test.tsxalso passes UNCHANGED across the 2026-09-04 view-model split, which is the evidence that the split preserved behaviour on a 1076-line screen.mobile/screen-tests/screen-mocks.test.tsxrenders all ten states of theauth(auth landing) gallery entry — release build, Android without an Apple entrance, both provider failures, the validation gate, a service rejection, both dev submissions in flight, long error copy, and guest mode on — each asserting content only that state produces. It also renders all eleven states of theemailentry: the address step, an address the regex rejects, a request in flight, theHTTP 400andNetwork request failedsend failures, the fresh code step, mid-typing, six digits in, verifying, a rejected code, and a throttled resend.mobile/screen-tests/root-layout-auth-gate.test.tsx— 4 cases (theresolveAuthRedirect"reopened hours later as a guest" regression guard).mobile/screen-tests/onboarding-location.test.tsx,onboarding-notifications.test.tsx— 2 cases each.mobile/screen-tests/profile.test.tsx— 11 cases.mobile/screen-tests/settings.test.tsx— 10 cases.mobile/src/components/permissions/__tests__/{permissionRouting, PERMISSION_SCREENS,onboardingNavigation}.test.ts— unit coverage for the onboarding sequencing logic.
E2E: mobile/maestro/tests/auth.yaml (tags: auth, onboarding, smoke,
critical) — provisions a fresh account via the backend directly, cold-boots
to a signed-out state, signs in through the dev card, walks the full
location→notifications permission chain, and returns the app to signed-out.
Absorbed the former referral-loop.yaml on 2026-08-26 since both started from
the same fresh-account state.
Open questions
IsRESOLVED 2026-08-31, confirmed by the product owner:guest_modecurrently enabled in the production database?guest_modeis OFF in production. Guest sign-in is not visible to real users; Apple, Google, and the emailed 6-digit code are the only new-session paths. Marketing copy must say an account is required. (The code default is also off, so both agree.)- Is community content surviving account deletion an intentional design
choice or a gap? The code is unambiguous about what happens (posts,
comments, votes, patch photos, and feedback text are not deleted), but no
comment in
delete-user-data.tsexplains why this is the right behavior for a real (non-test) account deletion, only why it's structured as a separate function. This may be relevant to App Store Review Guideline 5.1.1(v) ("account deletion... should also delete the user's account"), which typically expects a user's own generated content to be removed too — worth a deliberate product decision rather than an assumption either way. AuthService.getProfile(backend/src/auth/auth.service.ts) has no callers anywhere in the codebase outside its own module registration — appears to be dead code, not verified further since it's not reachable from any route.