From 19e1d48807f445eb82e63ec6e330857d209fddce Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 26 Aug 2026 11:21:30 -0400 Subject: [PATCH] [econ] gift profanity just in case --- apps/econ/package.json | 1 + apps/econ/src/econ.app.ts | 28 ++++++++++++++++++++-- apps/econ/src/test/integration/api.test.ts | 25 +++++++++++++++++++ pnpm-lock.yaml | 3 +++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/apps/econ/package.json b/apps/econ/package.json index 387d9e3..a59a652 100644 --- a/apps/econ/package.json +++ b/apps/econ/package.json @@ -16,6 +16,7 @@ "test": "run-vitest" }, "dependencies": { + "@2toad/profanity": "3.3.0", "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 65a31be..11606a5 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -30,6 +30,9 @@ import { UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM, } from '../../api/src/custom-avatar-items-db' import { getInventionById, toSaveResult } from '../../api/src/inventions-db' +// The profanity filter behind `api`'s `POST /api/sanitize/v1`, imported rather than copied +// so a gift note is masked by the very same word list every other player-typed string is. +import { censorSwears } from '../../api/src/sanitize' // The notification-type ids the hub carries, and the payload shapes recovered from the // client's own decoder (both owned by the `notify` worker). Imported rather than copied so // the frames this worker builds are typed by the shapes the client actually parses — a @@ -649,6 +652,27 @@ const CONSUMABLE_GRANT_COUNT = 1 /** The "Coach" system account — the sender a self-buy or anonymous gift is attributed to. */ const COACH_ACCOUNT_ID = 1 +/** What a box says when the buyer wrote nothing — a self-purchase, or a gift sent bare. */ +const DEFAULT_GIFT_MESSAGE = 'A gift for you <3' + +/** + * The note a gift box carries, masked the way every other string a player typed is. + * + * The buyer writes this and someone ELSE reads it — off the box, out of the hub frame, and + * for as long as the box goes unopened — so a gift is a way to put text in front of a player + * who never chose to hear from you. The same filter runs in `chat` for the same reason: + * nothing obliges a client to have called `POST /api/sanitize/v1` first, and this is the last + * point before the note is stored. + * + * Masking (not refusing) matches the rest of this server: the purchase goes through, the + * swear comes out as asterisks, and the buyer is never told their gift was rejected. Blocked + * characters are deliberately left alone, as in chat — a note is emoji-carrying text, and + * stripping format characters would break the joiners inside a multi-person emoji. + */ +function giftMessage(gift: GiftRequest | null): string { + return typeof gift?.Message === 'string' ? censorSwears(gift.Message) : DEFAULT_GIFT_MESSAGE +} + /** * Build the stored gift-box content (the client's rendered "gift box") from a gift-drop. * @@ -2617,7 +2641,7 @@ const app = new Hono({ strict: false }) // A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift // is attributed to the "Coach" system account (id 1), never a null/0 sender. const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID - const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3' + const message = giftMessage(gift) const giftContext = Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null // A gift is paid for here and granted THERE, so an id that names nobody would take the // buyer's tokens and strand the box on an account that will never read it. The client @@ -2841,7 +2865,7 @@ const app = new Hono({ strict: false }) ? (gift?.ToPlayerId as number) : id const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID - const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3' + const message = giftMessage(gift) const giftContext = Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index c53c043..b99acc7 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1464,6 +1464,31 @@ describe('econ endpoints', () => { expect(received?.payload).toMatchObject({ FromPlayerId: 1 }) }) + test('POST /api/storefronts/v2/buyItem masks swears in the gift message', async () => { + await seedAccount(208, 'MaskedReceiver') + await drainFrames() + const res = await giftBackpack('334', { + ToPlayerId: 208, + Message: 'happy birthday you shit', + Anonymous: false, + GiftContext: 500, + }) + expect(res.status).toBe(200) + // The buyer writes it and someone else reads it, so it is filtered like any other + // player-typed string — masked per character, never refused. + const masked = 'happy birthday you ****' + expect( + ((await res.json()) as { BalanceUpdates: Array<{ Data: Array<{ Message: string }> }> }) + .BalanceUpdates[0]?.Data[0]?.Message + ).toBe(masked) + const [gift] = await pendingGifts('208') + expect(gift?.Message).toBe(masked) + const received = (await drainFrames()).find( + (f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate + ) + expect(received?.payload).toMatchObject({ Message: masked }) + }) + test('POST /api/storefronts/v2/buyItem 404s a gift to a player that does not exist', async () => { await drainFrames() const res = await giftBackpack('332', { ToPlayerId: 999999, Message: 'hi', Anonymous: false }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e31b89d..1a6b451 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -568,6 +568,9 @@ importers: apps/econ: dependencies: + '@2toad/profanity': + specifier: 3.3.0 + version: 3.3.0 '@repo/domain': specifier: workspace:* version: link:../../packages/domain