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 lives only on a
HIDDEN route, scout://auth/dev (mobile/app/auth/dev.tsx), that renders only in
dev builds or under Maestro (showDevForm, derived from entrances.card === 'dev',
mobile/src/components/auth/authLandingViewModel.ts:214) and redirects home
anywhere else; nothing in the app links to it, and 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.
Where sign-in lives changed on 2026-10-24/25 (in store builds since v1.1.112). The entry
point is no longer the /auth screen. Every signed-out phone opens on the first-run
tutorial, whose fourth step is the account step where it signs in or signs up (see
onboarding): a fresh install, and equally a phone after Sign out, Delete
Account or a lost session (resolveAuthRedirect, mobile/src/providers/authRedirect.ts:68-79;
Alan, 2026-10-25: the standalone screen "looks weird" after sign-out). A phone that has
finished onboarding before is told apart only after it signs in on step 4: it then goes
straight into the app, without the permission ask or the reveal again. That rule is the
persisted, per-phone deviceOnboarded flag (mobile/src/domain/store.ts:904), read by
the tutorial (mobile/app/onboarding-tutorial.tsx:294-303) and onInteractiveSignIn.
/auth still exists as a route with the same account panel, but no signed-out device is
left on it: AuthGate sends bare /auth to the tutorial too.
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).
- Entry point moved into the tutorial (2026-10-24/25, in store builds since v1.1.112):
every signed-out device lands on
/onboarding-tutorialand signs in on its account step. Builds before v1.1.112 open on/authfirst. Sign-out and Delete Account land on the tutorial's step 1 (authRedirect.ts:68-79); becausedeviceOnboardedsurvives both (store.ts:3288), the sign-in on step 4 then goes straight home, skipping the ask and the reveal (onboarding-tutorial.tsx:294-303). - Google sign-in, Apple sign-in, email 6-digit code: shipped, always visible on
/authand on the tutorial's account step — not flag-gated. Apple sign-in is iOS-only: Android and web show Google and the email code (appleSignInAvailable: Platform.OS === 'ios',mobile/src/components/auth/authLandingViewModel.ts:471). - Guest mode ("Continue as guest"): SHIPPED, CURRENTLY HIDDEN. Functionally
complete server-side (
AuthEngineService.anonymous,auth-engine.service.ts:196), but the entry point is off in production — theguest_modeflag reads OFF in the live registry (GET https://scout-patches.com/api/feature-flags, checked 2026-09-22), so/authoffers only Apple, Google and email. It was ON from 2026-09-20 to 2026-09-22; while on, it was verified end-to-end on the production Android build (v1.1.91, versionCode 53) on 2026-09-20:continue-as-guest-buttonrenders on/auth, tapping it mints an anonymous session, onboarding skips/onboarding-referral(the guest-only route), home loads, patches unlock and collect against the live backend, and the Profile screen shows theGuestBannerplus a "FIELD GUIDE · GUEST" header. The visibility is flag-state, not code-state. The code-defined default is stilldefaultEnabled: 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." The flag was introduced 2026-08-27 (commit14e33fe7, "flag guest mode entry point"). It was confirmed OFF on 2026-08-31, switched ON by 2026-09-20, and OFF again by 2026-09-22, so always re-read the live flag rather than this sentence before describing guest mode as visible or hidden — that is the value that decides it. Since 2026-10-24 the guest link is also hidden on the tutorial's account step, behind the sameguest_modeflag (showGuestMode,AuthEntrances.tsx:381); the account-after- tutorial change fully built the guest path there and left it OFF. Hidden, not deleted. - Password email/password sign-up/sign-in: fully implemented backend
endpoints (
POST /api/auth/signup,POST /api/auth/signin), but no shipped mobile build shows a form that calls them to a real user. On device the form is the hidden dev sign-in screen,scout://auth/dev(mobile/app/auth/dev.tsx,DevSignInCardinAuthEntrances.tsx, testIDsdev-sign-in-screen,dev-email-input/dev-password-input/dev-sign-in-button/dev-create-account-button). It moved OFF the sign-in landing on 2026-10-25 (Alan: "I just want it off this main screen"). It renders only when__DEV__ || getIsMaestro()is true and redirects to/otherwise, so a store build that receives the link signs no one in. AuthGate lets a signed-out device stay on it (resolveAuthRedirect,mobile/src/providers/authRedirect.ts); a sign-in there routes like/auth/email(tutorial at the permission step for a never-onboarded device, home for an onboarded one). Reach it withxcrun simctl openurl booted "scout://auth/dev", MaestroopenLink, or Drawer → DEV → Deep links. Used for local development, debugging and Maestro E2E provisioning (mobile/maestro/scripts/provision-account.js— moved frommobile/scripts/; it mints the throwawaymaestro-<label>@example.comaccounts named bybackend/scripts/maestro-accounts.ts:1-7— andmobile/maestro/lib/dev-sign-in.yaml), not a user-facing path. A third condition exists and is web-only: a browser bundle built withEXPO_PUBLIC_TEST_SIGNINshows an email + password card ON the landing (the password card, a real entrance, not the dev card), because a production web bundle has no other way in — Google sign-in is sponsors-only on web and the email path is a mailed six-digit code. It cannot reach a phone:mobile/src/config/testSignIn.tsis the literalfalseon native and the variable's name appears nowhere in the native bundle, so nothing is read at runtime and no environment variable, launch argument or deep link can flip it. A fourth, also web-only and also incapable of reaching a phone:EXPO_PUBLIC_AUTO_CREATE_ACCOUNTskips sign-in entirely and mints an account on arrival, for one-time authentication smoke tests. Every fresh browser that opens such a build creates a real account, so it is pointed at a throwaway API or cleaned up after. - "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. Its install button links/i/<owner's code>when the owner already has a referral code (read-only — the page never mints one), else/get, tagged with the link'ssrc(defaultprofile). Since 2026-10-13 the app's Share mints the owner's code on their first share and shares/u/<slug>?ref=<code>&src=profile, in the message text only (no separateurl, which made iOS print the link twice) — see referral-and-invite.md; the fine print reads "Free on iPhone and Android" (it said "No account needed to look around" until 2026-09-30, which was false whileguest_modeships off). - 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 — wears the shared AccountPanel on the ember field, with no progress dots and no Skip; Apple, Google and email as one row of round logo-only buttons in the providers' colours (lab R7 · B, 2026-10-25); no dev card (that is /auth/dev) (the old hero lockup and city-medallion crown were removed 2026-10-24). Unreachable while signed out since 2026-10-25: AuthGate sends every signed-out device, bare /auth included, to the tutorial; a signed-in user here is sent into the app |
mobile/app/auth/index.tsx |
scout://auth |
| Email code sign-in | mobile/app/auth/email.tsx |
scout://auth/email |
"Why do I need an account?" — a sheet (RouteSheet, transparentModal) opened ONLY by a tap of the quiet link at the bottom of the account panel (below the Terms and Privacy line), never by itself; four reasons, the Privacy Policy, Got it. Allowed while signed out (resolveAuthRedirect). Copy is data in accountCopy.ts, pinned by its copy-truth test (lab R8, approved 2026-10-25) |
mobile/app/why-account.tsx |
scout://why-account |
| Dev sign-in (hidden, dev builds and Maestro only; redirects home anywhere else) — email + password and "New User"; moved off the landing 2026-10-25 | mobile/app/auth/dev.tsx |
scout://auth/dev |
| Onboarding — tutorial, incl. the account step (its step 4, where every signed-out device signs in; a device that has onboarded before then goes straight home) and the location + notification ask as its step 5 (the rest is out of scope, see onboarding) | mobile/app/onboarding-tutorial.tsx |
scout://onboarding-tutorial |
| Onboarding — first-unlock reveal (out of scope, see onboarding) | mobile/app/onboarding-reveal.tsx |
scout://onboarding-reveal |
| 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 |
| Edit profile — photo and handle (members only; see profile-photos, user-handles) | mobile/app/edit-profile.tsx |
scout://edit-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:19-20):
GET /u/:slug— the shareable public profile page (public-profile.controller.ts:115).GET /u/:slug/card.png— the same profile rendered as a 1200×630 OG image (profile-card.controller.ts:37). No auth guard; renders are budgeted at 60 a minute per IP and charged only on a cache miss (common/card-server.ts), so a cached card is never a 429.GET /api/avatar/:seed.svg— a deterministic generated avatar, any seed string (public-profile.controller.ts:108).GET /get— UA-sniffing redirect to the App Store or Play Store (public-profile.controller.ts:35), the target of the profile page's own CTA when the owner has no invite code. Accepts?src=&c=&m=campaign tags and logs each tap (see referral-and-invite.md).
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:100), which
additionally calls RevenueCat to transfer entitlements from the device's
anonymous RevenueCat id to the new account id. Since 2026-10-25 it aliases
only an id that starts with $RCAnonymousID: (purchases.service.ts:7-15);
any other id is ignored and nothing is transferred.
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; (2a') delete the member's profile
photo (Profile.avatarUrl, a public S3 object) — same rule, a failure aborts
(since 2026-09-28); (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), 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. Accessibility is fixed when
a keychain item is created — expo-secure-store re-saves an existing key with
SecItemUpdate on the value alone — so the items live under _v2 names
(scout_refresh_token_v2, scout_auth_identity_v2). Reads fall back to the
legacy names, writes go to the new ones and then delete the legacy item, so an
install from before the change migrates on its first refresh (2026-10-05; until
then, re-saving the old names silently kept them WHEN_UNLOCKED). 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. The only thing that ends a session, other than
the user tapping Sign out, is an authoritative 401 from POST /api/auth/refresh
(the server rejecting the refresh token itself: revoked, unknown, expired, or
its profile deleted). A network failure, a 5xx, a 429, a refreshed token that
fails to decode, or a 401 from any other endpoint all keep the session and
retry later. A data-call 401 that arrives before the first refresh of a cold
start has landed (an offline launch still showing the optimistic restore)
refreshes with the persisted refresh token; until 2026-10-05 that path deleted
the refresh token from the keychain instead, a permanent logout on no real
evidence.
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),automatedSource(nullable;'google_play_review'marks an account Google Play's pre-launch robots created,'apple_review'one created from an Apple App Review device (recognised by its Apple network address — see admin-and-content-ops) — stamped once at account creation byoauthSignIn/anonymous, or on an email-code account's firstmagicLinkVerify, a stats-exclusion tag that never affects sign-in),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' | 'manual' | 'admin' | 'unknown'— only'gps', a live in-app unlock, earns achievements; see unlock-and-location). 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(ClientIpThrottlerGuard) (src/common/client-ip-throttler.guard.ts) applies to the whole auth controller; individual routes add their own rate limits.
The throttle key is the resolved client IP (ClientIpService, src/common/client-ip.service.ts). It is req.ip unless a trusted web front end forwards a signed visitor IP; no live front end does that today (the shop doesn't send it). Built, not deployed: not live until launch (see road-trip-planner.md, which documents the signed header).
Web sign-in on the road trip planner. Built, not deployed: not live until launch (see road-trip-planner.md, Sign-in).
| 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 (render budget: 60/min per IP on a cache miss, CardServer) |
Public profile as a PNG OG card (ProfileCardController) |
GET /api/avatar/:seed.svg |
none | Deterministic generated avatar — the public page's fallback when the member has no photo |
GET /api/account/profile |
JwtAuthGuard |
The caller's own { id, handle, avatarUrl, email, isAnonymous } — the app's source for its identity |
GET /api/account/handle-availability |
JwtAuthGuard (60/min) |
Check a chosen handle; see user-handles |
PATCH /api/account/profile |
JwtAuthGuard (10/min) |
Multipart photo + handle save, members only; see profile-photos |
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,profile-card.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/src/components/onboarding/account/AccountPanel.tsx(+accountCopy.ts) — the account panel shared by/authand the tutorial's account step (lab Round 5 · B since 2026-10-25: the Field Museum patch, "Keep every patch you earn.", one sentence, the gold free line; the panel drawsACCOUNT_PATCHitself, hardcoded instarterPack.tssince 2026-11-04, so both hosts show the same patch without waiting on the catalogue); entrances and view-model inmobile/src/components/auth/AuthEntrances.tsxandauthLandingViewModel.ts.mobile/src/providers/authRedirect.ts(resolveAuthRedirect) andAuthGateinmobile/app/_layout.tsx:386-421— signed-out routing: every signed-out device goes to the tutorial since 2026-10-25;deviceOnboardedis read only by the tutorial, after a sign-in on its account step.mobile/app/auth/index.tsx— the sign-in screen (Apple/Google/email/guest). Since 2026-10-24 a thin shell overAccountPanel, still in the house view-model pattern:AuthLandingScreenViewModelis re-exported (:22) frommobile/src/components/auth/authLandingViewModel.ts:79,AuthLandingScreenViewModelImpl(:23) ISuseAuthLandingViewModel(authLandingViewModel.ts:156, owns every hook), and the pureAuthLandingScreenLayout(:25) rendersAccountPanelunder the ember field.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(:108),EmailAuthScreenViewModelImpl(:153, owns every hook) and the pureEmailAuthScreenLayout(:291). 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)(:57), 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—resolveOnboardingRoute, taking a single options object (tutorialSeen,revealSeen,referralAsked,isRealAccount) and sequencing tutorial→reveal→home. There are nolocation/notificationsinputs (the standalone permission screens were deleted; the ask is the tutorial's permission step, step 5) and nohasUnlocksinput (the reveal always runs once). The referral hop is switched off — seemobile/src/components/referral/referralCapture.ts, whichAuthGate'sreferralOwedreads too, soresolveAuthRedirect'sreferralOwedis always false in the app. The tutorial and reveal steps are covered in full in onboarding.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). Neither Sign out nor Delete account navigates (fixed 2026-10-24): bothawait signOut()and leave the route toAuthGate, as the drawer's sign-out does. Sign out used toreplace('/auth')without awaiting, so a still-signed-in user sat on/authandAuthGatebounced them into the drawer and back; Delete account used toreplace('/')afterAuthGatehad already sent the signed-out device to/auth, showing the drawer for a frame. A failed sign-out now shows a "Sign out failed" alert instead of an unhandled rejection.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. Three rows the rest of this doc does not otherwise name (verified 2026-10-29):- Public profile toggle (
settings-public-profile-toggle,mobile/app/(drawer)/settings.tsx:491) — aBrassSwitchthat readsGET /api/account/public-profileon mount and writesPATCH /api/account/public-profile(mobile/src/api/public-profile.ts:6-9); optimistic, reverts on a failed PATCH, shows a spinner while busy (settings.tsx:151-160). Same endpoints as the API table above. - Click Sound toggle (
settings-sound-toggle,settings.tsx:505) — the only UI for thesoundEnabledpreference in the app store (mobile/src/domain/store.ts:1035, defaultfalseat:1630, persisted at:3634);mobile/src/utils/clickSound.ts:30no-ops unless it is on. Local only; never sent to the server. - Test Celebration (
settings-test-celebration,settings.tsx:658) — under the admin-only "Developer tools" section (viewModel.isAdmin,isAdminEmail(user?.email)at:126, section gate:638). Pushes/celebrationwith a random collected patch that has art (settings.tsx:163-174); does nothing when no such patch exists. Not visible to ordinary members.
- Public profile toggle (
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
-
Delete Account and the privacy policy disagree (found 2026-10-25). Delete Account (
DELETE /api/account,backend/src/account/account.service.ts) removes patches, achievements, trips, purchases, album data and the avatar, but NOT posts, comments, votes or patch photos —deleteCommunityDataruns only for test accounts (backend/src/prisma/delete-user-data.ts). The privacy policy says deleting the account removes "contributed content" (backend/views/privacy/page.hbs). Alan, 2026-10-25: leave both as they are — deleted accounts' posts and comments stay. So the "Why do I need an account?" sheet (and any copy) must never say deleting an account erases what you posted. -
The profile-link share is measured (2026-09-30):
shareProfileUrlgoes throughanalytics.trackShare('profile', 'profile_link', …)(analytics-events.md). -
The shared text carries the link exactly once (2026-10-13, board c1524b30): the payload is
"My field guide on Scout:\n\n<url>"with nourlfield, where<url>is/u/<slug>?ref=<code>&src=profile(inviteUrlFor; norefwhen no code can be had — see referral-and-invite.md). It used to set bothmessageandurl, and iOS hands those to the share sheet as two items, so an emailed share read "…: <link> <link>". Every link share in the app (profile, invite, album links) now goes throughshareLink/linkSharePayloadinmobile/src/lib/shareLink.ts, which never setsurl. Pinned bymobile/screen-tests/profile.test.tsxandmobile/src/lib/__tests__/shareLink.test.ts. -
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,userProgressionanduserFeatureEvent(resetProgress,sync.service.ts) but leavesuserPurchaseuntouched — a reset user keeps anything they bought. Posts and other activity stay too, but earn badges only from the reset on (docs/features/achievements.md). -
"Clear Cache" (Settings) changes nothing on the server. It drops the query cache, the device's copy of content, the collection, messages and so on, and pulls them again (
clearCache,mobile/src/domain/store.ts). What only the device holds stays: queued collects and removals, trips, settings and the session. The collection stays until a new copy replaces it, and offline it refuses and clears nothing. -
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:72). -
A failed Apple or Google sign-in was INVISIBLE on a release build — fixed 2026-09-30. Both handlers set
error, but the only alerts that rendered it sat inside the dev, password and auto-create cards, none of which a store build shows. Now the failure renders under the provider buttons whenever no card is up (testID="auth-provider-error"), is sent to analytics asauth_failedwith a closed-listerror_code(nevererr.message), and is captured to Sentry withtags.feature = 'auth'. A dismissed sheet isauth_cancelled, with no error shown. See analytics-events.md; pinned bymobile/screen-tests/auth-index-release.test.tsx. -
"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. -
No email goes to
example.com. That domain is reserved and Resend refuses it, soAuthEmailService.sendreturns without calling Resend whenisReservedEmailDomainmatches (backend/src/common/email.ts). Maestro and React Native web E2E accounts all use it. The owner's+aliasaddresses are internal for analytics but still get mail, so this does not useisInternalEmail. -
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). -
The sign-in screen is reached involuntarily only when the server confirms the session is dead, or the keychain stays unreadable through three foregrounded attempts (
KEYCHAIN_READ_MAX_ATTEMPTS). There is no silent re-authentication: a refresh token the server rejects cannot be recovered without the user signing in again. Server logs for 2026-09-21 → 10-05 show 6 such rejections, all from dev builds or accounts deleted on 2026-09-23. -
Sign-out does not remove the photo album from the device.
reset()andclearLocalUserData()both preservevisitPhotos,scannedPhotoIdsandimportScanCompletedAt(pickAlbumCache,mobile/src/domain/store.ts) — a product decision on 2026-10-05. On a shared device the next account sees the previous person's album, and if that account has cloud-album access the album is backed up to it.
What this feature does NOT do
-
A fresh device is NOT shown a sign-in screen first (since v1.1.112). It sees the tutorial, and signs in at its fourth step. Do not describe the app as "sign in, then a tutorial".
-
/authdoes NOT teach. A sign-in there goes straight in: no tutorial and no first- unlock reveal, becausedeviceOnboardedis already true. A phone handed to someone new skips the lesson, asdeviceOnboardedsurvives sign-out and Delete Account. -
There is no guest path in the shipped UI, on
/author in the tutorial. It is built and flag-gated OFF (guest_mode), not deleted. -
The account copy does NOT say an account keeps "everything". It keeps patches, achievements, posts and comments. Photos stay on the device, and cloud album is granted to no one. Free, no subscription.
-
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). It does show the member's profile photo when they have set one (since 2026-09-28, profile-photos) — on the page and the share card — and still never shows the handle. -
Guest mode's visibility is decided entirely by the live
guest_modeflag, not by the code — the code ships it off (defaultEnabled: falsein both registries), and the database value overrides that. It is currently OFF in production (checked 2026-09-22), so guest mode does NOT appear to real users today; flipping the flag on shows it again with no code change and no release. -
"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 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. The in-app leaderboard lists handles and patch counts, but its rows don't open a profile either.
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):
- Account-after-tutorial (2026-10-24):
mobile/src/providers/__tests__/authRedirect.test.ts(signed out: fresh install to the tutorial,/auth/emailallowed, bare/authbounced, onboarded to/auth, deep-link carry and non-carry);mobile/screen-tests/root-layout-auth-gate.test.tsx(AuthGate signed-out routing,:172);mobile/src/domain/__tests__/store.deviceOnboarded.test.ts;mobile/src/providers/__tests__/AuthProvider.test.tsx(interactive sign-in callsonInteractiveSignIn);mobile/src/components/onboarding/account/__tests__/accountCopy.test.ts(the copy-truth guard);mobile/screen-tests/auth-index.test.tsx("has no tutorial progress dots on the standalone screen",:158; the guest-flag pair,:206and:222). 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-tutorial.test.tsx— 40 cases, including the account step and the permission step that replaced the deletedonboarding-location.test.tsx/onboarding-notifications.test.tsx.mobile/screen-tests/account-after-tutorial-flow.test.tsx— the one test on the REAL navigator (expo-router/testing-library'srenderRouter, with the realAuthGate,OnboardingProvider, tutorial and email screen): a fresh signed-out device lands on the tutorial, Skip reaches the account step, "Continue with email" pushes/auth/email, and a successful code sign-in pops back to the SAME tutorial instance (one route in the stack, same key, mounted once) at the permission step. Falsified by replacing thedismissTowithreplaceorpush(two tutorials in the stack), by dropping it (stuck on/auth/email), and by stopping the tutorial's in-place advance.mobile/screen-tests/profile.test.tsx— the variant-B redesign (2026-09-28): photo vs initial, long handle wraps, rarity above Collected, achievements error + retry, the page-loader skeleton instead of "0 / 1,269" (a regression proven failing against the old screen), Edit profile entry points, and no level/XP/streak text. Plus the sign-out / Delete account regression (2026-10-24, proven failing against the old screen): neither navigates while sign-out is in flight or after it lands, and a failed sign-out shows an alert.mobile/screen-tests/edit-profile.test.tsx— the Edit profile screen.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.
Session resilience (unit):
mobile/src/providers/__tests__/AuthProvider.test.tsx— "never signs out on non-authoritative evidence": a 401 before the first refresh retries with the persisted token (paired with the same path still signing out on an authoritative 401), and an undecodable refreshed token keeps the session. Both bugs confirmed failing against the pre-2026-10-05 code.mobile/src/lib/auth/__tests__/tokenStore.keychainAccessibility.test.ts— a fake keychain with expo-secure-store's real semantics (accessibility fixed at creation); an upgraded install must read back while locked after one save. Confirmed failing against the same-key-name code.mobile/src/domain/__tests__/store.albumCacheSurvives.test.ts— the album cache survivesclearLocalUserData, residue discard, andreset().
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 hidden dev screen (scout://auth/dev), 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 — and it has changed twice. As of 2026-09-22guest_modecurrently enabled in the production database?guest_modeis OFF in production (live registry, checked 2026-09-22). It was ON on 2026-09-20, verified by reading the live registry (GET https://scout-patches.com/api/feature-flags) and by driving the production Android build through a full guest session. The earlier resolution ("OFF, confirmed by the product owner, 2026-08-31") was correct when written and is superseded too. With the flag off, marketing copy must NOT say no sign-up is needed; if it comes back on, "no sign-up" is right but "no account" never is, since a guest is still a realprofilesrow. This answer expires the moment someone flips the flag. Re-read it rather than trusting this line.- 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.