outfit security wip

This commit is contained in:
Devin Zuczek
2026-10-10 17:07:37 -04:00
parent 585cf68637
commit f3111bcf0d
5 changed files with 493 additions and 7 deletions
+13
View File
@@ -287,6 +287,19 @@ inconsistency here without checking the client first.
other implementations set it at all, it names the start of a voice-chat timeout. Other other implementations set it at all, it names the start of a voice-chat timeout. Other
implementations with timed bans keep their set-time private and serve the remainder on implementations with timed bans keep their set-time private and serve the remainder on
every read; `banned_at` here is the staff panel's audit trail, not the client's. every read; `banned_at` here is the staff panel's audit trail, not the client's.
- An outfit is served to OTHER players (`api`: `POST /outfits/bulk`) with the selections the
wearer does not own stripped (`restrictOutfitToOwned` in `@repo/domain`); the owner's
`GET /outfits/me` stays verbatim. Nothing on the save path checks ownership. A legacy
selection entry (`SelectionsV1`/`OutfitSelections`, `;`-delimited) is
`<AvatarItemDesc>,<extra>,<bodyPart>` with the descriptor's empty trailing fields dropped,
which is why `5cd08cfb-…,,1` and `40528de7-…,51ef8d39-…,0b2395e1-…,,0` are both one entry;
a v2 selection (`SelectionsV2`/`OutfitSelectionsV2`, JSON-in-a-string) names the item as
`PrefabGuid` and a custom shirt as `UgcOutfitData.CustomAvatarItemId`. Ownership is decided
on the BASE ASSET (first descriptor field, which is `PrefabGuid`) — not the colour — because
the v2 forms carry no colour and the forms must agree; the free default catalog and the
custom-clothing base items count as owned. `CustomizationSettings` has only been observed
as its header (`AvatarVersion`, `AvatarBodyType`); any v2-shaped selections inside it are
stripped wherever they sit. A document is re-encoded only when something was removed.
- Accessibility is sent as the `RoomAccessibility` enum NAME on - Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
+9 -4
View File
@@ -1017,14 +1017,19 @@ export const OutfitsBulkRequest = z.object({
/** /**
* `POST /outfits/bulk` — the worn outfit of each account asked for, keyed by account id. * `POST /outfits/bulk` — the worn outfit of each account asked for, keyed by account id.
* *
* The key is the id as a STRING (a JSON object key always is) and the value is the same * The key is the id as a STRING (a JSON object key always is) and the value is the stored
* stored outfit `GET /outfits/me` serves. An account with nothing saved is ABSENT from the * outfit `GET /outfits/me` serves, less any selection naming an item the wearer does not own
* map rather than present with a null — a map expresses "no outfit" by not carrying the key. * (see `restrictOutfitToOwned` in @repo/domain) — this is what other players dress them
* from. An account with nothing saved is ABSENT from the map rather than present with a
* null — a map expresses "no outfit" by not carrying the key.
*/ */
export const OutfitsBulkResponse = z.object({ export const OutfitsBulkResponse = z.object({
OutfitsByAccountId: z OutfitsByAccountId: z
.record(z.string(), StoredOutfit) .record(z.string(), StoredOutfit)
.describe('Keyed by account id as a string. Accounts with no saved outfit are omitted'), .describe(
'Keyed by account id as a string. Accounts with no saved outfit are omitted. ' +
'Selections naming items the wearer does not own are stripped'
),
}) })
/** /**
+46 -3
View File
@@ -6,14 +6,22 @@ import {
getOutfit, getOutfit,
getOutfits, getOutfits,
getOutfitsByAccounts, getOutfitsByAccounts,
getOwnedOutfitItemsByAccounts,
inventionDescriptionRejection, inventionDescriptionRejection,
inventionLongDescriptionRejection, inventionLongDescriptionRejection,
inventionNameRejection, inventionNameRejection,
inventionTagRejection, inventionTagRejection,
MAX_BULK_OUTFIT_ACCOUNTS, MAX_BULK_OUTFIT_ACCOUNTS,
outfitItemBase,
restrictOutfitToOwned,
setOutfit, setOutfit,
} from '@repo/domain' } from '@repo/domain'
// The free catalog, as the econ worker bundles it: what `GET /api/avatar/v4/items` hands
// every player before their purchases, and the base items custom clothing is built on.
// Read here so the bulk outfit read knows which worn items need no purchase.
import defaultAvatarItems from '../../../econ/static/default-avatar-items.json'
import defaultBaseAvatarItems from '../../../econ/static/default-base-avatar-items.json'
import { import {
createCustomAvatarItem, createCustomAvatarItem,
deleteCustomAvatarItem, deleteCustomAvatarItem,
@@ -134,6 +142,19 @@ import type { InventionTag, SavedInvention } from '../inventions-db'
*/ */
const BULK_CUSTOM_AVATAR_ITEM_CAP = 100 const BULK_CUSTOM_AVATAR_ITEM_CAP = 100
/**
* The base assets every player may wear without buying anything: the default catalog econ
* hands everyone (`GET /api/avatar/v4/items` prepends purchases to it) and the base items
* custom clothing is built on. Folded into each wearer's owned set when `POST /outfits/bulk`
* strips unowned selections — see `restrictOutfitToOwned` for why ownership is decided on
* the base asset.
*/
const FREE_ITEM_BASES: ReadonlySet<string> = new Set(
[...defaultAvatarItems, ...defaultBaseAvatarItems].map((item) =>
outfitItemBase(item.AvatarItemDesc)
)
)
/** /**
* The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated * The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated
* `customAvatarItemIds` form fields, and the same spelling is read off the query string * `customAvatarItemIds` form fields, and the same spelling is read off the query string
@@ -1092,6 +1113,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// id, and a list would make it match up the order itself. An account with nothing saved is // id, and a list would make it match up the order itself. An account with nothing saved is
// left out of the map — see `getOutfitsByAccounts`. // left out of the map — see `getOutfitsByAccounts`.
// //
// This is how OTHER players see an outfit, so it is served with the selections the wearer
// does not own stripped: nothing on the save path checks ownership, and an edited save
// would otherwise dress a player in items they never bought for the whole room to see.
// The owner's own `GET /outfits/me` stays verbatim.
//
// `UnityAssetTarget` / `UnityAssetVersion` name the baked-asset build the client would // `UnityAssetTarget` / `UnityAssetVersion` name the baked-asset build the client would
// like the outfits for. Nothing here bakes assets, so both are accepted and ignored. // like the outfits for. Nothing here bakes assets, so both are accepted and ignored.
.post( .post(
@@ -1103,8 +1129,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'The worn outfit (slot 0) of each account in `AccountIds`, keyed by account id — the ' + 'The worn outfit (slot 0) of each account in `AccountIds`, keyed by account id — the ' +
'call the client makes to dress a room full of players in one request.\n\n' + 'call the client makes to dress a room full of players in one request.\n\n' +
'A MAP rather than a list: the client looks each player up by id. The key is the id ' + 'A MAP rather than a list: the client looks each player up by id. The key is the id ' +
'as a string, and the value is the same stored outfit `GET /outfits/me` serves, ' + 'as a string, and the value is the stored outfit `GET /outfits/me` serves, LESS any ' +
'handed back exactly as it was saved. An account with nothing saved in slot 0 is ' + 'selection naming an item the wearer does not own: a base asset that is neither in ' +
'the free default catalog nor on any item they bought, or a custom item they ' +
'neither bought nor made. The save path checks nothing, and this read is what ' +
'everyone else dresses the player from. An outfit that loses nothing is served ' +
'exactly as saved. An account with nothing saved in slot 0 is ' +
'ABSENT from the map rather than carrying a null — a map says “no outfit” by not ' + 'ABSENT from the map rather than carrying a null — a map says “no outfit” by not ' +
'having the key, and inventing one for a player who has never saved would dress them ' + 'having the key, and inventing one for a player who has never saved would dress them ' +
'in something they never chose.\n\n' + 'in something they never chose.\n\n' +
@@ -1141,8 +1171,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
} }
const outfits = await getOutfitsByAccounts(c.env.DB, accountIds, CURRENT_OUTFIT_SLOT) const outfits = await getOutfitsByAccounts(c.env.DB, accountIds, CURRENT_OUTFIT_SLOT)
// What each wearer may wear, read for the accounts that HAVE an outfit: a selection
// naming an item the wearer never bought is dropped before anyone else dresses them
// in it. The free catalog counts as owned; see `restrictOutfitToOwned`.
const owned = await getOwnedOutfitItemsByAccounts(
c.env.DB,
[...outfits.keys()],
FREE_ITEM_BASES
)
const OutfitsByAccountId: Record<string, unknown> = {} const OutfitsByAccountId: Record<string, unknown> = {}
for (const [accountId, outfit] of outfits) OutfitsByAccountId[String(accountId)] = outfit for (const [accountId, outfit] of outfits) {
const wearable = owned.get(accountId)
OutfitsByAccountId[String(accountId)] = wearable
? restrictOutfitToOwned(outfit, wearable)
: outfit
}
return c.json({ OutfitsByAccountId }) return c.json({ OutfitsByAccountId })
} }
) )
+176
View File
@@ -24,6 +24,7 @@ import {
ROOM_INSTANCE_SCHEMA_DDL, ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL, ROOM_SCHEMA_DDL,
seedRoomWithSubRooms, seedRoomWithSubRooms,
setOutfit,
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
SUPPORTED_GAME_VERSIONS, SUPPORTED_GAME_VERSIONS,
} from '@repo/domain' } from '@repo/domain'
@@ -38,6 +39,11 @@ import {
SYSTEM_SENDER_ID, SYSTEM_SENDER_ID,
THREAD_SCHEMA_DDL, THREAD_SCHEMA_DDL,
} from '../../../../chat/src/thread-db' } from '../../../../chat/src/thread-db'
import {
grantCustomAvatarItem,
INVENTORY_CUSTOM_SCHEMA_DDL,
} from '../../../../econ/src/inventory-custom-db'
import { grantItem, INVENTORY_SCHEMA_DDL } from '../../../../econ/src/inventory-db'
import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw' import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw'
import inventionPriceCapSql from '../../../migrations/0033_invention_price_cap.sql?raw' import inventionPriceCapSql from '../../../migrations/0033_invention_price_cap.sql?raw'
import { banEvasionMatch, resolveBan } from '../../bans-db' import { banEvasionMatch, resolveBan } from '../../bans-db'
@@ -173,6 +179,10 @@ beforeAll(async () => {
// Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0. // Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0.
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Bought items and bought custom items (owned by the econ worker) — /outfits/bulk strips
// what a wearer doesn't own.
for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_CUSTOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Inventions table (owned by the api worker) — invention save/mine use it. // Inventions table (owned by the api worker) — invention save/mine use it.
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -2057,6 +2067,172 @@ describe('public endpoints', () => {
expect(bad.status).toBe(400) expect(bad.status).toBe(400)
}) })
test('POST /outfits/bulk strips the selections a wearer does not own', async () => {
const bulk = async (ids: number[]) => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/bulk`, {
method: 'POST',
headers: { ...(await bearer('42')), 'content-type': 'application/json' },
body: JSON.stringify({ AccountIds: ids, UnityAssetTarget: null, UnityAssetVersion: null }),
})
expect(res.status).toBe(200)
return ((await res.json()) as { OutfitsByAccountId: Record<string, unknown> })
.OutfitsByAccountId
}
const save = async (sub: string, outfit: unknown) => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'content-type': 'application/json' },
body: JSON.stringify(outfit),
})
expect(res.status).toBe(200)
}
// Base assets: one from the free default catalog, the base item custom shirts are built
// on, one account 301 BOUGHT (in a colour — the inventory key is the whole descriptor),
// and one nobody owns.
const free = '5cd08cfb-c729-4c30-96d9-6a99bb934d91'
const base = 'c5d70cb4-71dd-4fe4-b719-34fe2073c611'
const bought = 'aaaaaaaa-1111-4222-8333-444444444444'
const unowned = 'bbbbbbbb-1111-4222-8333-444444444444'
await grantItem(env.DB, 301, {
AvatarItemType: 0,
AvatarItemDesc: `${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,`,
PlatformMask: -1,
FriendlyName: 'Bought Hat',
Tooltip: '',
Rarity: 1,
})
// Custom items: one 301 made, one they bought from 999, one of 999's they never bought.
const custom = async (customAvatarItemId: string, creatorAccountId: number) =>
createCustomAvatarItem(env.DB, {
customAvatarItemId,
creatorAccountId,
name: customAvatarItemId,
description: '',
price: 0,
baseAvatarItemId: 2184,
baseAvatarItemColor: '#F55C1A',
accessibility: 1,
designFilename: 'd.png',
thumbnailImageFilename: 't.png',
})
const made = (await custom('cccccccc-1111-4222-8333-444444444444', 301)).CustomAvatarItemId
const boughtCustom = (await custom('dddddddd-1111-4222-8333-444444444444', 999))
.CustomAvatarItemId
const unownedCustom = (await custom('eeeeeeee-1111-4222-8333-444444444444', 999))
.CustomAvatarItemId
await grantCustomAvatarItem(env.DB, 301, boughtCustom)
const selection = (PrefabGuid: string, BodyPart: number, customId?: string) => ({
PrefabGuid,
CombinationGuid: '',
BodyPart,
...(customId === undefined
? {}
: {
UgcOutfitData: {
BaseAvatarItemColor: { r: 0, g: 0, b: 0, a: 0 },
CustomAvatarItemId: customId,
},
}),
})
// Guid case is not identity — the client is not consistent about it — so the bought
// base rides upper-cased in the v2 list.
const v2 = {
selections: [
selection(free, 1),
selection(bought.toUpperCase(), 0),
selection(unowned, 2),
selection(base, 3, made),
selection(base, 4, boughtCustom.toUpperCase()),
selection(base, 5, unownedCustom),
],
}
// Only the header of `CustomizationSettings` has been observed; whatever selections it
// carries are found wherever they sit, by the same rule.
const settings = {
AvatarVersion: 2,
AvatarBodyType: 0,
Body: { Items: [selection(free, 1), selection(unowned, 2)] },
}
const envelope = (
SelectionsV1: string,
SelectionsV2: string,
CustomizationSettings: string
) => ({
DataVersion: 2,
LegacyData: {
SelectionsV1,
SelectionsV2,
FaceFeatures: '{"ver":7}',
SkinColor: 'skin',
HairColor: 'hair',
},
CustomizationSettings,
Selections: [],
Slot: 0,
Name: '',
Accessibility: 1,
ThumbnailFileName: null,
})
const saved = envelope(
`${free},,1;${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,,0;${unowned},,2;${base},,3`,
JSON.stringify(v2),
JSON.stringify(settings)
)
await save('301', saved)
// Account 303 wears only free things: served byte-for-byte as saved.
const allFree = envelope(
`${free},,1;${base},,0`,
JSON.stringify({ selections: [selection(free, 1)] }),
JSON.stringify({ AvatarVersion: 2, AvatarBodyType: 0 })
)
await save('303', allFree)
// Account 302 saved through econ's flat shape (`/api/avatar/v3/saved/set`), which lands
// in the same table: the flat fields are stripped the same way.
const flat = {
Slot: 0,
PreviewImageName: null,
OutfitSelections: `${free},,1;${unowned},,0`,
OutfitSelectionsV2: JSON.stringify({
selections: [selection(free, 1), selection(unowned, 0)],
}),
FaceFeatures: '{"ver":6}',
SkinColor: 'skin',
HairColor: 'hair',
CustomAvatarItems: [{ CustomAvatarItemId: unownedCustom }],
}
await setOutfit(env.DB, 302, flat)
const outfits = await bulk([301, 302, 303])
// 301: the unowned base is gone from every form, the unbought custom item too; the
// free, bought (either case), made and bought-custom selections stay. The documents
// are re-encoded only because something was removed from them.
expect(outfits['301']).toEqual(
envelope(
`${free},,1;${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,,0;${base},,3`,
JSON.stringify({ selections: v2.selections.filter((_, i) => i !== 2 && i !== 5) }),
JSON.stringify({ ...settings, Body: { Items: [selection(free, 1)] } })
)
)
expect(outfits['302']).toEqual({
...flat,
OutfitSelections: `${free},,1`,
OutfitSelectionsV2: JSON.stringify({ selections: [selection(free, 1)] }),
CustomAvatarItems: [],
})
expect(outfits['303']).toEqual(allFree)
// The owner's own read is untouched: what they saved is what they get back.
const mine = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
headers: await bearer('301'),
})
expect(await mine.json()).toEqual(saved)
})
test('PUT /outfits/me 400s on an unparseable body', async () => { test('PUT /outfits/me 400s on an unparseable body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT', method: 'PUT',
+249
View File
@@ -122,3 +122,252 @@ export async function setOutfit(db: D1Database, accountId: number, outfit: Outfi
.bind(accountId, outfit.Slot, JSON.stringify(outfit)) .bind(accountId, outfit.Slot, JSON.stringify(outfit))
.run() .run()
} }
// ---- Serving an outfit to OTHER players -------------------------------------------------
//
// An outfit is stored exactly as its owner posted it, and `/outfits/me` hands it straight
// back to them. But the bulk read (`POST /outfits/bulk`) is how everyone ELSE's client
// dresses a player, and nothing on the way in checks that the player may wear what they
// saved — an edited save, or a client that skips the store, puts an unbought item on an
// avatar everyone in the room then sees. So the bulk read serves each outfit with the
// selections the wearer does not own taken OUT, and the owner keeps seeing their own
// save untouched.
//
// What a selection names, in each of the forms the two write paths store:
//
// - A legacy descriptor list (`LegacyData.SelectionsV1` in the newer envelope, the flat
// `OutfitSelections` in econ's) is `;`-delimited, one entry per worn item, each
// `<AvatarItemDesc>,<extra>,<bodyPart>` — e.g. `40528de7-…,51ef8d39-…,0b2395e1-…,,0`
// and `5cd08cfb-…,,1`. The `AvatarItemDesc` is `<baseAsset>,<color>,<texture>,` with its
// empty trailing fields dropped, which is why the entries differ in length.
// - A v2 selection list (`LegacyData.SelectionsV2`, `OutfitSelectionsV2`) is JSON in a
// string, `{ selections: [ { PrefabGuid, CombinationGuid, BodyPart, UgcOutfitData? } ] }`,
// where `PrefabGuid` is the base asset and a custom shirt rides in
// `UgcOutfitData.CustomAvatarItemId`.
// - `CustomizationSettings` is the newer client's own structured document, also JSON in a
// string. Only its header (`AvatarVersion`, `AvatarBodyType`) has been observed; whatever
// selections it carries are assumed to be the v2 shape above, found wherever they sit.
// - `Selections` and `CustomAvatarItems` are plain arrays (observed empty), walked the same
// way.
//
// Ownership is decided on the BASE ASSET — the first field of the descriptor, which is the
// one identifier every form carries (`PrefabGuid` is nothing else) and the field the client
// decides renderability on (see `baseAsset` in econ's catalog). A player owns a base asset
// when any `inventory` row of theirs starts with it, or when it is in the free default
// catalog (what econ's `GET /api/avatar/v4/items` hands every player) or is one of the base
// items custom clothing is built on. That is coarser than a purchase — a store sells the
// same base in several colours as separate items, and owning one colour lets the others
// through — because the v2 forms record no colour to check, and a rule the three forms
// disagree on would dress a player differently depending on which one the client reads.
// A custom item (`CustomAvatarItemId`) is owned when bought (`inventory_custom`) or made
// (`custom_avatar_item.creator_account_id`), the same pair econ's owned list serves.
//
// An outfit that loses nothing is returned as the very object it came in as, string fields
// byte-for-byte: the JSON-in-a-string documents are re-encoded only when a selection inside
// them was removed, keeping the verbatim round trip the note at the top of this file asks
// for in the common case.
/** What one player may wear: base assets and custom item ids, each lower-cased. */
export interface OwnedOutfitItems {
bases: Set<string>
customItemIds: Set<string>
}
/**
* The base asset of an `AvatarItemDesc` or of a legacy selection entry: the first
* comma-separated field, lower-cased. The same first field whichever form of the item it is
* read from — a catalog descriptor (`<base>,<color>,<texture>,`), an inventory key, or a
* worn selection (`<base>,…,<bodyPart>`).
*/
export function outfitItemBase(desc: string): string {
return (desc.split(',')[0] ?? '').trim().toLowerCase()
}
/** A JSON object, as the walkers below see one. */
type JsonObject = Record<string, unknown>
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Whether one v2-style selection object names only owned things. An object with neither a
* `PrefabGuid` nor a `CustomAvatarItemId` is not a selection and is always kept.
*/
function selectionOwned(selection: JsonObject, owned: OwnedOutfitItems): boolean {
const prefab = selection.PrefabGuid
if (typeof prefab === 'string' && prefab !== '' && !owned.bases.has(outfitItemBase(prefab))) {
return false
}
const ugc = selection.UgcOutfitData
const customIds = [
selection.CustomAvatarItemId,
isObject(ugc) ? ugc.CustomAvatarItemId : undefined,
]
for (const id of customIds) {
if (typeof id === 'string' && id !== '' && !owned.customItemIds.has(id.toLowerCase())) {
return false
}
}
return true
}
/**
* Walk a parsed JSON value and drop, from every array in it, the selection objects the
* player does not own. Returns the (possibly new) value and whether anything was dropped;
* an untouched subtree is returned as the same reference.
*/
function pruneSelections(value: unknown, owned: OwnedOutfitItems): [unknown, boolean] {
if (Array.isArray(value)) {
let changed = false
const kept: unknown[] = []
for (const entry of value) {
if (isObject(entry) && !selectionOwned(entry, owned)) {
changed = true
continue
}
const [pruned, entryChanged] = pruneSelections(entry, owned)
changed ||= entryChanged
kept.push(pruned)
}
return changed ? [kept, true] : [value, false]
}
if (isObject(value)) {
let changed = false
const out: JsonObject = {}
for (const [key, entry] of Object.entries(value)) {
const [pruned, entryChanged] = pruneSelections(entry, owned)
changed ||= entryChanged
out[key] = pruned
}
return changed ? [out, true] : [value, false]
}
return [value, false]
}
/**
* The legacy `;`-delimited descriptor list with the unowned entries removed. Returned as
* the same string when nothing was removed; a string that is not a list (empty, or
* carrying no `;`-separated descriptors) is returned as it is.
*/
function pruneLegacySelections(list: string, owned: OwnedOutfitItems): string {
const entries = list.split(';')
const kept = entries.filter((entry) => entry === '' || owned.bases.has(outfitItemBase(entry)))
return kept.length === entries.length ? list : kept.join(';')
}
/**
* A JSON-in-a-string document with the unowned selections removed. The original string is
* returned when nothing was removed — or when it does not parse, which is the client's own
* document to make sense of, not this server's.
*/
function pruneJsonDocument(document: string, owned: OwnedOutfitItems): string {
let parsed: unknown
try {
parsed = JSON.parse(document)
} catch {
return document
}
const [pruned, changed] = pruneSelections(parsed, owned)
return changed ? JSON.stringify(pruned) : document
}
/**
* An outfit as other players should see it: every selection naming something the wearer
* does not own is removed from each form the outfit carries (see the section note above).
* The outfit is returned as the same object when it loses nothing.
*/
export function restrictOutfitToOwned(outfit: Outfit, owned: OwnedOutfitItems): Outfit {
let changed = false
const out: JsonObject = { ...outfit }
const pruneString = (
target: JsonObject,
key: string,
prune: (value: string, owned: OwnedOutfitItems) => string
) => {
const value = target[key]
if (typeof value !== 'string') return
const pruned = prune(value, owned)
if (pruned !== value) {
target[key] = pruned
changed = true
}
}
const pruneArray = (target: JsonObject, key: string) => {
const [pruned, arrayChanged] = pruneSelections(target[key], owned)
if (arrayChanged) {
target[key] = pruned
changed = true
}
}
// The newer envelope's legacy block, copied so the stored object is not written to.
if (isObject(outfit.LegacyData)) {
const legacy: JsonObject = { ...outfit.LegacyData }
pruneString(legacy, 'SelectionsV1', pruneLegacySelections)
pruneString(legacy, 'SelectionsV2', pruneJsonDocument)
out.LegacyData = legacy
}
pruneString(out, 'CustomizationSettings', pruneJsonDocument)
pruneArray(out, 'Selections')
// Econ's flat shape.
pruneString(out, 'OutfitSelections', pruneLegacySelections)
pruneString(out, 'OutfitSelectionsV2', pruneJsonDocument)
pruneArray(out, 'CustomAvatarItems')
return changed ? (out as Outfit) : outfit
}
/**
* What each of several players may wear — the ownership behind
* {@link restrictOutfitToOwned}, read for the bulk outfit read's whole roster in one
* round trip. `freeBases` are the base assets every player has (the default catalog and
* the custom-clothing base items), folded into each player's set.
*
* Three reads batched: bought items (`inventory`, keyed by `AvatarItemDesc`, reduced to the
* base asset), bought custom items (`inventory_custom`) and made custom items
* (`custom_avatar_item` by creator) — the two sources econ's owned-items list joins. Ids are
* de-duplicated and the caller must already have held the list to
* {@link MAX_BULK_OUTFIT_ACCOUNTS}, D1's parameter cap. Every account asked for gets an
* entry, if only the free set.
*/
export async function getOwnedOutfitItemsByAccounts(
db: D1Database,
accountIds: number[],
freeBases: ReadonlySet<string>
): Promise<Map<number, OwnedOutfitItems>> {
const ids = [...new Set(accountIds)]
const owned = new Map<number, OwnedOutfitItems>()
for (const id of ids) owned.set(id, { bases: new Set(freeBases), customItemIds: new Set() })
if (ids.length === 0) return owned
const placeholders = ids.map((_, n) => `?${n + 1}`).join(', ')
const [items, bought, made] = await db.batch<{ account_id: number; value: string }>([
db
.prepare(
`SELECT account_id, avatar_item_desc AS value FROM inventory WHERE account_id IN (${placeholders})`
)
.bind(...ids),
db
.prepare(
`SELECT account_id, custom_avatar_item_id AS value FROM inventory_custom WHERE account_id IN (${placeholders})`
)
.bind(...ids),
db
.prepare(
`SELECT creator_account_id AS account_id, custom_avatar_item_id AS value
FROM custom_avatar_item WHERE creator_account_id IN (${placeholders})`
)
.bind(...ids),
])
for (const row of items.results) owned.get(row.account_id)?.bases.add(outfitItemBase(row.value))
for (const row of [...bought.results, ...made.results]) {
if (typeof row.value === 'string') {
owned.get(row.account_id)?.customItemIds.add(row.value.toLowerCase())
}
}
return owned
}