[econ] gift profanity just in case

This commit is contained in:
Devin Zuczek
2026-08-26 11:21:30 -04:00
parent d31e05d41a
commit 19e1d48807
4 changed files with 55 additions and 2 deletions
+1
View File
@@ -16,6 +16,7 @@
"test": "run-vitest"
},
"dependencies": {
"@2toad/profanity": "3.3.0",
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
+26 -2
View File
@@ -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<App>({ 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<App>({ 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
@@ -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 })
+3
View File
@@ -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