From 629a7024031ae36ffab03b7e55455b2e9308ab81 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 9 Sep 2026 11:31:48 -0400 Subject: [PATCH] [accounts] emoji --- apps/accounts/src/accounts.app.ts | 65 +++- apps/accounts/src/openapi.ts | 21 +- .../accounts/src/test/integration/api.test.ts | 114 ++++++- apps/accounts/src/whitelisted-emojis.ts | 312 ++++++++++++++++++ packages/domain/src/accounts-db.ts | 2 +- 5 files changed, 509 insertions(+), 5 deletions(-) create mode 100644 apps/accounts/src/whitelisted-emojis.ts diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index c46fa1f..2100c61 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -32,6 +32,7 @@ import { CreateAccountResult, DisplayNameRequest, EmailRequest, + EmojiRequest, form, HealthResponse, IdentityFlagsRequest, @@ -45,7 +46,9 @@ import { SuccessResponse, UsernameRequest, UsernameResult, + WhitelistedEmojis, } from './openapi' +import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis' import type { Context } from 'hono' import type { Account } from '@repo/domain' @@ -109,8 +112,8 @@ function toAccountDto(account: Account) { username: account.username, displayName: account.displayName, profileImage: account.profileImage, - // Nothing writes these yet, and rows stored before they existed have neither - // key — always emit them as "" rather than letting them go missing. + // Rows stored before these fields existed have neither key — always emit them as + // "" rather than letting them go missing. bannerImage: account.bannerImage ?? '', displayEmoji: account.displayEmoji ?? '', isJunior: account.isJunior, @@ -210,6 +213,24 @@ const app = new Hono() (c) => c.json({ service: 'accounts', status: 'ok' }) ) + // ---- Emoji config -------------------------------------------------------- + // The picker the client fills its displayEmoji grid from. A BARE array — no + // `{ success, error, value }` envelope and no wrapper object; the client parses the + // response body itself as the list. + .get( + '/emojiConfig/whitelistedEmojis', + describeRoute({ + tags: ['Config'], + summary: 'Emoji a player may use as their displayEmoji', + description: [ + 'A bare JSON array of emoji, in the order the client draws them. Static — not', + 'auth-gated, and identical for every player.', + ].join(' '), + responses: { 200: json(WhitelistedEmojis, 'The whitelisted emoji, in picker order') }, + }), + (c) => c.json(WHITELISTED_EMOJIS) + ) + // ---- Self account -------------------------------------------------------- .get( '/account/me', @@ -665,6 +686,46 @@ const app = new Hono() } ) + // The emoji shown beside the player's display name. The body is a single field — + // `displayEmoji=%F0%9F%A4%AA` — and the value is checked against the same list + // `GET /emojiConfig/whitelistedEmojis` serves, then stored in that list's CANONICAL + // form: `displayEmoji` is compared as a plain string, and the client highlights the + // current pick by matching it against the picker list it fetched, so a stored value + // that differs only by a variation selector highlights nothing. + // + // Broadcast like every other public-DTO mutation here — the emoji rides along in the + // AccountUpdate payload, so it redraws beside the name without a refetch. + .put( + '/account/me/emoji', + describeRoute({ + tags: ['Profile'], + summary: 'Set display emoji', + description: [ + 'Persists the emoji shown beside the display name and broadcasts it in the', + 'AccountUpdate payload. The value must be one the whitelist serves; an empty value', + 'clears the pick.', + ].join(' '), + security: AUTHED, + requestBody: form(EmojiRequest, 'A whitelisted emoji, or "" to clear'), + responses: { + 200: json(SuccessResponse, 'Updated'), + 400: { description: 'Not a whitelisted emoji (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const submitted = (await formField(c, 'displayEmoji')).trim() + // An empty value clears the pick; anything else has to be on the list. + const displayEmoji = submitted === '' ? '' : resolveWhitelistedEmoji(submitted) + if (displayEmoji === null) return c.body(null, 400) + const account = await updateAccount(c.env.DB, id, { displayEmoji }) + await pushAccountUpdate(c, account) + return c.json({ success: true }) + } + ) + // The profile banner — the wide image behind the header on a player's profile. Same // shape as the avatar below: the body names an image the player has already uploaded // (the client posts one of their own photos, `sharecamera//.jpg`), so this diff --git a/apps/accounts/src/openapi.ts b/apps/accounts/src/openapi.ts index 87bd771..03b4883 100644 --- a/apps/accounts/src/openapi.ts +++ b/apps/accounts/src/openapi.ts @@ -75,7 +75,7 @@ export const AccountDto = z.object({ bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'), displayEmoji: z .string() - .describe('Emoji beside the display name — always "" (nothing sets it yet)'), + .describe('Emoji beside the display name, set by PUT /account/me/emoji; "" when unset'), isJunior: z.boolean(), platforms: z.int().describe('PlatformType bitmask of linked platforms'), personalPronouns: z.int().describe('Pronoun flags bitmask'), @@ -139,6 +139,15 @@ export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurch */ export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() }) +/** + * `GET /emojiConfig/whitelistedEmojis` response — a BARE array of emoji, no envelope + * and no object around it (see `WHITELISTED_EMOJIS`). + */ +export const WhitelistedEmojis = z + .string() + .array() + .describe('The emoji a player may set as their displayEmoji, in picker order') + /** Root health check. */ export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') }) @@ -230,6 +239,16 @@ export const BioRequest = z.object({ bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'), }) +/** + * `PUT /account/me/emoji` form body. The value must be one of the emoji served by + * `GET /emojiConfig/whitelistedEmojis`; an empty value clears the current pick. Checked + * in the handler rather than here, because the check also CANONICALIZES the value + * (see `resolveWhitelistedEmoji`) and a schema can only accept or reject it. + */ +export const EmojiRequest = z.object({ + displayEmoji: z.string().describe('A whitelisted emoji, or "" to clear'), +}) + export const ProfileImageRequest = z.object({ imageName: z.string().describe('Avatar object key; empty is rejected (400)'), }) diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index a0aef71..d708fdb 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -6,6 +6,8 @@ import '../../accounts.app' import { SCHEMA_DDL } from '@repo/domain' +import { WHITELISTED_EMOJIS } from '../../whitelisted-emojis' + import type { Env } from '../../context' declare module 'cloudflare:test' { @@ -173,7 +175,7 @@ describe('auth-gated endpoints', () => { // An unset email is "", not null — the client reads it as a string, and the // hub frame this DTO also rides drops null values outright. email: '', - // Nothing sets these yet, but the key has to be present — the client reads + // Unset on a fresh account, but the key has to be present — the client reads // both off the account DTO. bannerImage: '', displayEmoji: '', @@ -403,6 +405,91 @@ describe('auth-gated endpoints', () => { expect(sent.map((n) => (n.data as { bannerImage?: string }).bannerImage)).toContain(key) }) + test('PUT /account/me/emoji persists the emoji and pushes the profile update', async () => { + type Sent = { playerId: number; notificationType: string | number; data: unknown } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + await hub().fetch('http://do/', { method: 'DELETE' }) + + // Exactly the body the client sends: one urlencoded field (`%F0%9F%A4%AA`). + const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { + method: 'PUT', + headers: { ...(await bearer('779')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'displayEmoji=%F0%9F%A4%AA', + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + + // Served back by both the self and public reads — displayEmoji is in the public DTO. + const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('779') }) + expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}') + const pub = await exports.default.fetch(`${ORIGIN}/account/779`) + expect(((await pub.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}') + + // And it rides the profile-update notification, so it redraws beside the name. + const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[] + expect(sent.length).toBeGreaterThan(0) + expect(sent.map((n) => (n.data as { displayEmoji?: string }).displayEmoji)).toContain( + '\u{1F92A}' + ) + }) + + // The pick is stored in the whitelist's CANONICAL form. A client that posts the emoji + // without its U+FE0F variation selector means the same pick, but storing what arrived + // would leave a string the picker list no longer matches, so the current pick would + // stop highlighting. + test('PUT /account/me/emoji canonicalizes a pick sent without its variation selector', async () => { + const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { + ...form({ displayEmoji: '\u{2764}' }), + headers: { ...(await bearer('780')), 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + expect(res.status).toBe(200) + + const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('780') }) + const stored = ((await me.json()) as { displayEmoji: string }).displayEmoji + expect(stored).toBe('\u{2764}\u{FE0F}') + expect(WHITELISTED_EMOJIS).toContain(stored) + }) + + // An empty value clears the pick rather than 400ing — that's how the picker's "none" + // gets back to no emoji at all. + test('PUT /account/me/emoji clears the pick on an empty value', async () => { + const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { + ...form({ displayEmoji: '\u{1F389}' }), + headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + expect(set.status).toBe(200) + + const cleared = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { + ...form({ displayEmoji: '' }), + headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + expect(cleared.status).toBe(200) + + const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('781') }) + expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('') + }) + + // displayEmoji renders beside the display name, so an unchecked field would be a + // free-text label on every profile. Off-list values are refused, not stored. + test('PUT /account/me/emoji 401s without a token, 400s on an off-list value', async () => { + const anon = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { + ...form({ displayEmoji: '\u{1F92A}' }), + }) + expect(anon.status).toBe(401) + + for (const displayEmoji of ['not an emoji', '\u{1F92A}\u{1F92A}', '\u{1F595}\u{1F3FB}']) { + const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, { + ...form({ displayEmoji }), + headers: { ...(await bearer('782')), 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + expect(res.status).toBe(400) + } + + // Nothing was stored by the refusals. + const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('782') }) + expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('') + }) + test('PUT /account/me/bannerimage 401s without a token, 400s without an imageName', async () => { const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, { ...form({ imageName: 'x.jpg' }), @@ -563,6 +650,7 @@ describe('auth-gated endpoints', () => { 'GET /account/{id}', 'GET /account/{id}/bio', 'GET /accountprivacysettings/{id}', + 'GET /emojiConfig/whitelistedEmojis', 'GET /parentalcontrol/me', 'POST /account/create', 'POST /account/me/email', @@ -570,6 +658,7 @@ describe('auth-gated endpoints', () => { 'PUT /account/me/bannerimage', 'PUT /account/me/bio', 'PUT /account/me/displayname', + 'PUT /account/me/emoji', 'PUT /account/me/identityflags', 'PUT /account/me/personalpronouns', 'PUT /account/me/profileimage', @@ -758,3 +847,26 @@ test('POST /account/me/phone stores an E.164 number exactly as the client sends // Verbatim — no normalising, no stripping of the +. expect(row?.phone).toBe('+15552223333') }) + +// The emoji picker. Served as a BARE array — the client parses the response body itself +// as the list, so wrapping it in `{ value: [...] }` or the success envelope every +// mutation here uses would leave the picker empty. +test('GET /emojiConfig/whitelistedEmojis serves the list as a bare array', async () => { + const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`) + expect(res.status).toBe(200) + const body = (await res.json()) as string[] + expect(Array.isArray(body)).toBe(true) + expect(body).toEqual(WHITELISTED_EMOJIS) + // Order is the picker's order, and the first entry anchors it. + expect(body[0]).toBe('😀') + // Every entry is a non-empty string and appears once — a duplicate draws twice in + // the grid, and the list is compared against `displayEmoji` as an exact string. + expect(body.every((e) => typeof e === 'string' && e.length > 0)).toBe(true) + expect(new Set(body).size).toBe(body.length) +}) + +// Not auth-gated: the client asks for the picker before it has a token in hand. +test('GET /emojiConfig/whitelistedEmojis needs no bearer token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`) + expect(res.status).toBe(200) +}) diff --git a/apps/accounts/src/whitelisted-emojis.ts b/apps/accounts/src/whitelisted-emojis.ts new file mode 100644 index 0000000..dd74c47 --- /dev/null +++ b/apps/accounts/src/whitelisted-emojis.ts @@ -0,0 +1,312 @@ +/** + * The emoji a player may pick as their `displayEmoji` — served verbatim as a bare JSON + * array by `GET /emojiConfig/whitelistedEmojis`, with no envelope around it. + * + * ORDER IS THE PICKER'S ORDER: the client renders the array as it arrives, so this is + * grouped by theme (smileys, hearts, gestures, animals, food, weather, tech, music, + * sport, objects, vehicles, symbols). Sorting it would reshuffle the grid; append a new + * entry to the end of the group it belongs to instead. + * + * Entries are UNIQUE — a repeat draws the same emoji twice in the grid — and are stored + * as the exact code-point sequence the client sends back, variation selectors (U+FE0F) + * and zero-width joiners included: `displayEmoji` is compared as a plain string, so + * `\u{2764}` and `\u{2764}\u{FE0F}` are two different values and only the form listed + * here round-trips. + */ +export const WHITELISTED_EMOJIS: string[] = [ + '😀', + '😃', + '😄', + '😁', + '😆', + '😅', + '😂', + '🤣', + '😊', + '😇', + '🙂', + '🙃', + '😉', + '😍', + '🥰', + '😘', + '😗', + '😚', + '😋', + '😛', + '😜', + '🤪', + '😝', + '🤑', + '🤗', + '🤭', + '🤫', + '🤔', + '🤨', + '😐', + '😑', + '😶', + '🙄', + '😏', + '😣', + '😥', + '😮', + '🤐', + '😯', + '😪', + '😫', + '🥱', + '😴', + '😌', + '🤓', + '😎', + '🤩', + '🥳', + '😤', + '😭', + '😢', + '🥺', + '😡', + '🤬', + '😱', + '😨', + '😰', + '😬', + '🤯', + '🥶', + '🥵', + '🤠', + '🤖', + '👽', + '👻', + '💀', + '☠️', + '👹', + '👺', + '👾', + '❤️', + '🧡', + '💛', + '💚', + '💙', + '💜', + '🖤', + '🤍', + '🤎', + '💖', + '💗', + '💓', + '💕', + '💞', + '💘', + '💝', + '💟', + '❣️', + '💔', + '👍', + '👎', + '👌', + '✌️', + '🤞', + '🤟', + '🤘', + '🤙', + '👏', + '🙌', + '👐', + '🤲', + '🙏', + '👋', + '✋', + '🤚', + '🫶', + '💪', + '🧠', + '👀', + '👁️', + '👄', + '🦾', + '🦿', + '🐶', + '🐱', + '🐭', + '🐹', + '🐰', + '🦊', + '🐻', + '🐼', + '🐨', + '🐯', + '🦁', + '🐸', + '🐵', + '🐧', + '🐦', + '🦅', + '🦆', + '🦄', + '🐴', + '🐢', + '🐙', + '🦈', + '🐬', + '🐳', + '🦋', + '🐝', + '🐞', + '🦖', + '🦕', + '🐲', + '🍎', + '🍌', + '🍇', + '🍉', + '🍓', + '🍒', + '🥝', + '🍍', + '🥑', + '🌮', + '🍕', + '🍔', + '🍟', + '🌭', + '🥪', + '🍗', + '🍿', + '🍩', + '🍪', + '🎂', + '🍫', + '🍬', + '🍭', + '🧋', + '☕', + '🥤', + '🍺', + '🥛', + '☀️', + '🌤️', + '⛅', + '🌥️', + '☁️', + '🌧️', + '⛈️', + '❄️', + '🌈', + '⭐', + '🌟', + '✨', + '⚡', + '🔥', + '💧', + '🌊', + '🌸', + '🌹', + '🍀', + '🌲', + '🎮', + '🕹️', + '💻', + '⌨️', + '🖥️', + '📱', + '🖱️', + '🎧', + '📷', + '📹', + '💿', + '💾', + '🔋', + '🔌', + '🛰️', + '🚀', + '🎵', + '🎶', + '🎼', + '🎤', + '🥁', + '🎸', + '🎹', + '🎺', + '🎷', + '🎻', + '⚽', + '🏀', + '🏈', + '⚾', + '🎾', + '🏐', + '🏓', + '🥊', + '🏆', + '🥇', + '🥈', + '🥉', + '💎', + '💰', + '💸', + '🪙', + '🎁', + '📦', + '🔑', + '🗝️', + '🛡️', + '⚔️', + '🧸', + '🎈', + '🎉', + '🎊', + '🕯️', + '💡', + '📚', + '📖', + '✏️', + '🖊️', + '🚗', + '🚕', + '🚌', + '🚓', + '🚑', + '🚒', + '🏎️', + '🚲', + '✈️', + '🚁', + '🚢', + '✔️', + '✅', + '❌', + '⭕', + '❗', + '❓', + '💯', + '♾️', + '🔔', + '🔕', + '❤️‍🔥', +] + +/** + * The whitelist keyed by its FE0F-stripped form, so a lookup tolerates the one way the + * client legitimately disagrees with this list: U+FE0F is a PRESENTATION hint, and + * `\u{2764}`/`\u{2764}\u{FE0F}` are the same picked emoji even though they are different + * strings. Verified collision-free — stripping FE0F maps the 271 entries onto 271 + * distinct keys — so the fold can never make two picks ambiguous. + */ +const BY_STRIPPED = new Map( + WHITELISTED_EMOJIS.map((emoji) => [emoji.replaceAll('\u{FE0F}', ''), emoji]) +) + +/** + * Resolve a client-submitted emoji to its CANONICAL whitelist entry, or null when it + * isn't on the list. Storing the canonical form (rather than what arrived) is what keeps + * a stored `displayEmoji` string-equal to the picker entry it came from — the client + * highlights the current pick by comparing against the list it fetched. + * + * Whitelisting matters here beyond tidiness: `displayEmoji` renders beside the display + * name, so an unchecked field is a free-text label on every player's profile. + */ +export function resolveWhitelistedEmoji(input: string): string | null { + return BY_STRIPPED.get(input.replaceAll('\u{FE0F}', '')) ?? null +} diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 838e17c..5923e87 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -35,7 +35,7 @@ export interface Account { profileImage: string /** Profile banner image key, set by `accounts` `PUT /account/me/bannerimage`. `""` until then. */ bannerImage: string - /** The emoji shown beside the display name. No route sets it yet — always `""`. */ + /** The emoji beside the display name, set by `accounts` `PUT /account/me/emoji`. `""` until then. */ displayEmoji: string isJunior: boolean platforms: number