What changed, and why

Sharing (#88) shipped with a user-typed display name — the only identity that reaches a public run page. A player could type a slur, an impersonation, or a harassing handle and it rode onto that page. The detector floor caught the unforgivable categories; contextual abuse of a self-chosen handle was a documented, open follow-up.

This change closes that hole by construction: the handle becomes generated, never typed. With no free-text input anywhere, there is nothing to type an abusive handle into. A player can reroll to a different generated name, but cannot author one.

The handle also graduates from a per-share field to a per-account property (a new profiles table), minted lazily on first need and carried across the anonymous→account upgrade for free. Two new surfaces give it a home: a /profile page and a /settings account hub. Blast radius is contained: the public-read boundary (getPublicRun) and its tests are untouched; the core game and anonymous sharing are unaffected.

The handle: generated, never typed

The generator is the curated-pool stance applied to identity (the same shape as the objective pool): a finite, hand-held set of safe outputs, never the model or the user inventing one. A handle is an "Adjective Noun" pair drawn from two wordlists themed to the game's historian voice — "Wandering Cartographer", "Gilded Scribe".

Two curated wordlists — clean by construction, historian-flavored.

server/utils/username.ts · 92 lines
server/utils/username.ts92 lines · TypeScript
⋯ 27 lines hidden (lines 1–27)
1/**
2 * Generated, reroll-only usernames (issue #117).
3 *
4 * A player's public handle is GENERATED, never typed — which is what makes it safe by
5 * construction: with no free-text input there is nothing to type a slur, an
6 * impersonation, or a harassing handle into (the self-chosen-handle abuse follow-up
7 * flagged in run-share.post.ts disappears entirely). The only way to change it is to
8 * REROLL — generate another one.
9 *
10 * The handle is an "Adjective Noun" pair drawn from two curated wordlists themed to the
11 * game's historian / timeline voice ("Wandering Cartographer", "Gilded Scribe"). Both
12 * lists are clean by construction, AND the COMBINATION is screened (`isWholesomeUsername`)
13 * so no pairing reads badly — a redundant same-root pair ("Wandering Wanderer") or any
14 * specific combo we decide to exclude. Mirrors the curated objective pool's stance
15 * (server/utils/objectives.ts): a finite, hand-held set of safe outputs, never the model
16 * or the user inventing one.
17 *
18 * Pure + dependency-free, so it unit-tests in node and never enters the browser bundle's
19 * concern (the generation runs server-side, like the objective pool). `generateUsername`
20 * takes an injectable RNG so a test can pin the draw deterministically.
21 *
22 * Display names are deliberately NON-UNIQUE (issue #117's lean): the subject id is the
23 * real account key, so a collision between two "Gilded Scribe"s is harmless flavor, not a
24 * bug — no discriminator, no global uniqueness check.
25 */
26 
27/** The adjective half — evocative, period-flavored, all wholesome. */
28export const USERNAME_ADJECTIVES = [
29 'Wandering', 'Gilded', 'Patient', 'Astral', 'Verdant', 'Quiet', 'Amber', 'Distant',
30 'Ardent', 'Tireless', 'Lucid', 'Humble', 'Storied', 'Errant', 'Vivid', 'Solemn',
31 'Kindly', 'Boundless', 'Twilight', 'Northern', 'Ancient', 'Cobalt', 'Marble',
32 'Lantern', 'Steadfast', 'Roaming', 'Candid', 'Mellow'
33] as const
34 
35/** The noun half — agent-nouns of the chronicler / mapmaker world, so the pair always
36 * reads as a person's handle. */
37export const USERNAME_NOUNS = [
38 'Cartographer', 'Scribe', 'Archivist', 'Chronicler', 'Curator', 'Wayfarer',
39 'Lorekeeper', 'Annalist', 'Antiquary', 'Surveyor', 'Navigator', 'Compiler',
40 'Steward', 'Witness', 'Observer', 'Astronomer', 'Geographer', 'Diarist',
41 'Pilgrim', 'Voyager', 'Sage', 'Scholar', 'Almanacker', 'Cartwright',
42 'Historian', 'Recorder', 'Mariner', 'Herald'
43] as const
44 
45/**
46 * The combination escape valve: specific "adjective noun" pairs (lower-cased) we never
47 * want to ship, even though both words are individually fine. Empty by default — the
48 * lists are curated so nothing reads badly — but it's the documented seam to exclude a
49 * pairing found unfortunate in review, without surgery on the wordlists.
⋯ 43 lines hidden (lines 50–92)
50 */
51const UNFORTUNATE_COMBINATIONS = new Set<string>([])
52 
53/** First few letters of a word, lower-cased — a cheap shared-root probe. */
54const stem = (word: string): string => word.toLowerCase().slice(0, 5)
55 
56/**
57 * Is this generated pair fit to ship? It must be an "Adjective Noun" shape, must not be
58 * an explicitly-excluded combination, and must not be a redundant same-root pairing
59 * ("Wandering Wanderer" reads as a typo, not a handle). Pure + exported so the screen is
60 * directly testable, like the objective pool's normalizeObjectiveTitle.
61 */
62export function isWholesomeUsername(name: string): boolean {
63 const key = name.trim().toLowerCase()
64 if (UNFORTUNATE_COMBINATIONS.has(key)) return false
65 const parts = key.split(/\s+/)
66 if (parts.length !== 2) return false
67 const [adjective, noun] = parts
68 if (!adjective || !noun) return false
69 // A pair whose two words share a root is redundant — screen it out.
70 return stem(adjective) !== stem(noun)
72 
73/** Pick one element of a non-empty list with the given RNG (0 ≤ rng() < 1). */
74function pick<T>(list: readonly T[], rng: () => number): T {
75 return list[Math.floor(rng() * list.length)]
77 
78/**
79 * Generate one fresh "Adjective Noun" handle, screened so the pairing is wholesome
80 * (`isWholesomeUsername`). Re-draws on the rare screened-out combination, bounded so it
81 * always returns; the curated lists make a clean draw overwhelmingly likely on the first
82 * try. `rng` is injectable so a test can pin the exact draw.
83 */
84export function generateUsername(rng: () => number = Math.random): string {
85 for (let attempt = 0; attempt < 8; attempt++) {
86 const candidate = `${pick(USERNAME_ADJECTIVES, rng)} ${pick(USERNAME_NOUNS, rng)}`
87 if (isWholesomeUsername(candidate)) return candidate
88 }
89 // Belt-and-braces: a hand-checked clean pair, should the screen ever exhaust the
90 // attempt budget (it won't with the curated lists, but generation must never fail).
91 return `${USERNAME_ADJECTIVES[0]} ${USERNAME_NOUNS[1]}`

Clean lists aren't enough on their own: the combination is screened too, so no pairing reads badly. isWholesomeUsername rejects a non-two-word shape, an explicitly-excluded pair, and a redundant same-root pairing ("Wandering Wanderer" reads as a typo). Generation re-draws on the rare screened-out pair, bounded so it always returns; rng is injected so a test can pin the draw.

The combination screen, and generation that re-draws until it passes.

server/utils/username.ts · 92 lines
server/utils/username.ts92 lines · TypeScript
⋯ 61 lines hidden (lines 1–61)
1/**
2 * Generated, reroll-only usernames (issue #117).
3 *
4 * A player's public handle is GENERATED, never typed — which is what makes it safe by
5 * construction: with no free-text input there is nothing to type a slur, an
6 * impersonation, or a harassing handle into (the self-chosen-handle abuse follow-up
7 * flagged in run-share.post.ts disappears entirely). The only way to change it is to
8 * REROLL — generate another one.
9 *
10 * The handle is an "Adjective Noun" pair drawn from two curated wordlists themed to the
11 * game's historian / timeline voice ("Wandering Cartographer", "Gilded Scribe"). Both
12 * lists are clean by construction, AND the COMBINATION is screened (`isWholesomeUsername`)
13 * so no pairing reads badly — a redundant same-root pair ("Wandering Wanderer") or any
14 * specific combo we decide to exclude. Mirrors the curated objective pool's stance
15 * (server/utils/objectives.ts): a finite, hand-held set of safe outputs, never the model
16 * or the user inventing one.
17 *
18 * Pure + dependency-free, so it unit-tests in node and never enters the browser bundle's
19 * concern (the generation runs server-side, like the objective pool). `generateUsername`
20 * takes an injectable RNG so a test can pin the draw deterministically.
21 *
22 * Display names are deliberately NON-UNIQUE (issue #117's lean): the subject id is the
23 * real account key, so a collision between two "Gilded Scribe"s is harmless flavor, not a
24 * bug — no discriminator, no global uniqueness check.
25 */
26 
27/** The adjective half — evocative, period-flavored, all wholesome. */
28export const USERNAME_ADJECTIVES = [
29 'Wandering', 'Gilded', 'Patient', 'Astral', 'Verdant', 'Quiet', 'Amber', 'Distant',
30 'Ardent', 'Tireless', 'Lucid', 'Humble', 'Storied', 'Errant', 'Vivid', 'Solemn',
31 'Kindly', 'Boundless', 'Twilight', 'Northern', 'Ancient', 'Cobalt', 'Marble',
32 'Lantern', 'Steadfast', 'Roaming', 'Candid', 'Mellow'
33] as const
34 
35/** The noun half — agent-nouns of the chronicler / mapmaker world, so the pair always
36 * reads as a person's handle. */
37export const USERNAME_NOUNS = [
38 'Cartographer', 'Scribe', 'Archivist', 'Chronicler', 'Curator', 'Wayfarer',
39 'Lorekeeper', 'Annalist', 'Antiquary', 'Surveyor', 'Navigator', 'Compiler',
40 'Steward', 'Witness', 'Observer', 'Astronomer', 'Geographer', 'Diarist',
41 'Pilgrim', 'Voyager', 'Sage', 'Scholar', 'Almanacker', 'Cartwright',
42 'Historian', 'Recorder', 'Mariner', 'Herald'
43] as const
44 
45/**
46 * The combination escape valve: specific "adjective noun" pairs (lower-cased) we never
47 * want to ship, even though both words are individually fine. Empty by default — the
48 * lists are curated so nothing reads badly — but it's the documented seam to exclude a
49 * pairing found unfortunate in review, without surgery on the wordlists.
50 */
51const UNFORTUNATE_COMBINATIONS = new Set<string>([])
52 
53/** First few letters of a word, lower-cased — a cheap shared-root probe. */
54const stem = (word: string): string => word.toLowerCase().slice(0, 5)
55 
56/**
57 * Is this generated pair fit to ship? It must be an "Adjective Noun" shape, must not be
58 * an explicitly-excluded combination, and must not be a redundant same-root pairing
59 * ("Wandering Wanderer" reads as a typo, not a handle). Pure + exported so the screen is
60 * directly testable, like the objective pool's normalizeObjectiveTitle.
61 */
62export function isWholesomeUsername(name: string): boolean {
63 const key = name.trim().toLowerCase()
64 if (UNFORTUNATE_COMBINATIONS.has(key)) return false
65 const parts = key.split(/\s+/)
66 if (parts.length !== 2) return false
67 const [adjective, noun] = parts
68 if (!adjective || !noun) return false
69 // A pair whose two words share a root is redundant — screen it out.
70 return stem(adjective) !== stem(noun)
72 
73/** Pick one element of a non-empty list with the given RNG (0 ≤ rng() < 1). */
74function pick<T>(list: readonly T[], rng: () => number): T {
75 return list[Math.floor(rng() * list.length)]
77 
78/**
79 * Generate one fresh "Adjective Noun" handle, screened so the pairing is wholesome
80 * (`isWholesomeUsername`). Re-draws on the rare screened-out combination, bounded so it
81 * always returns; the curated lists make a clean draw overwhelmingly likely on the first
82 * try. `rng` is injectable so a test can pin the exact draw.
83 */
84export function generateUsername(rng: () => number = Math.random): string {
85 for (let attempt = 0; attempt < 8; attempt++) {
86 const candidate = `${pick(USERNAME_ADJECTIVES, rng)} ${pick(USERNAME_NOUNS, rng)}`
87 if (isWholesomeUsername(candidate)) return candidate
88 }
89 // Belt-and-braces: a hand-checked clean pair, should the screen ever exhaust the
90 // attempt budget (it won't with the curated lists, but generation must never fail).
91 return `${USERNAME_ADJECTIVES[0]} ${USERNAME_NOUNS[1]}`

A username per account

The handle is keyed by the account subject — the same string balances, runs, and run_snapshots key on. The store mirrors the run-snapshot port/adapter shape: an in-memory default (so dev and tests need nothing) and a Supabase adapter behind one port, chosen by config. getUsername mints lazily on first need; the Supabase insert is guarded so two concurrent first-reads can't both win — a subject always resolves to one stable handle.

The port, and the durable adapter: read, else mint-with-guard, then read back.

server/utils/profile-store.ts · 115 lines
server/utils/profile-store.ts115 lines · TypeScript
⋯ 24 lines hidden (lines 1–24)
1/**
2 * The profile store — a player's durable, generated username (issue #117), keyed by the
3 * account SUBJECT (the Supabase user id, falling back to the device id) — the same string
4 * balances / runs / run_snapshots key on. The username is GENERATED, never typed: this
5 * store hands one out on first need and rerolls it on demand, and that's the whole surface
6 * (no setter takes a caller-supplied name — there's nothing to type a slur into).
7 *
8 * Two adapters behind one port, chosen by config — the repo's env-degradation idiom
9 * (cf. runSnapshotStore, balanceStore):
10 * - in-memory (default): no external dependency, so `npm run dev` and the unit tests
11 * need nothing. A username doesn't outlive the process, but the round-trip
12 * (get -> reroll -> get) is real.
13 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured. Reads/writes
14 * the `profiles` row via the service (secret) key, which bypasses RLS — the table has
15 * no public policy, so it is server-only.
16 *
17 * Carried across the anonymous→account upgrade for free: Supabase upgrades the anonymous
18 * user IN PLACE (same id), so a profile keyed by that id survives the email attach with no
19 * migration (#83's linking) — exactly how the balance and saved runs carry over.
20 */
21import { createClient, type SupabaseClient } from '@supabase/supabase-js'
22import { supabaseConfig } from './supabase'
23import { generateUsername } from './username'
24 
25export interface ProfileStore {
26 /** The subject's username, GENERATING and persisting one on first need (lazy), so a
27 * brand-new account has a handle the instant anything asks for it. Stable across
28 * calls until a reroll. */
29 getUsername(subject: string): Promise<string>
30 /** Replace the subject's username with a freshly generated one; returns the new name.
31 * The only way to change a username (issue #117) — generation again, never a typed
32 * value. */
33 rerollUsername(subject: string): Promise<string>
35 
36/** Dev/test default — keeps the get/reroll round-trip real without any external
37 * dependency. Not durable across process restarts; the Supabase adapter is the one that
⋯ 24 lines hidden (lines 38–61)
38 * actually preserves the handle. */
39export class InMemoryProfileStore implements ProfileStore {
40 private readonly usernames = new Map<string, string>()
41 
42 async getUsername(subject: string): Promise<string> {
43 const existing = this.usernames.get(subject)
44 if (existing) return existing
45 const username = generateUsername()
46 this.usernames.set(subject, username)
47 return username
48 }
49 
50 async rerollUsername(subject: string): Promise<string> {
51 const username = generateUsername()
52 this.usernames.set(subject, username)
53 return username
54 }
56 
57/** Reads/writes the `profiles` row via the service (secret) key, which bypasses RLS — the
58 * table has no public policy, so it is server-only. */
59export class SupabaseProfileStore implements ProfileStore {
60 constructor(private readonly client: SupabaseClient) {}
61 
62 async getUsername(subject: string): Promise<string> {
63 const existing = await this.read(subject)
64 if (existing) return existing
65 
66 // First need: mint one. Insert guarded by `ignoreDuplicates` so two concurrent
67 // first-reads can't both win — the loser's insert is a no-op and it re-reads the
68 // winner's name below, so a subject always resolves to ONE stable username.
69 const username = generateUsername()
70 const { error } = await this.client
71 .from('profiles')
72 .upsert({ subject, username }, { onConflict: 'subject', ignoreDuplicates: true })
73 if (error) throw new Error(`profiles insert failed: ${error.message}`)
74 
75 // Re-read the canonical row: our insert if it landed, or the concurrent winner's.
76 return (await this.read(subject)) ?? username
77 }
78 
79 async rerollUsername(subject: string): Promise<string> {
80 const username = generateUsername()
81 const { error } = await this.client
82 .from('profiles')
83 .upsert({ subject, username, updated_at: new Date().toISOString() }, { onConflict: 'subject' })
84 if (error) throw new Error(`profiles reroll failed: ${error.message}`)
85 return username
86 }
87 
⋯ 28 lines hidden (lines 88–115)
88 /** The subject's stored username, or null when they have no profile row yet. */
89 private async read(subject: string): Promise<string | null> {
90 const { data, error } = await this.client
91 .from('profiles')
92 .select('username')
93 .eq('subject', subject)
94 .maybeSingle()
95 if (error) throw new Error(`profiles read failed: ${error.message}`)
96 return (data?.username as string | null) ?? null
97 }
99 
100let cached: ProfileStore | null = null
101 
102/** The configured profile store (memoized — reuses one Supabase client). */
103export function profileStore(): ProfileStore {
104 if (cached) return cached
105 const cfg = supabaseConfig()
106 cached = cfg
107 ? new SupabaseProfileStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
108 : new InMemoryProfileStore()
109 return cached
111 
112/** Test seam: clear the memoized store so the next call re-reads config. */
113export function resetProfileStore(): void {
114 cached = null

The schema is migration 0009. The table is locked to the server, and the migration also closes the abuse surface for existing data: it nulls every legacy typed share_name, so no self-chosen free-text handle survives on a public page. Those shares stay live (the token is untouched) — they just read anonymous until the owner re-attributes, which now attaches a generated handle.

profiles table, RLS on (server-only), and the legacy-name null-out.

supabase/migrations/0009_profiles.sql · 35 lines
supabase/migrations/0009_profiles.sql35 lines · Transact-SQL
⋯ 16 lines hidden (lines 1–16)
1-- Per-account profiles (issue #117): a player's GENERATED, reroll-only username, keyed by
2-- the account subject (the Supabase user id, or the device id in the no-session fallback) —
3-- the same string balances / runs / run_snapshots key on. Promotes the handle from a
4-- per-share, user-typed field to a durable account property: one identity, generated on
5-- first need, that every share of that account surfaces.
6--
7-- Server-only, like run_snapshots: no public RLS policy, so it's reachable only through
8-- the service (secret) key from the server. Carried across the anonymous→account upgrade
9-- for free — Supabase upgrades the anonymous user IN PLACE (same id), so a profile keyed
10-- by that id needs no migration when the player attaches an email (#83's linking).
11--
12-- subject — the account key (PK). Non-unique display names are fine (issue #117's
13-- lean): this id is the real identity, so two identical usernames collide
14-- harmlessly.
15-- username — the generated "Adjective Noun" handle (server-curated wordlists).
16-- updated_at — last reroll, for ordering / audit.
17create table if not exists public.profiles (
18 subject text primary key,
19 username text not null,
20 updated_at timestamptz not null default now()
21);
22 
23-- Lock it to the server: RLS on with NO public policy means the anon / authenticated keys
24-- can't touch it; only the service (secret) key reaches it. Same stance as runs / balances /
25-- run_snapshots — without this, the public schema's default grants would leave it readable
26-- and writable by the anon key.
27alter table public.profiles enable row level security;
28 
29-- Close the self-chosen-handle abuse surface this issue resolves (run-share.post.ts:13).
30-- The old per-share `share_name` was untrusted, user-typed text riding onto a PUBLIC page;
31-- it is now server-stamped with the account's GENERATED username (or null = anonymous),
32-- never typed. Null out every existing typed name so no legacy free-text handle survives
33-- on a public page: those shares stay LIVE (the token is untouched) but read as anonymous
34-- until the owner re-attributes, which now attaches their generated username.
35update public.run_snapshots set share_name = null where share_name is not null;

The share path: no typed input, and reroll follows everywhere

The share endpoint no longer reads a name from the client. Attribution is opt-in (attribute, default off — preserving #88's anonymous-by-default stance); when on, the name attached is the account's generated handle, resolved server-side. Any name field in the body is ignored outright, and the old length-cap + hard-block-detector call on a typed name are gone — there's nothing untrusted to check.

attribute opt-in → the account handle (or null); the client name is never read.

server/api/run-share.post.ts · 42 lines
server/api/run-share.post.ts42 lines · TypeScript
⋯ 21 lines hidden (lines 1–21)
1/**
2 * POST /api/run-share — make one of the current subject's saved runs public (issue #88),
3 * or toggle whether it carries the player's name. Mints a stable, unguessable share token
4 * the public page (/r/:token) is reached by, and returns the live share state. Re-sharing
5 * an already-public run keeps the same token, so the URL stays stable.
6 *
7 * Ownership is enforced server-side: the store scopes by subject, so a run the subject
8 * doesn't own reads as absent (404) and never gets shared on their behalf.
9 *
10 * Attribution is opt-in (`attribute`, default off → anonymous), preserving #88's
11 * privacy-first stance. When ON, the name attached is the account's GENERATED username
12 * (issue #117), resolved server-side — NEVER client-typed. That closes the self-chosen-
13 * handle abuse follow-up flagged here before (run-share.post.ts:13): with no free-text
14 * input there is nothing to type a slur / impersonation / harassing handle into, so the
15 * hard-block detector call on a typed name is no longer needed.
16 */
17import { runSnapshotStore } from '~/server/utils/run-snapshot-store'
18import { profileStore } from '~/server/utils/profile-store'
19import { currentSubject } from '~/server/utils/auth'
20import { isUuid } from '~/server/utils/uuid'
21 
22export default defineEventHandler(async (event) => {
23 if (getMethod(event) !== 'POST') {
24 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
25 }
26 const body = (await readBody(event)) as { runId?: unknown; attribute?: unknown } | null
27 // The run id is a uuid; isUuid is the gate (no length-coercion that would obscure intent).
28 const runId = typeof body?.runId === 'string' ? body.runId : ''
29 if (!isUuid(runId)) {
30 throw createError({ statusCode: 404, statusMessage: 'Not Found' })
31 }
32 
33 const subject = await currentSubject(event)
34 // Opt-in → the account's generated username; otherwise null (anonymous). The name is
35 // never trusted from the client — any `name` field in the body is ignored outright.
36 const name = body?.attribute === true ? await profileStore().getUsername(subject) : null
37 const state = await runSnapshotStore().shareRun(subject, runId, name)
38 if (!state) {
39 throw createError({ statusCode: 404, statusMessage: 'Not Found' })
40 }
41 return state
42})

Because the handle is now an account property, changing it has to follow everywhere it appears in public. syncAttributedShares re-stamps a player's already-attributed shares — and leaves anonymous shares anonymous (a reroll never opts a private share into attribution). The in-memory and Supabase adapters express the same rule; the durable one is a single owner-scoped UPDATE with a share_name IS NOT NULL filter.

The same invariant, two adapters: only this subject's attributed shares move.

server/utils/run-snapshot-store.ts · 420 lines
server/utils/run-snapshot-store.ts420 lines · TypeScript
⋯ 183 lines hidden (lines 1–183)
1/**
2 * The run-snapshot store — where a finished run becomes a durable, owned record.
3 * POST /api/run-save writes the snapshot here keyed by the run id; the "your runs"
4 * routes (GET /api/runs, GET /api/runs/:id) read it back, scoped to the account
5 * subject so a player only ever sees their own runs.
6 *
7 * Two adapters behind one port, chosen by config — the repo's env-degradation idiom
8 * (cf. runStore, balanceStore):
9 * - in-memory (default): no external dependency, so `npm run dev` and the unit
10 * tests need nothing. History doesn't outlive the process, but the round-trip is
11 * real, which is what the save -> list -> load test exercises.
12 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured. Writes
13 * the `run_snapshots` row (and stamps the owner onto the billing `runs` row) via
14 * the service (secret) key, which bypasses RLS — the table has no public policy,
15 * so it is server-only.
16 *
17 * Ownership is by SUBJECT (the Supabase user id, falling back to the device id) — the
18 * same string balances/runs key on. Reads filter by it, so a non-owned run id reads
19 * as absent (the route turns that into a 404, never leaking another subject's run).
20 */
21import { randomUUID } from 'node:crypto'
22import { createClient, type SupabaseClient } from '@supabase/supabase-js'
23import { supabaseConfig } from './supabase'
24import { migrateSnapshot } from '~/utils/run-snapshot'
25import type { RunSnapshot, RunSummary, PublicRun, ShareState, RemixCredit } from '~/utils/run-snapshot'
26 
27/** Save-time extras the snapshot blob itself doesn't carry. */
28export interface SaveRunOptions {
29 /** When this run is a replay (issue #89), the SOURCE run's share token — resolved
30 * server-side to the parent's stable run id and stamped as the lineage pointer. The
31 * client only ever holds the public token (the run id is never exposed to it). */
32 derivedFromToken?: string | null
34 
35export interface RunSnapshotStore {
36 /** Persist (or overwrite) the finished run's snapshot, owned by `subject`. */
37 saveRun(subject: string, snapshot: RunSnapshot, opts?: SaveRunOptions): Promise<void>
38 /** A subject's saved runs, newest first (the list projection). */
39 listRuns(subject: string): Promise<RunSummary[]>
40 /** One saved run by id, only if `subject` owns it; null otherwise. */
41 getRun(subject: string, runId: string): Promise<RunSnapshot | null>
42 /**
43 * Make an owned run public (issue #88): mint a share token if it has none (reuse it
44 * if already shared, so the URL is stable), set the attribution name, and return the
45 * live share state. Null when `subject` doesn't own the run (reads as absent).
46 *
47 * `name` is the account's GENERATED username when the owner opts into attribution, or
48 * null for anonymous (issue #117). It is server-resolved — never client-typed — so the
49 * old self-chosen-handle abuse surface is gone; the store just persists the string it's
50 * handed.
51 */
52 shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null>
53 /**
54 * Re-stamp the attribution name on every one of `subject`'s ALREADY-attributed shares
55 * (issue #117). Called after a reroll so a player's public handle stays consistent
56 * across all their shared runs — the username is an account property, so changing it
57 * follows everywhere it appears. Anonymous shares (name null) are left untouched.
58 */
59 syncAttributedShares(subject: string, username: string): Promise<void>
60 /** Revoke an owned run's share (clear token + name). Idempotent; a no-op otherwise. */
61 unshareRun(subject: string, runId: string): Promise<void>
62 /** The owner's view of a run's share status; null when `subject` doesn't own it. */
63 getShareState(subject: string, runId: string): Promise<ShareState | null>
64 /** Resolve a public share token to its sanitized projection; null when unknown. */
65 getPublicRun(token: string): Promise<PublicRun | null>
67 
68/** Project a snapshot down to the list row. */
69function toSummary(snapshot: RunSnapshot, createdAt: string): RunSummary {
70 return {
71 runId: snapshot.runId,
72 status: snapshot.status,
73 title: snapshot.objective.title,
74 progress: snapshot.objectiveProgress,
75 createdAt
76 }
78 
79/**
80 * Project a stored snapshot down to its PUBLIC, shareable form. The snapshot blob is
81 * already identity-free (the owner lives only in the row's `user_id` column, never in
82 * the data), so the sanitization here is deliberate-and-explicit: blank the run id (the
83 * share token is the only handle a public viewer gets) and surface only the opt-in
84 * display name. This is the boundary the public endpoint serves — never the raw row.
85 */
86export function toPublicRun(snapshot: RunSnapshot, name: string | null, remixedFrom: RemixCredit | null = null): PublicRun {
87 const trimmed = name?.trim()
88 // Normalize the stored blob to the current shape (an older snapshot may predate a
89 // field, e.g. v2's peakProgress) before it crosses to the public viewer.
90 const run = migrateSnapshot(snapshot)
91 return {
92 run: { ...run, runId: '' },
93 attribution: trimmed ? trimmed : null,
94 remixedFrom
95 }
97 
98/**
99 * Build the one-hop "remixed from" credit (issue #89) from the PARENT run's live fields.
100 * Only public, opt-in data crosses: the parent's display name (anonymous if blank/unset),
101 * its objective title, and its current share token (for an optional backlink, null when
102 * the parent is no longer shared). Never the parent's run id, owner, or device.
103 */
104function toRemixCredit(
105 parent: { title: string; shareName: string | null; shareToken: string | null } | null
106): RemixCredit | null {
107 if (!parent) return null
108 const name = parent.shareName?.trim()
109 return {
110 attribution: name ? name : null,
111 objectiveTitle: parent.title,
112 sourceToken: parent.shareToken ?? null
113 }
115 
116/** Dev/test default — keeps the round-trip real (save -> list -> load) without any
117 * external dependency. Not durable across process restarts; the Supabase adapter is
118 * the one that actually preserves history. */
119export class InMemoryRunSnapshotStore implements RunSnapshotStore {
120 private readonly runs: Array<{
121 subject: string
122 snapshot: RunSnapshot
123 createdAt: string
124 shareToken: string | null
125 shareName: string | null
126 derivedFromRunId: string | null
127 }> = []
128 
129 /** Resolve a source share token to the parent's stable run id (issue #89), guarding
130 * self-derivation. Returns null for an unknown token or no token. */
131 private resolveDerivedRunId(token: string | null | undefined, selfRunId: string): string | null {
132 if (!token) return null
133 const parent = this.runs.find((r) => r.shareToken === token)
134 return parent && parent.snapshot.runId !== selfRunId ? parent.snapshot.runId : null
135 }
136 
137 async saveRun(subject: string, snapshot: RunSnapshot, opts?: SaveRunOptions): Promise<void> {
138 const createdAt = new Date().toISOString()
139 const existing = this.runs.find((r) => r.snapshot.runId === snapshot.runId)
140 if (existing) {
141 // Snapshots are immutable, but the save fires again once the epilogue
142 // lands — overwrite in place (keep the original position/createdAt, and any
143 // share already minted for this run).
144 existing.subject = subject
145 existing.snapshot = snapshot
146 // Stamp the pointer only on a SUCCESSFUL resolve, and never overwrite one
147 // already set with null: it's stable once stamped, so a later un-share (the
148 // token stops resolving) leaves the lineage intact — only the walked credit
149 // goes anonymous (see getPublicRun).
150 const resolved = this.resolveDerivedRunId(opts?.derivedFromToken, snapshot.runId)
151 if (resolved) existing.derivedFromRunId = resolved
152 return
153 }
154 this.runs.push({
155 subject,
156 snapshot,
157 createdAt,
158 shareToken: null,
159 shareName: null,
160 derivedFromRunId: this.resolveDerivedRunId(opts?.derivedFromToken, snapshot.runId)
161 })
162 }
163 
164 async listRuns(subject: string): Promise<RunSummary[]> {
165 return this.runs
166 .filter((r) => r.subject === subject)
167 .map((r) => toSummary(r.snapshot, r.createdAt))
168 .reverse()
169 }
170 
171 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
172 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
173 return found ? migrateSnapshot(found.snapshot) : null
174 }
175 
176 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
177 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
178 if (!found) return null
179 found.shareToken = found.shareToken ?? randomUUID()
180 found.shareName = name
181 return { token: found.shareToken, name: found.shareName }
182 }
183 
184 async syncAttributedShares(subject: string, username: string): Promise<void> {
185 for (const run of this.runs) {
186 // Only the subject's already-attributed shares (a name set); anonymous ones
187 // (null) stay anonymous — a reroll never opts a private share into attribution.
188 if (run.subject === subject && run.shareName !== null) run.shareName = username
189 }
190 }
⋯ 163 lines hidden (lines 191–353)
191 
192 async unshareRun(subject: string, runId: string): Promise<void> {
193 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
194 if (!found) return
195 found.shareToken = null
196 found.shareName = null
197 }
198 
199 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
200 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
201 return found ? { token: found.shareToken, name: found.shareName } : null
202 }
203 
204 async getPublicRun(token: string): Promise<PublicRun | null> {
205 const found = this.runs.find((r) => r.shareToken === token)
206 if (!found) return null
207 // Walk one hop to the parent's LIVE row for the "remixed from" credit (issue #89),
208 // so the source's current opt-in (name / still-shared) is what surfaces.
209 const parent = found.derivedFromRunId
210 ? this.runs.find((r) => r.snapshot.runId === found.derivedFromRunId) ?? null
211 : null
212 const remixedFrom = toRemixCredit(
213 parent
214 ? { title: parent.snapshot.objective.title, shareName: parent.shareName, shareToken: parent.shareToken }
215 : null
216 )
217 return toPublicRun(found.snapshot, found.shareName, remixedFrom)
218 }
220 
221/** Records each saved run as a `run_snapshots` row via the service (secret) key,
222 * which bypasses RLS — the table has no public policy, so it is server-only. */
223export class SupabaseRunSnapshotStore implements RunSnapshotStore {
224 constructor(private readonly client: SupabaseClient) {}
225 
226 async saveRun(subject: string, snapshot: RunSnapshot, opts?: SaveRunOptions): Promise<void> {
227 // Stamp the owner onto the billing run row too (it carries only device_id
228 // today) so a run's ownership is legible from either table.
229 const owned = await this.client.from('runs').update({ user_id: subject }).eq('id', snapshot.runId)
230 if (owned.error) throw new Error(`runs owner update failed: ${owned.error.message}`)
231 
232 const row: Record<string, unknown> = {
233 run_id: snapshot.runId,
234 user_id: subject,
235 version: snapshot.version,
236 status: snapshot.status,
237 title: snapshot.objective.title,
238 progress: snapshot.objectiveProgress,
239 data: snapshot
240 }
241 // Stamp the lineage pointer ONLY for a replay (issue #89): resolve the source's
242 // public share token to the parent's STABLE run id (a token can be revoked / re-
243 // minted; the id can't), guarding self-derivation. The column is set only on a
244 // SUCCESSFUL resolve and is NEVER written as null — so an ordinary run's upsert is
245 // byte-identical to before, and a replay whose source un-shares before the run ends
246 // keeps the pointer an earlier save stamped (the credit then walks to an anonymized
247 // parent). It's a stable pointer, never overwritten with null.
248 if (opts?.derivedFromToken) {
249 const parentId = await this.resolveDerivedRunId(opts.derivedFromToken)
250 if (parentId && parentId !== snapshot.runId) row.derived_from_run_id = parentId
251 }
252 
253 // Upsert on the run-id PK so the second (post-epilogue) save overwrites the
254 // first rather than erroring — the snapshot is immutable, the write is not.
255 const { error } = await this.client.from('run_snapshots').upsert(row)
256 if (error) throw new Error(`run_snapshots upsert failed: ${error.message}`)
257 }
258 
259 /** Resolve a source share token to the parent's stable run id (issue #89); null when
260 * the token is unknown / revoked. */
261 private async resolveDerivedRunId(token: string): Promise<string | null> {
262 const { data, error } = await this.client
263 .from('run_snapshots')
264 .select('run_id')
265 .eq('share_token', token)
266 .maybeSingle()
267 if (error) throw new Error(`run_snapshots lineage resolve failed: ${error.message}`)
268 return (data?.run_id as string | null) ?? null
269 }
270 
271 /** Walk one hop to the parent's LIVE row for the "remixed from" credit (issue #89) —
272 * its current title, opt-in name, and share token. Null when there's no parent (not
273 * a replay) or the parent has since been deleted. */
274 private async resolveRemixCredit(parentRunId: string | null): Promise<RemixCredit | null> {
275 if (!parentRunId) return null
276 const { data, error } = await this.client
277 .from('run_snapshots')
278 .select('title, share_name, share_token')
279 .eq('run_id', parentRunId)
280 .maybeSingle()
281 if (error) throw new Error(`run_snapshots remix credit failed: ${error.message}`)
282 if (!data) return null
283 return toRemixCredit({
284 title: data.title as string,
285 shareName: (data.share_name as string | null) ?? null,
286 shareToken: (data.share_token as string | null) ?? null
287 })
288 }
289 
290 async listRuns(subject: string): Promise<RunSummary[]> {
291 const { data, error } = await this.client
292 .from('run_snapshots')
293 .select('run_id, status, title, progress, created_at')
294 .eq('user_id', subject)
295 .order('created_at', { ascending: false })
296 if (error) throw new Error(`run_snapshots list failed: ${error.message}`)
297 return ((data ?? []) as Array<{
298 run_id: string
299 status: RunSnapshot['status']
300 title: string
301 progress: number
302 created_at: string
303 }>).map((row) => ({
304 runId: row.run_id,
305 status: row.status,
306 title: row.title,
307 progress: row.progress,
308 createdAt: row.created_at
309 }))
310 }
311 
312 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
313 const { data, error } = await this.client
314 .from('run_snapshots')
315 .select('data')
316 .eq('run_id', runId)
317 .eq('user_id', subject)
318 .maybeSingle()
319 if (error) throw new Error(`run_snapshots get failed: ${error.message}`)
320 // Normalize an older stored blob (e.g. a v1 row predating v2's peakProgress) so a
321 // saved-run view always reads a current-shape snapshot.
322 return data?.data ? migrateSnapshot(data.data as RunSnapshot) : null
323 }
324 
325 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
326 // Mint a token ONLY if the owned run has none yet, in one row-atomic UPDATE (the
327 // `share_token IS NULL` guard). Two concurrent shares can't both mint — the second
328 // sees the first's token and matches no row — so re-sharing keeps one stable URL
329 // and never trips the unique constraint. (A wasted uuid when already shared is
330 // harmless: the guard excludes the row, so it's never written.)
331 const minted = await this.client
332 .from('run_snapshots')
333 .update({ share_token: randomUUID() })
334 .eq('run_id', runId)
335 .eq('user_id', subject)
336 .is('share_token', null)
337 if (minted.error) throw new Error(`run_snapshots share mint failed: ${minted.error.message}`)
338 
339 // Set the name and read back the live token (the freshly-minted one, or a token
340 // from a prior share). Scoped to the owner, so a run the subject doesn't own
341 // matches no row and reads as absent (null) — never shared on their behalf.
342 const { data, error } = await this.client
343 .from('run_snapshots')
344 .update({ share_name: name })
345 .eq('run_id', runId)
346 .eq('user_id', subject)
347 .select('share_token, share_name')
348 .maybeSingle()
349 if (error) throw new Error(`run_snapshots share failed: ${error.message}`)
350 if (!data) return null
351 return { token: data.share_token as string, name: (data.share_name as string | null) ?? null }
352 }
353 
354 async syncAttributedShares(subject: string, username: string): Promise<void> {
355 // One row-set UPDATE: re-stamp every attributed share this subject owns. The
356 // `share_name IS NOT NULL` filter leaves anonymous shares untouched, so a reroll
357 // never converts a private share into an attributed one.
358 const { error } = await this.client
359 .from('run_snapshots')
360 .update({ share_name: username })
361 .eq('user_id', subject)
362 .not('share_name', 'is', null)
363 if (error) throw new Error(`run_snapshots attribution sync failed: ${error.message}`)
364 }
⋯ 56 lines hidden (lines 365–420)
365 
366 async unshareRun(subject: string, runId: string): Promise<void> {
367 const { error } = await this.client
368 .from('run_snapshots')
369 .update({ share_token: null, share_name: null })
370 .eq('run_id', runId)
371 .eq('user_id', subject)
372 if (error) throw new Error(`run_snapshots unshare failed: ${error.message}`)
373 }
374 
375 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
376 const { data, error } = await this.client
377 .from('run_snapshots')
378 .select('share_token, share_name')
379 .eq('run_id', runId)
380 .eq('user_id', subject)
381 .maybeSingle()
382 if (error) throw new Error(`run_snapshots share state failed: ${error.message}`)
383 if (!data) return null
384 return {
385 token: (data.share_token as string | null) ?? null,
386 name: (data.share_name as string | null) ?? null
387 }
388 }
389 
390 async getPublicRun(token: string): Promise<PublicRun | null> {
391 // Looked up by the share token ALONE — no owner filter — because the public page
392 // has no subject. The token is the capability; an unknown/revoked token misses.
393 const { data, error } = await this.client
394 .from('run_snapshots')
395 .select('data, share_name, derived_from_run_id')
396 .eq('share_token', token)
397 .maybeSingle()
398 if (error) throw new Error(`run_snapshots public get failed: ${error.message}`)
399 if (!data) return null
400 const remixedFrom = await this.resolveRemixCredit((data.derived_from_run_id as string | null) ?? null)
401 return toPublicRun(data.data as RunSnapshot, (data.share_name as string | null) ?? null, remixedFrom)
402 }
404 
405let cached: RunSnapshotStore | null = null
406 
407/** The configured run-snapshot store (memoized — reuses one Supabase client). */
408export function runSnapshotStore(): RunSnapshotStore {
409 if (cached) return cached
410 const cfg = supabaseConfig()
411 cached = cfg
412 ? new SupabaseRunSnapshotStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
413 : new InMemoryRunSnapshotStore()
414 return cached
416 
417/** Test seam: clear the memoized store so the next call re-reads config. */
418export function resetRunSnapshotStore(): void {
419 cached = null

The reroll endpoint ties it together: it commits the new handle to the profile first (the source of truth, so the returned name always matches the account), then re-stamps the shares best-effort. A soft per-subject rate limit guards against hammering.

Reroll: rate-limit, commit the handle, then re-stamp shares (non-fatal).

server/api/profile/reroll.post.ts · 36 lines
server/api/profile/reroll.post.ts36 lines · TypeScript
⋯ 18 lines hidden (lines 1–18)
1/**
2 * POST /api/profile/reroll — generate a NEW username for the current subject (issue #117).
3 * Reroll is the ONLY way to change a handle (there is no setter that takes a typed value),
4 * which is what keeps it safe: a player can shuffle to a different generated name, never
5 * type one. Subject-scoped, so a player only ever rerolls their own.
6 *
7 * The new handle is also re-stamped onto all of the subject's already-attributed shares, so
8 * the account-wide username stays consistent everywhere it appears in public.
9 *
10 * Reroll is cheap, but a soft per-subject rate limit keeps it from being hammered (#117).
11 * The window is in-process (best-effort across serverless instances) — a guard rail for a
12 * harmless action, not a security boundary.
13 */
14import { currentSubject } from '~/server/utils/auth'
15import { profileStore } from '~/server/utils/profile-store'
16import { runSnapshotStore } from '~/server/utils/run-snapshot-store'
17import { withinRerollLimit } from '~/server/utils/reroll-limit'
18 
19export default defineEventHandler(async (event) => {
20 const subject = await currentSubject(event)
21 if (!withinRerollLimit(subject, Date.now())) {
22 throw createError({ statusCode: 429, statusMessage: 'Too many rerolls just now — give it a moment.' })
23 }
24 // The profile row is the source of truth for the handle; commit it first so the
25 // returned username always matches the persisted account.
26 const username = await profileStore().rerollUsername(subject)
27 // Re-stamp the player's attributed shares to match. Best-effort: a failure here must
28 // not 500 a successful reroll (the shares are a denormalized copy and self-heal on the
29 // next reroll), so the returned handle stays consistent with the profile.
30 try {
31 await runSnapshotStore().syncAttributedShares(subject, username)
32 } catch (error) {
33 console.error('Failed to sync attributed shares after reroll:', error)
34 }
35 return { username }
36})

The surfaces: ShareControls, the profile, the settings hub

In ShareControls, the free-text input is gone. In its place: an opt-in to attach the handle, the handle shown read-only, and a reroll. The create call posts { attribute } — no name. The attribution note tracks the live account handle, so a reroll elsewhere is reflected rather than showing a stale snapshot.

A toggle and a shown-not-typed handle; create posts the opt-in flag only.

components/ShareControls.vue · 167 lines
components/ShareControls.vue167 lines · Vue
⋯ 36 lines hidden (lines 1–36)
1<template>
2 <!-- The owner's share affordance for one saved run (issue #88): make it public (with an
3 opt-in handle, off by default), see the live link, send it, or revoke it. Reads its
4 current state from the server so it shows the right face after a reload. The handle,
5 when attached, is the account's GENERATED username (issue #117) — never typed. -->
6 <div data-testid="share-controls" class="border ew-line rounded-sm p-4">
7 <div v-if="loading" class="ew-faint italic text-sm">Checking share status…</div>
8 
9 <div v-else-if="unavailable" class="ew-faint text-sm" data-testid="share-unavailable">
10 Sharing isn't available for this run yet.
11 </div>
12 
13 <!-- Public: the link + the ways to send it + revoke -->
14 <template v-else-if="shared">
15 <div class="flex items-center gap-2 flex-wrap">
16 <span class="ew-label ew-label--rule mb-0"><span aria-hidden="true">🌍</span> This run is public</span>
17 </div>
18 <p class="ew-muted text-sm mt-2">Anyone with this link can view it — no account needed.</p>
19 <code data-testid="share-url" class="block ew-mono text-xs ew-muted bg-transparent border ew-line rounded-sm px-2.5 py-2 mt-2 break-all">{{ publicUrl }}</code>
20 <div class="mt-3">
21 <ShareLinks :url="publicUrl" :text="shareText" />
22 </div>
23 <p class="ew-faint text-xs mt-3" data-testid="share-attribution">{{ attributionNote }}</p>
24 <button type="button" class="ew-btn text-sm mt-3" :disabled="busy" data-testid="share-stop" @click="stopSharing">
25 {{ busy ? 'Stopping…' : 'Stop sharing' }}
26 </button>
27 </template>
28 
29 <!-- Private: the opt-in to make it public -->
30 <template v-else>
31 <template v-if="!expanded">
32 <span class="ew-label ew-label--rule mb-0">Share this run</span>
33 <p class="ew-muted text-sm mt-2">Post your rewritten history — the verdict, the epilogue, the timeline.</p>
34 <button type="button" class="ew-btn ew-btn--primary text-sm mt-3" data-testid="share-open" @click="expanded = true">
35 ↗ Share this run
36 </button>
37 </template>
38 <template v-else>
39 <span class="ew-label ew-label--rule mb-0">Make this run public</span>
40 <p class="ew-muted text-sm mt-2">A public, read-only page at an unguessable link. Anonymous unless you attach your handle.</p>
41 <label class="flex items-center gap-2 text-sm mt-3 cursor-pointer">
42 <input v-model="attribute" type="checkbox" data-testid="share-name-toggle" />
43 <span>Attach my handle</span>
44 </label>
45 <!-- The handle is generated, shown read-only (never an input). Reroll for another. -->
46 <div v-if="attribute" data-testid="share-handle" class="flex items-center gap-2 mt-2 flex-wrap">
47 <span class="ew-serif ew-fg font-semibold">{{ username || 'Conjuring a name…' }}</span>
48 <button type="button" class="ew-tap ew-hover-fg text-xs underline disabled:opacity-50 disabled:no-underline"
49 :disabled="rerolling || !username" data-testid="share-handle-reroll" @click="reroll">{{ rerolling ? 'rerolling…' : 'reroll' }}</button>
50 </div>
51 <div class="flex items-center gap-2 mt-3 flex-wrap">
52 <button type="button" class="ew-btn ew-btn--primary text-sm" :disabled="busy" data-testid="share-create" @click="makePublic">
53 {{ busy ? 'Creating…' : 'Create public link' }}
54 </button>
55 <button type="button" class="ew-btn text-sm" :disabled="busy" data-testid="share-cancel" @click="expanded = false">
56 Cancel
57 </button>
58 </div>
⋯ 64 lines hidden (lines 59–122)
59 </template>
60 </template>
61 
62 <p v-if="error" class="ew-bad text-sm mt-3" data-testid="share-error">{{ error }}</p>
63 </div>
64</template>
65 
66<script setup lang="ts">
67/**
68 * ShareControls — the owner's share state machine for a saved run. It fetches the run's
69 * current share state on mount (so a reload shows "public, here's the link" vs "share this
70 * run" correctly), toggles public/private against /api/run-share, and offers an opt-in
71 * handle at share time (off by default — anonymous). The handle is the account's GENERATED
72 * username (issue #117): there is no text input — the player attaches it or rerolls it, but
73 * never types it. Ownership is enforced server-side; this only ever acts on the account's
74 * own runs.
75 */
76import { ref, computed, onMounted } from 'vue'
77import type { ShareState } from '~/utils/run-snapshot'
78import { useGameStore } from '~/stores/game'
79 
80const props = defineProps<{
81 runId: string
82 /** The prebuilt brag line the parent composes from the run (objective + verdict). */
83 shareText: string
84}>()
85 
86const gameStore = useGameStore()
87const username = computed(() => gameStore.username)
88 
89const state = ref<ShareState | null>(null)
90const loading = ref(true)
91const unavailable = ref(false)
92const busy = ref(false)
93const rerolling = ref(false)
94const error = ref('')
95const expanded = ref(false)
96// Whether to attach the account handle to this share (off → anonymous, #88's default).
97const attribute = ref(false)
98 
99const shared = computed(() => !!state.value?.token)
100const origin = computed(() => useRequestURL().origin)
101const publicUrl = computed(() => (state.value?.token ? `${origin.value}/r/${state.value.token}` : ''))
102// When attributed, show the LIVE account handle (it tracks a reroll), not the mount-time
103// snapshot; fall back to the stored name if the handle hasn't loaded yet.
104const attributionNote = computed(() =>
105 state.value?.name ? `Shared as "${username.value || state.value.name}"` : 'Shared anonymously — no handle attached.'
107 
108onMounted(async () => {
109 // Have the handle ready in case the player opts to attach it.
110 void gameStore.loadUsername()
111 try {
112 state.value = await $fetch<ShareState>('/api/run-share', { query: { runId: props.runId } })
113 // A stored name means this run was shared WITH attribution — reflect that.
114 if (state.value?.name) attribute.value = true
115 } catch {
116 // The run isn't owned/saved yet — nothing to share against.
117 unavailable.value = true
118 } finally {
119 loading.value = false
120 }
121})
122 
123async function makePublic() {
124 busy.value = true
125 error.value = ''
126 try {
127 state.value = await $fetch<ShareState>('/api/run-share', {
128 method: 'POST',
129 body: { runId: props.runId, attribute: attribute.value }
130 })
131 expanded.value = false
⋯ 36 lines hidden (lines 132–167)
132 } catch (e) {
133 error.value = errorMessage(e) ?? 'Could not create the link. Try again.'
134 } finally {
135 busy.value = false
136 }
138 
139async function reroll() {
140 rerolling.value = true
141 try {
142 await gameStore.rerollUsername()
143 } finally {
144 rerolling.value = false
145 }
147 
148async function stopSharing() {
149 busy.value = true
150 error.value = ''
151 try {
152 await $fetch('/api/run-share', { method: 'DELETE', query: { runId: props.runId } })
153 state.value = { token: null, name: null }
154 } catch {
155 error.value = 'Could not stop sharing. Try again.'
156 } finally {
157 busy.value = false
158 }
160 
161/** Pull the server's statusMessage off an H3/ofetch error, if present. */
162function errorMessage(e: unknown): string | null {
163 const data = (e as { data?: { statusMessage?: unknown; message?: unknown } })?.data
164 const msg = data?.statusMessage ?? data?.message
165 return typeof msg === 'string' && msg.trim() ? msg : null
167</script>

AccountUsername is the reusable handle card (used by /profile and /settings): it reads the handle from the store — the single source of truth, so a reroll updates it everywhere — and never gates reroll on a loaded handle, so a failed load can't leave a dead button. A failed reroll surfaces an inline error, and the value carries aria-live so the new name is announced. /profile shows the handle + reroll + the player's runs (via an extracted RunsList); /settings is the account hub (handle, sign-in, balance/buy, notification-prefs home, tour). The RunsBalance popover now links into both.

Shown, never typed; reroll always enabled; failure + a11y handled.

components/AccountUsername.vue · 51 lines
components/AccountUsername.vue51 lines · Vue
⋯ 3 lines hidden (lines 1–3)
1<template>
2 <!-- The account's generated, reroll-only handle (issue #117). Shown on the profile and
3 settings pages: the player can shuffle to a different generated name, but never type
4 one — which is what keeps it safe by construction. -->
5 <div data-testid="account-username">
6 <p class="ew-label">Your handle</p>
7 <div class="flex items-center gap-3 mt-1.5 flex-wrap">
8 <!-- aria-live so a reroll's new name is announced; the button is never gated on a
9 loaded handle, so a failed load can't leave a permanently dead control. -->
10 <span v-if="username" data-testid="username-value" aria-live="polite"
11 class="ew-serif ew-fg text-2xl font-bold leading-none">{{ username }}</span>
12 <span v-else data-testid="username-loading" class="ew-faint italic">Conjuring a name…</span>
13 <button type="button" class="ew-btn text-sm" :disabled="busy" data-testid="username-reroll" @click="reroll">
14 <span aria-hidden="true"></span> {{ busy ? 'Rerolling…' : 'Reroll' }}
15 </button>
16 </div>
17 <p v-if="error" data-testid="username-error" class="ew-bad text-xs mt-2">Couldn't reroll just now — try again.</p>
18 <p class="ew-faint text-xs mt-2 leading-relaxed max-w-md">
19 Generated, not typed — the only name that appears on runs you choose to share. Reroll
20 for a different one anytime.
21 </p>
22 </div>
23</template>
⋯ 16 lines hidden (lines 24–39)
24 
25<script setup lang="ts">
26/**
27 * AccountUsername — the username card + reroll affordance, reused by /profile and
28 * /settings. Reads the handle from the game store (the single source of truth, so a reroll
29 * here updates it everywhere it's shown) and mints one lazily on mount.
30 */
31import { computed, onMounted, ref } from 'vue'
32import { useGameStore } from '~/stores/game'
33 
34const gameStore = useGameStore()
35const username = computed(() => gameStore.username)
36const busy = ref(false)
37const error = ref(false)
38 
39onMounted(() => gameStore.loadUsername())
40 
41async function reroll() {
42 busy.value = true
43 error.value = false
44 try {
45 // null = the reroll failed; surface it rather than silently doing nothing.
46 if (!(await gameStore.rerollUsername())) error.value = true
47 } finally {
48 busy.value = false
49 }
⋯ 2 lines hidden (lines 50–51)
51</script>

Verification

The whole suite is green — 1037 tests across the node and Nuxt projects — and nuxt typecheck is clean. Tests cover the acceptance directly: generation always yields a clean "Adjective Noun" from the lists, the share path uses the account handle with no typed input (component + store + source-contract tests), reroll, attribution sync, and the anonymous→account carry-through. A representative example pins that the curated lists are clean by construction — every pairing passes the screen, so generation is never biased into the fallback.

Exhaustive: every curated adjective × noun pairing is wholesome.

tests/unit/server/utils/username.spec.ts · 80 lines
tests/unit/server/utils/username.spec.ts80 lines · TypeScript
⋯ 51 lines hidden (lines 1–51)
1import { describe, it, expect } from 'vitest'
2import {
3 USERNAME_ADJECTIVES,
4 USERNAME_NOUNS,
5 generateUsername,
6 isWholesomeUsername
7} from '~/server/utils/username'
8 
9/**
10 * Generated, reroll-only usernames (issue #117). What matters: generation always yields a
11 * clean "Adjective Noun" handle drawn from the curated lists, the combination screen
12 * actually screens, and the curated lists are clean BY CONSTRUCTION so generation is never
13 * biased into the fallback. There is no typed-name path anywhere — the safety is that a
14 * handle can only be generated, never authored.
15 */
16describe('username generation (issue #117)', () => {
17 it('produces an "Adjective Noun" pair, both words from the curated lists', () => {
18 const adjectives = new Set<string>(USERNAME_ADJECTIVES)
19 const nouns = new Set<string>(USERNAME_NOUNS)
20 for (let i = 0; i < 500; i++) {
21 const name = generateUsername()
22 const [adjective, noun, ...rest] = name.split(' ')
23 expect(rest).toHaveLength(0)
24 expect(adjectives.has(adjective)).toBe(true)
25 expect(nouns.has(noun)).toBe(true)
26 }
27 })
28 
29 it('only ever emits a wholesome combination', () => {
30 for (let i = 0; i < 500; i++) {
31 expect(isWholesomeUsername(generateUsername())).toBe(true)
32 }
33 })
34 
35 it('is deterministic under an injected RNG (so a test can pin the draw)', () => {
36 // rng() === 0 → index 0 of each list: the first adjective + the first noun.
37 expect(generateUsername(() => 0)).toBe(`${USERNAME_ADJECTIVES[0]} ${USERNAME_NOUNS[0]}`)
38 })
39 
40 it('draws the adjective then the noun, in that order (catches a pick swap)', () => {
41 // Mid-bucket fractions select distinct indices from each list — adjective[1], noun[2].
42 // A swap (noun drawn into the first slot) would yield a different, detectable string.
43 const seq = [1.5 / USERNAME_ADJECTIVES.length, 2.5 / USERNAME_NOUNS.length]
44 let i = 0
45 const rng = () => seq[i++]
46 expect(generateUsername(rng)).toBe(`${USERNAME_ADJECTIVES[1]} ${USERNAME_NOUNS[2]}`)
47 })
48 
49 // The curated lists must be clean BY CONSTRUCTION: if any list pairing were screened
50 // out, generation would waste draws and could fall through to the fixed fallback —
51 // i.e. the wordlists would silently bias. Assert EVERY combination passes the screen.
52 it('every curated adjective × noun pairing is wholesome', () => {
53 for (const adjective of USERNAME_ADJECTIVES) {
54 for (const noun of USERNAME_NOUNS) {
55 expect(isWholesomeUsername(`${adjective} ${noun}`)).toBe(true)
56 }
⋯ 24 lines hidden (lines 57–80)
57 }
58 })
59 
60 it('has no duplicate words within either list', () => {
61 expect(new Set(USERNAME_ADJECTIVES).size).toBe(USERNAME_ADJECTIVES.length)
62 expect(new Set(USERNAME_NOUNS).size).toBe(USERNAME_NOUNS.length)
63 })
64})
65 
66describe('the combination screen (isWholesomeUsername)', () => {
67 it('accepts a normal two-word handle', () => {
68 expect(isWholesomeUsername('Gilded Scribe')).toBe(true)
69 })
70 
71 it('rejects a redundant same-root pairing (reads as a typo, not a handle)', () => {
72 expect(isWholesomeUsername('Wandering Wanderer')).toBe(false)
73 })
74 
75 it('rejects anything that is not exactly two words', () => {
76 expect(isWholesomeUsername('Scribe')).toBe(false)
77 expect(isWholesomeUsername('Gilded Wandering Scribe')).toBe(false)
78 expect(isWholesomeUsername('')).toBe(false)
79 })
80})