Scout — Full Product Context → feature documentation

User handles

Every Scout profile carries a unique handle — the name shown on posts, comments, photos and the in-app Profile screen, and the thing @-mentions resolve against. Members can change it on Edit profile.

Summary

Every Scout profile carries a unique handle — the name shown on posts, comments, photos, the leaderboard and the in-app Profile screen, and the thing @-mentions resolve against. It is not shown on the public profile page (see below). A handle is derived from the account's email address at signup (and by the one-time backfill), and since 2026-09-28 a member can change it on the Edit profile screen (board ticket 1c035f64) — as often as they like, with the old handle freed at once. Guests keep the generated anon-<animal>-<n> name they already displayed, promoted into a real handle, and cannot change it.

Before this existed, members had no name at all. Profile.displayName was set to null on signup and wiped again on guest→member promotion, so what users saw was a computed fallback — the local part of their email address — which two different accounts could produce identically.

Status (shipped / beta-badged / flagged off)

Shipped, unconditional. No feature flag gates handles; they are on for every account. There is no opt-out, because the handle replaces a name that was already being displayed. Changing a handle (Edit profile) is also shipped and unflagged, for members only.

User-facing surfaces

How it works (end-to-end)

  1. Derivation — normalizeHandle() (backend/src/profile/handle.ts) takes the email local part (or a guest's anon-* name) and applies one rule: lowercase · drop the +tag · fold accents via NFKD · strip anything outside [a-z0-9-] · collapse repeated hyphens · trim leading/trailing hyphens · pad to a 3-character minimum.

    saxal28+09524576@gmail.com → saxal28
    jane.mccarthy@gmail.com    → janemccarthy
    A.Lan_Sax+beta@work.co     → alansax
    josé@gmail.com             → jose
    anon-otter-11 (a guest)    → anon-otter-11   (unchanged)
    

    Hyphens are permitted specifically so guests need no special case: no email local part contains one after +tag removal, so allowing them changes nothing for members while leaving anon-otter-11 intact.

  2. Collision handling — handleCandidates() returns the bare form, then name2 … name9, then a candidate derived from the profile id (janemccarthy-abcdef). The id-derived tail makes the list unexhaustible: the local database has 33 plus-addressed variants of one address, all normalizing to saxal28, and they walk past every numeric suffix.

  3. Claiming — pickHandle() (backend/src/profile/handle-assignment.ts) SELECTs which candidates are already taken and returns the first free one. It reads before it writes deliberately: every caller runs inside a Prisma interactive transaction, and in Postgres a constraint violation aborts the whole transaction, so a try/catch retry loop over candidates cannot work there.

  4. Assignment happens at six auth call sites (backend/src/auth/auth-engine.service.ts): password signup and promotion, guest creation, OAuth signup and promotion, and magic-link signup. Creates pre-generate their own UUID rather than letting the database default it, because the terminal fallback candidate is derived from the id.

  5. Promotion re-derives. When a guest becomes a member, the handle is recomputed from the new email rather than merely surviving — otherwise the account would wear anon-otter-11 permanently, since nothing can edit it.

  6. Rendering — displayNameFor() (backend/src/patch-photos/username.ts) is handle-first, falling back to the email local part, then displayName, then "Guest". The fallback chain is not dead code: refresh tokens are non-rotating, so a client can present an access token minted before handles shipped.

  7. The JWT carries it — since 2026-09-28. handle is signed into the access token (auth-engine.service.ts:151,172 pass it to AccessTokenService.sign, backend/src/auth/token/access-token.service.ts) and read on the client (mobile/src/lib/auth/claims.ts decodeAccessClaims). Until 2026-09-28 it was not: sign() dropped the field both call sites passed, and decodeAccessClaims never read it, so user.handle was always undefined and the Profile screen showed the title-cased email prefix. An earlier version of this doc said the claim was signed; it was wrong. Both halves are now fixed and pinned by regression tests. The claim is optional on the client for the same non-rotating-refresh reason (null from the server, undefined when absent). The app's own identity (handle and photo) is read from GET /api/account/profile, not the token, because a token cannot carry the photo and its claim is stale until the next refresh after a rename.

  8. Changing a handle. PATCH /api/account/profile with a handle field (backend/src/account/profile-edit.service.ts). The chosen-handle rule is validateChosenHandle() (backend/src/profile/handle.ts), stricter than the signup derivation — it rejects rather than rewrites:

    • lowercased and trimmed; ^[a-z0-9]+(-[a-z0-9]+)*$ (no leading, trailing or doubled hyphen); 3–20 characters;
    • reserved: admin, scout, support, staff, moderator, official, help, system, root, and anything starting anon- (the guest namespace);
    • profanity: each hyphen-separated segment through findBlockedTerm, plus the stem list (containsBlockedStem, backend/src/moderation/text-filter.ts) anywhere in the handle. A plain substring check over every term is deliberately NOT done — it would reject therapist, grapevine and scunthorpe. Existing handles that break these rules are grandfathered: the rule runs only when the handle actually changes, so a photo-only save never trips on an old 21-character handle. There is no cooldown, and the old handle is free for anyone immediately — no history, no hold. A taken handle returns 409 with up to three free, valid suggestions (the handleCandidates() ladder filtered through the rule); two concurrent claims resolve to exactly one owner via the unique index (isHandleConflict). After a successful save the app writes the result into its myProfile cache, invalidates byline queries, and refreshes the session so the claim is current.

Data model (Prisma)

Distinct from Profile.slug, which is a URL segment that always carries a hash suffix and is minted lazily on first public-profile view.

API surface

Handles are read through existing payloads (post/comment bylines, profile responses) and the JWT claim. Since 2026-09-28 (all JwtAuthGuard, members only for writes — a guest gets 403):

Method & path Throttle Purpose
GET /api/account/profile — The caller's own { id, handle, avatarUrl, email, isAnonymous }
GET /api/account/handle-availability?handle= 60/min { available, handle, reason?, suggestions? } — reason is format, too-short, too-long, reserved or taken
PATCH /api/account/profile 10/min Multipart: optional handle, photo, removePhoto — see profile-photos

There is still no endpoint to search handles.

Key files

Configuration and flags

None. No environment variable or feature flag affects handles.

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Open questions