diff --git a/apps/api/migrations/0017_report_custom_avatar_item.sql b/apps/api/migrations/0017_report_custom_avatar_item.sql new file mode 100644 index 0000000..a357b30 --- /dev/null +++ b/apps/api/migrations/0017_report_custom_avatar_item.sql @@ -0,0 +1,26 @@ +-- Reporting a CUSTOM AVATAR ITEM (`POST /api/customAvatarItems/v1/{id}/report`) reuses the +-- report table, exactly as the event and invention reports beside it do: same fields, same +-- moderation life — a moderator acting on one sets `banned` on the row as they would for a +-- player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync. +-- +-- `custom_avatar_item_id` names the reported item. TEXT, not INTEGER, because a custom +-- avatar item is keyed by a GUID (`aeef6bfa-09b1-4859-a77c-47a6e7523543`) where an event and +-- an invention are keyed by a number — the three columns are the same idea in three key +-- types, which is why they are three columns rather than one polymorphic id. +-- +-- NULL on every other kind of report. It joins `event_id` and `invention_id`, and all three +-- are mutually exclusive: a row names an event, an invention, an item, or none of them (an +-- ordinary player report), which is what tells the kinds apart. +-- +-- The row's `reported_player_id` is the item's CREATOR, read from the item rather than sent +-- by the client. The client sends `ReportedPlayerId: null` on this route — it does not know +-- who made the item — and the column is NOT NULL, so deriving it is the only way to fill it, +-- and "who is answerable for this item" is the only honest answer anyway. +-- +-- No `room_id`: an item is not tied to one room the way an event is. +-- +-- NOT indexed, for the same reason 0016 gave and then dropped 0011's index over `event_id`: +-- it is written on every report of this kind and read by nothing. Every moderation read goes +-- by player (`idx_report_reported`) or by the ban flag. Add an index with the query needing it. + +ALTER TABLE report ADD COLUMN custom_avatar_item_id TEXT; diff --git a/apps/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts index 828cd8b..35a4d0a 100644 --- a/apps/api/src/custom-avatar-items-db.ts +++ b/apps/api/src/custom-avatar-items-db.ts @@ -249,6 +249,103 @@ export async function listHotCustomAvatarItems( return results.map(toDto) } +/** + * The "Coach" system account — this server's stock content is authored by it, the same id the + * `econ` worker attributes a self-buy or an anonymous gift to. + */ +export const COACH_ACCOUNT_ID = 1 + +/** What `GET /api/customAvatarItems/v1/search` narrows the catalog by. */ +export interface CustomAvatarItemSearch { + /** Free text, matched against an item's NAME or its DESCRIPTION. Blank means no filter. */ + searchQuery?: string + /** + * `OutfitType`s to include. EMPTY means no filter rather than no results: the client sends + * the full set of types it can render, so an absent parameter is "everything", not "nothing". + */ + outfitTypes?: number[] + /** Whether items authored by the Coach — this server's stock content — are included. */ + includeCoachItems?: boolean + /** Lowest price to include, inclusive. */ + minPrice?: number + /** Highest price to include, inclusive. */ + maxPrice?: number + /** Rows to skip, for paging. */ + skip?: number + /** Rows to return. Capped at {@link SEARCH_MAX_TAKE}. */ + take?: number +} + +/** The most rows one search returns, whatever `take` asks for. The client asks for 100. */ +export const SEARCH_MAX_TAKE = 200 + +/** + * The store's item search (`GET /api/customAvatarItems/v1/search`), newest first. + * + * PUBLISHED items only — `Accessibility` 0 is the unpublished state, and this is the browse + * surface everyone shares, so an unpublished item must not appear here even to its creator (who + * has `fromCreator` for that). + * + * `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring. + * Both sides are lowered rather than relying on `LIKE`, which folds case for ASCII only and + * would miss half of what players type. `%` and `_` in the needle are escaped, so searching for + * a literal one finds it instead of matching everything. + * + * `outfitTypes` is a WHITELIST when non-empty and no filter when empty, which is the opposite of + * how an empty IN () clause reads in SQL: the client sends every type it can render, so treating + * an absent parameter as "match nothing" would empty the store. + * + * Ordered by recency because there is nothing else to order by — no purchase counts, no wear + * counts, no ratings are recorded — which is the same stand-in the `hot` feed makes. The + * `custom_avatar_item_id` tiebreak is what makes paging stable: without it, two items sharing a + * `created_at` can swap places between pages and one is served twice while the other is missed. + */ +export async function searchCustomAvatarItems( + db: D1Database, + search: CustomAvatarItemSearch = {} +): Promise { + const take = Math.min(Math.max(search.take ?? 50, 0), SEARCH_MAX_TAKE) + const skip = Math.max(search.skip ?? 0, 0) + if (take === 0) return [] + + const where = ['accessibility != 0'] + const binds: Array = [] + /** Bind a value and get its placeholder, so the numbering can't drift as clauses are added. */ + const bind = (value: number | string): string => `?${binds.push(value)}` + + const needle = search.searchQuery?.trim() ?? '' + if (needle !== '') { + // Escaped so a needle of LIKE metacharacters matches them literally rather than everything. + const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`) + const pattern = bind(`%${escaped}%`) + where.push( + `(lower(name) LIKE ${pattern} ESCAPE '\\' OR lower(description) LIKE ${pattern} ESCAPE '\\')` + ) + } + + const outfitTypes = search.outfitTypes ?? [] + if (outfitTypes.length > 0) { + where.push(`outfit_type IN (${outfitTypes.map((t) => bind(t)).join(', ')})`) + } + if (search.includeCoachItems === false) { + where.push(`creator_account_id != ${bind(COACH_ACCOUNT_ID)}`) + } + if (search.minPrice !== undefined) where.push(`price >= ${bind(search.minPrice)}`) + if (search.maxPrice !== undefined) where.push(`price <= ${bind(search.maxPrice)}`) + + const limit = bind(take) + const offset = bind(skip) + const { results } = await db + .prepare( + `SELECT * FROM custom_avatar_item WHERE ${where.join(' AND ')} + ORDER BY created_at DESC, custom_avatar_item_id + LIMIT ${limit} OFFSET ${offset}` + ) + .bind(...binds) + .all() + return results.map(toDto) +} + /** * What an account has authored (`GET /api/customAvatarItems/v2/fromCreator/:id`), newest * first, with the total for the client's paginated envelope. `includeUnpublished` is for diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index cd695a4..48fd0ff 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -344,12 +344,30 @@ export async function ownsAllInventions( } /** - * Invention search — the browse/search list the client shows when picking an - * invention to spawn. Only published, non-hidden inventions are visible here (a - * player's own unpublished ones come from `getInventionsByCreator`). `value` is - * matched case-insensitively against the name and description, term by term; an - * empty `value` browses everything published. Paginated via skip/take, newest - * first. Returns a bare array — the shape the client expects from v2/search. + * Invention search — the browse/search list the client shows when picking an invention to + * spawn. Only published, non-hidden inventions are visible here (a player's own unpublished + * ones come from `getInventionsByCreator`). Newest first, paginated via skip/take. Returns a + * bare array — the shape the client expects from v2/search. + * + * `value` is split into terms on whitespace and `+`, and EVERY term must match (AND, not OR), + * which is what makes typing more words narrow the list. Each is matched case-insensitively + * against the NAME and the DESCRIPTION. + * + * Filtered, ordered and paged entirely IN SQL. It used to read every published invention into + * memory, filter there and slice — which meant the cost of a search grew with the whole + * catalogue no matter how narrow the query or how small the page, and a browse screen asking + * for 100 rows paid for all of them. `Name`, `Description` and `CreatedAt` live inside the JSON + * blob, so they are reached with `json_extract`; `is_published`/`hide_from_player` are already + * generated columns. + * + * Both sides of the comparison are lowered rather than leaning on `LIKE`, which folds case for + * ASCII only — and invention names are full of things it would not fold. `%` and `_` in a term + * are escaped so a player searching for one finds it instead of matching everything. + * + * A term starting with `#` is NOT special here: the browse screen's filter chips send `#small`, + * and a tag appears in no name or description, so those searches find nothing. Matching tags + * needs them out of the JSON blob and into something indexable first; until then this stays a + * text search rather than one that scans every row to look at its tags. */ export async function searchInventions( db: D1Database, @@ -357,22 +375,39 @@ export async function searchInventions( skip: number, take: number ): Promise { - let inventions = await publicInventions(db) + const limit = Math.max(take, 0) + const offset = Math.max(skip, 0) + if (limit === 0) return [] - const terms = value + const where = ['is_published = 1', 'hide_from_player = 0'] + const binds: Array = [] + /** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */ + const bind = (v: string | number): string => `?${binds.push(v)}` + + for (const term of value .trim() .toLowerCase() .split(/[\s+]+/) - .filter(Boolean) - for (const term of terms) { - inventions = inventions.filter( - (i) => i.Name.toLowerCase().includes(term) || i.Description.toLowerCase().includes(term) + .filter(Boolean)) { + const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`) + const pattern = bind(`%${escaped}%`) + where.push( + `(lower(json_extract(data, '$.Name')) LIKE ${pattern} ESCAPE '\\'` + + ` OR lower(json_extract(data, '$.Description')) LIKE ${pattern} ESCAPE '\\')` ) } - return inventions - .sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId) - .slice(skip, skip + take) + const limitAt = bind(limit) + const offsetAt = bind(offset) + const { results } = await db + .prepare( + `SELECT data FROM invention WHERE ${where.join(' AND ')} + ORDER BY json_extract(data, '$.CreatedAt') DESC, id DESC + LIMIT ${limitAt} OFFSET ${offsetAt}` + ) + .bind(...binds) + .all() + return results.map((r) => JSON.parse(r.data) as SavedInvention) } /** @@ -690,6 +725,14 @@ export async function getInventionsByRoom( .slice(skip, skip + take) } +/** + * The `version` that means "whichever is current" rather than a version number to match. + * + * Zero is not a version any invention has — a fresh save is version 1 — so a caller sending + * it does not know which version it wants, and reading it literally finds nothing. + */ +const CURRENT_INVENTION_VERSION = 0 + /** * A single version of an invention (`v1/version?inventionId=…&version=…`), which * is how the client resolves the blob to download for a given version number. @@ -698,6 +741,13 @@ export async function getInventionsByRoom( * (there's no `v4/addversion` yet), and a fresh save is always version 1. So this * answers for the current version number and reports null for any other, rather * than inventing a version whose blob doesn't exist. + * + * VERSION 0 is the exception: it means "whichever version is current" rather than a + * version number to match, and gets {@link CURRENT_INVENTION_VERSION}. The client asks + * for 0 when it has an invention id but no version to go with it — a discovery row or a + * spawn that carries the id alone — and there is no version 0 to find, so matching it + * literally 404s and the invention silently fails to load. Answering with the current + * version is what it would have asked for had it known the number. */ export async function getInventionVersion( db: D1Database, @@ -707,7 +757,12 @@ export async function getInventionVersion( ): Promise { const invention = await getInventionById(db, inventionId) if (invention === null) return null - if (invention.CurrentVersionNumber !== versionNumber) return null + if ( + versionNumber !== CURRENT_INVENTION_VERSION && + invention.CurrentVersionNumber !== versionNumber + ) { + return null + } // A version saved before its blob finished uploading (or before we hashed on // save at all) carries no hash. Hash it now and keep the result, so the other diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index e5ff5ab..9ac1fae 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -577,7 +577,7 @@ export const BulkCustomAvatarItemsRequest = z.object({ .describe('The ids to resolve; repeat the field once per id'), }) -/** A paginated custom-avatar-item page (no storage yet, so always empty). */ +/** A paginated custom-avatar-item page, out of the `custom_avatar_item` table. */ export const CustomAvatarItemsPage = z.object({ Results: CustomAvatarItemList, TotalResults: z.int(), @@ -972,6 +972,24 @@ export const PlayerEventReportRequest = z.object({ * body: it's the bearer token's player, and neither is the invention's creator, who is * read from the invention. */ +/** + * `POST /api/customAvatarItems/v1/{id}/report` JSON body. The item is named by the PATH, not + * the body, and `ReportedPlayerId` arrives NULL — the client does not know who made the item, + * so the creator is read off the item instead. + */ +export const CustomAvatarItemReportRequest = z.object({ + ReportCategory: z + .int() + .optional() + .describe('The reason picked in the report UI. Stored verbatim; unmapped'), + Details: z.string().optional().describe('The free-text description the reporter typed'), + ReportedPlayerId: z + .int() + .nullable() + .optional() + .describe('Sent as null and IGNORED — the reported player is the item’s creator'), +}) + export const InventionReportRequest = z.object({ InventionId: z.int().describe('The invention being reported'), ReportCategory: z diff --git a/apps/api/src/reports-db.ts b/apps/api/src/reports-db.ts index a5428ce..791a1ef 100644 --- a/apps/api/src/reports-db.ts +++ b/apps/api/src/reports-db.ts @@ -7,15 +7,17 @@ * what a player submitted: the table is a log of exactly what was reported. * * The `api` worker owns this schema/migration (migrations/0004_report.sql, - * 0009_report_ban.sql, 0011_report_event.sql and 0016_report_invention.sql, applied under - * its own `migrations_table` so it doesn't clash with the other workers' migrations that - * share the database). + * 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql and + * 0017_report_custom_avatar_item.sql, applied under its own `migrations_table` so it + * doesn't clash with the other workers' migrations that share the database). * - * A reported player EVENT or INVENTION lands here too, rather than in a table of its own: - * same fields, same moderation life. Such a row carries `event_id` or `invention_id`, and - * its `reported_player_id` is that thing's CREATOR — see - * `POST /api/playerevents/v1/report` and `POST /api/inventions/v1/report`. The two id - * columns are mutually exclusive; a row with neither is an ordinary player report. + * A reported player EVENT, INVENTION or CUSTOM AVATAR ITEM lands here too, rather than in a + * table of its own: same fields, same moderation life. Such a row carries `event_id`, + * `invention_id` or `custom_avatar_item_id`, and its `reported_player_id` is that thing's + * CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and + * `POST /api/customAvatarItems/v1/{id}/report`. The three id columns are mutually exclusive; + * a row with none of them is an ordinary player report. They are three columns rather than + * one polymorphic id because the keys differ in TYPE: two numbers and a guid. * * A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned` * on that same row (see `banFromReport`), so the ban carries the evidence for it. Two @@ -30,12 +32,13 @@ /** * Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql + - * 0011_report_event.sql + 0016_report_invention.sql). + * 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql). * - * Neither `event_id` nor `invention_id` is indexed: both are written on every report of - * their kind and read by nothing — no query here filters on either, and the reads that do - * exist go by player or by the ban flag. 0011's partial index over `event_id` was dropped - * in 0016 rather than mirrored. Add one back alongside the query that needs it. + * None of `event_id`, `invention_id` or `custom_avatar_item_id` is indexed: each is written + * on every report of its kind and read by nothing — no query here filters on any of them, + * and the reads that do exist go by player or by the ban flag. 0011's partial index over + * `event_id` was dropped in 0016 rather than mirrored. Add one back alongside the query that + * needs it. */ export const SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS report ( @@ -52,7 +55,8 @@ export const SCHEMA_DDL: string[] = [ banned INTEGER NOT NULL DEFAULT 0, ban_expires TEXT, event_id INTEGER, - invention_id INTEGER + invention_id INTEGER, + custom_avatar_item_id TEXT )`, `CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`, `CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`, @@ -90,6 +94,15 @@ export interface ReportRow { * invention isn't tied to one room the way an event is. */ invention_id: number | null + /** + * The custom avatar item this report is against, or NULL for any other kind — mutually + * exclusive with the two above. TEXT because such an item is keyed by a GUID where an + * event and an invention are keyed by numbers. See + * `POST /api/customAvatarItems/v1/{id}/report`: `reported_player_id` is the item's + * creator, read from the item, because the client sends `ReportedPlayerId: null` here — + * it does not know who made it. + */ + custom_avatar_item_id: string | null } /** @@ -111,6 +124,8 @@ export interface NewReport { eventId?: number | null /** Set only when reporting an INVENTION; never set alongside `eventId`. */ inventionId?: number | null + /** Set only when reporting a CUSTOM AVATAR ITEM; never set alongside the two above. */ + customAvatarItemId?: string | null } /** Record a submitted report, returning the stored row (with its assigned id). */ @@ -120,8 +135,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise() // RETURNING always yields the inserted row; the non-null assert keeps the caller diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 647dfa6..d2d1372 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -16,9 +16,11 @@ import { createCustomAvatarItem, deleteCustomAvatarItem, getCustomAvatarItem, + getCustomAvatarItems, listCustomAvatarItemsByCreator, listFeaturedCustomAvatarItems, listHotCustomAvatarItems, + searchCustomAvatarItems, updateCustomAvatarItem, } from '../custom-avatar-items-db' import { authedId, unauthorized } from '../http' @@ -49,6 +51,7 @@ import { BulkCustomAvatarItemsRequest, CreateCustomAvatarItemRequest, CustomAvatarItemList, + CustomAvatarItemReportRequest, CustomAvatarItemResponse, CustomAvatarItemsPage, ErrorResponse, @@ -92,6 +95,42 @@ import type { Context } from 'hono' import type { App } from '../context' import type { SavedInvention } from '../inventions-db' +/** + * The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers + * EMPTY rather than being truncated. + * + * Empty rather than the first 100, because a truncated answer is indistinguishable from the + * items simply not existing — the client reads the items it got back, not the ids it asked + * about, so it cannot tell a cut-off batch from a batch of misses and would cache the + * difference. Nothing renders this many custom items at once, so a batch this size is the + * client doing something other than filling a screen. + */ +const BULK_CUSTOM_AVATAR_ITEM_CAP = 100 + +/** + * 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 + * too — the client's exact encoding here has not been pinned down, so both are accepted + * rather than guessing one and answering nothing when it's the other. + * + * Each value may itself be a comma-separated list, and blanks are dropped rather than + * failing the request: a stray id must not cost the caller the rest of the batch. The order + * asked for is preserved, since `getCustomAvatarItems` answers in it. + */ +async function bulkCustomAvatarItemIds(c: Context): Promise { + const raw = [...(c.req.queries('customAvatarItemIds') ?? [])] + const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record) + const key = Object.keys(body).find((k) => k.toLowerCase() === 'customavataritemids') + const posted = key === undefined ? [] : body[key] + for (const value of Array.isArray(posted) ? posted : [posted]) { + if (typeof value === 'string') raw.push(value) + } + return raw + .flatMap((value) => value.split(',')) + .map((v) => v.trim()) + .filter((v) => v !== '') +} + /** * The gate every invention write runs through: the caller must be signed in, the * invention must exist, and it must be theirs. Yields the loaded invention, or the @@ -450,6 +489,122 @@ export const avatarRoutes = new Hono({ strict: false }) async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB)) ) + // The store's item search. The client sends the full set of `outfitTypes` it can render + // plus paging, and expects a BARE ARRAY of items back — not the `{ Results, TotalResults }` + // envelope `fromCreator` uses. + // + // Several parameters are accepted and not yet acted on; they are listed in the description + // rather than dropped silently, because a caller cannot tell the difference between a filter + // that was applied and one that was ignored by looking at the results. + .get( + '/api/customAvatarItems/v1/search', + describeRoute({ + tags: ['Avatar'], + summary: 'Search custom avatar items', + description: [ + 'The store’s item search: published items (`Accessibility` 0 is unpublished and is', + 'left out, from its creator too — `fromCreator` is where they see their own),', + 'newest first, as a BARE ARRAY.', + '`searchQuery` matches an item’s NAME or its DESCRIPTION, case-insensitively, as a', + 'substring; `%` and `_` in it are literal.', + '`outfitTypes` may repeat and acts as a whitelist; sending none means no filter', + 'rather than no results, since the client sends every type it can render.', + '`minPrice`/`maxPrice` bound the price, inclusive.', + '`skip`/`take` page the results, `take` capped at 200.', + '`includeCoachItems=false` leaves out this server’s stock content.', + '`itemTypes`, `ordering`, `unityAssetTarget` and `unityAssetVersion` are accepted and', + 'NOT yet acted on — nothing records purchase or wear counts to rank by, no per-target', + 'asset variants are stored, and custom avatar items are the only item type there is.', + '`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,', + 'whatever it says.', + ].join(' '), + parameters: [ + { + name: 'searchQuery', + in: 'query', + required: false, + description: 'Free text matched against the item’s name or description', + schema: { type: 'string' }, + }, + { + name: 'outfitTypes', + in: 'query', + required: false, + description: 'OutfitType to include; repeat for several. None means all.', + schema: { type: 'array', items: { type: 'integer' } }, + }, + { + name: 'skip', + in: 'query', + required: false, + description: 'Rows to skip (default 0)', + schema: { type: 'integer', minimum: 0 }, + }, + { + name: 'take', + in: 'query', + required: false, + description: 'Rows to return (default 50, capped at 200)', + schema: { type: 'integer', minimum: 0 }, + }, + { + name: 'minPrice', + in: 'query', + required: false, + description: 'Lowest price to include, inclusive', + schema: { type: 'integer', minimum: 0 }, + }, + { + name: 'maxPrice', + in: 'query', + required: false, + description: 'Highest price to include, inclusive', + schema: { type: 'integer', minimum: 0 }, + }, + { + name: 'includeCoachItems', + in: 'query', + required: false, + description: 'Include the Coach’s stock items (default true)', + schema: { type: 'boolean' }, + }, + ], + responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') }, + }), + async (c) => { + // `?outfitTypes=0&outfitTypes=2&…` — repeated, so read every value. A non-numeric one is + // dropped rather than turned into NaN, which would match nothing and quietly empty a + // filter the caller believes they set. + const outfitTypes = c.req + .queries('outfitTypes') + ?.map((v) => Number.parseInt(v, 10)) + .filter((n) => Number.isInteger(n)) + + // The client capitalises its booleans (`includeCoachItems=True`), so this is folded + // before comparing; anything that isn't recognisably false leaves the default alone. + const includeCoachItems = c.req.query('includeCoachItems')?.toLowerCase() !== 'false' + + const int = (name: string): number | undefined => { + const raw = c.req.query(name) + if (raw === undefined) return undefined + const n = Number.parseInt(raw, 10) + return Number.isInteger(n) ? n : undefined + } + + return c.json( + await searchCustomAvatarItems(c.env.DB, { + searchQuery: c.req.query('searchQuery'), + outfitTypes, + includeCoachItems, + minPrice: int('minPrice'), + maxPrice: int('maxPrice'), + skip: int('skip'), + take: int('take'), + }) + ) + } + ) + // The "hot" (trending) custom-avatar-item feed: every published (`Accessibility` != 0) // item from the `custom_avatar_item` table. There is nothing to rank a trend from yet, // so it is the accessible items, newest first. @@ -467,13 +622,23 @@ export const avatarRoutes = new Hono({ strict: false }) async (c) => c.json(await listHotCustomAvatarItems(c.env.DB)) ) - // A batch lookup of custom avatar items by id. The reference filters a static catalog - // down to the posted ids and returns the MATCHES AS A BARE ARRAY — not the - // `{ Results, TotalResults }` page its catalog file is written in, and not a 404 for - // ids it doesn't hold. Nothing stores custom items here (the reference's own catalog - // ships empty too), so every id misses and the array is empty. + // A batch lookup of custom avatar items by id, out of the `custom_avatar_item` table. + // The reference filters its catalog down to the posted ids and returns the MATCHES AS A + // BARE ARRAY — not the `{ Results, TotalResults }` page that catalog is written in, and + // not a 404 for ids it doesn't hold. + // + // This is how a `1.` entity in a GENERIC discovery row (`lists` + // `/algorithmiclists/:list?type=5`) gets resolved, so a row naming a custom item renders + // nothing at all when this doesn't answer. It stubbed out `[]` while nothing stored custom + // items; the table has existed since migration 0015 and the stub outlived it. // // Auth-gated, and the token is checked before anything else, as the reference does. + // + // A batch over {@link BULK_CUSTOM_AVATAR_ITEM_CAP} ids answers EMPTY. The client has been + // seen posting far more ids than a screen could draw, and serving those is both a large + // query and a large response for a request that is already not what it looks like. Empty is + // the safe answer because a miss here is not an error: unknown ids are simply absent, so the + // client already handles getting back fewer items than it asked about. .post( '/api/customAvatarItems/v1/bulk', describeRoute({ @@ -481,25 +646,46 @@ export const avatarRoutes = new Hono({ strict: false }) summary: 'Custom avatar items in bulk', description: 'Resolves a batch of custom-avatar-item ids to their items: the posted ' + - '`customAvatarItemIds` filtered against the catalog, returned as a BARE ARRAY of ' + - 'the ones that matched. Not the `{ Results, TotalResults }` page the sibling ' + - 'custom-item reads serve — the reference keeps its catalog in that shape but ' + - 'answers this route with the filtered array alone.\n\n' + + '`customAvatarItemIds` filtered against the `custom_avatar_item` table, returned ' + + 'as a BARE ARRAY of the ones that matched, in the order they were asked for. Not ' + + 'the `{ Results, TotalResults }` page the sibling custom-item reads serve — the ' + + 'reference keeps its catalog in that shape but answers this route with the ' + + 'filtered array alone.\n\n' + 'A miss is not an error: unknown ids are simply absent from the response, and the ' + - 'client reads the items it got back rather than the ids it asked for. Nothing ' + - 'stores custom items here, so every id misses and this is always `[]` — which is ' + - 'why the posted ids are not parsed.', + 'client reads the items it got back rather than the ids it asked for. Unpublished ' + + 'items (`Accessibility` 0) miss for everyone but their creator, the same rule the ' + + 'feeds and the creator shelf apply.\n\n' + + 'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' + + 'and the same spelling on the query string are both accepted, since the client’s ' + + 'exact encoding here has not been pinned down.\n\n' + + 'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' + + 'client has been seen posting more than a screen could draw, and a miss is already ' + + 'not an error here.', security: AUTHED, requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'), responses: { - 200: json(JsonArray, 'The matching items — always empty here'), + 200: json(CustomAvatarItemList, 'The items that matched, in request order'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - return c.json([]) + + const ids = await bulkCustomAvatarItemIds(c) + + // Over the cap: empty, and the table is not touched. Answering the batch would be a + // large query and a large response for a request that is already not what it looks + // like — a screen does not draw this many items. + if (ids.length > BULK_CUSTOM_AVATAR_ITEM_CAP) return c.json([]) + + const items = await getCustomAvatarItems(c.env.DB, ids) + // Unpublished items are held back from everyone but their creator — the same rule + // the featured/hot feeds and the creator shelf apply, so an item can't be surfaced + // through this route that the feeds hide. + return c.json( + items.filter((item) => item.Accessibility !== 0 || item.CreatorAccountId === id) + ) } ) @@ -872,7 +1058,10 @@ export const avatarRoutes = new Hono({ strict: false }) // RRInventionVersion, which carries the blob name the client downloads and the // SHA-256 of that blob. Public. Only the current version exists (nothing writes // version history yet), so any other version number 404s rather than naming a - // blob that isn't there. + // blob that isn't there — except `version=0`, which means "whichever is current" + // rather than a number to match. Nothing has a version 0, so a caller sending it + // doesn't know which version it wants, and matching it literally 404s an invention + // that exists. .get( '/api/inventions/v1/version', describeRoute({ @@ -883,15 +1072,19 @@ export const avatarRoutes = new Hono({ strict: false }) 'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' + 'never uploaded). Only the current version exists — nothing writes version ' + 'history yet — so any other version number 404s rather than naming a blob that ' + - 'is not there.', + 'is not there.\n\n' + + '`version=0` is the exception: it means “whichever is current” rather than a ' + + 'number to match, and gets the current version. No invention has a version 0 — a ' + + 'fresh save is version 1 — so a caller sending it does not know which version it ' + + 'wants, and matching it literally 404s an invention that exists.', parameters: [ intQuery('inventionId', 'Invention id; required'), - intQuery('version', 'Version number; required'), + intQuery('version', 'Version number; required. `0` means the current version'), ], responses: { 200: json(InventionVersionDto, 'The version'), 400: json(ErrorResponse, 'Missing inventionId or version'), - 404: { description: 'No such invention, or not the current version' }, + 404: { description: 'No such invention, or a version number that is not the current one' }, }, }), async (c) => { @@ -1251,15 +1444,20 @@ export const avatarRoutes = new Hono({ strict: false }) // Invention search/browse: published inventions matching `value` (matched against // name + description; absent → browse everything published), newest first. // Paginated via skip/take (take defaults to 100). Returns a bare array. + // + // Filtered, ordered and paged in SQL — it must not read the catalogue into memory to + // answer one page. .get( '/api/inventions/v2/search', describeRoute({ tags: ['Inventions'], summary: 'Search / browse inventions', description: - 'Published inventions matching `value` (matched against name and description), ' + - 'newest first. An absent `value` browses everything published — that is the ' + - 'browse screen’s initial request.', + 'Published inventions matching `value`, newest first. `value` is split into terms ' + + 'and every term must match, each against the name and the description. An absent ' + + '`value` browses everything published — that is the browse screen’s initial ' + + 'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' + + 'chips is treated as text and matches nothing.', parameters: [ stringQuery('value', 'Search text; absent browses everything'), ...pageParams(100), @@ -1301,6 +1499,75 @@ export const avatarRoutes = new Hono({ strict: false }) } ) + // Report a custom avatar item. Stored in the `report` table the player, event and invention + // reports use — same fields, same moderation life — with `custom_avatar_item_id` set. See + // migrations/0017_report_custom_avatar_item.sql. + // + // The item is named by the PATH, not the body, which is what distinguishes this from its + // siblings; the body's `ReportedPlayerId` arrives NULL and is ignored, since the client does + // not know who made the item. + .post( + '/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}/report', + describeRoute({ + tags: ['Avatar', 'Moderation'], + summary: 'Report a custom avatar item', + description: + 'Files a report against a custom avatar item, named by the PATH. Stored as a row in ' + + 'the same `report` table a player report goes to (`POST /api/PlayerReporting/v3/create`), ' + + 'an event report and an invention report — the same submission with the same ' + + 'moderation life, which a moderator converts into a ban the same way. What marks it ' + + 'as an item report is `custom_avatar_item_id`; the row’s `reported_player_id` is the ' + + 'item’s CREATOR, read from the item. The body’s `ReportedPlayerId` is sent as null ' + + 'and IGNORED even when set — the client does not know who made the item, and letting ' + + 'a client name who a report is against would let it point one at anybody. Nothing ' + + 'fills `room_id`: an item isn’t tied to one room the way an event is.\n\n' + + 'The reporter is the caller (from the bearer token), never a body field. ' + + '`ReportCategory` is stored verbatim — the enum is not mapped here. Nothing dedupes ' + + 'the rows: reporting the same item twice files two reports, and reporting your own ' + + 'is allowed rather than being a special case.\n\n' + + 'Answers the `{ success, error }` envelope the event and invention reports use, ' + + '`error` being an empty string rather than null, on the rejected branches too so ' + + 'there is only one shape to parse.', + security: AUTHED, + parameters: [idParam('id', 'The custom avatar item’s guid')], + requestBody: jsonBody(CustomAvatarItemReportRequest, 'The report'), + responses: { + 200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'), + 401: UNAUTHORIZED_RESPONSE, + 404: json(SuccessErrorEnvelope, 'No such custom avatar item'), + }, + }), + async (c) => { + const reporterId = await authedId(c) + if (reporterId === null) return unauthorized(c) + + const customAvatarItemId = c.req.param('id') + + // The item supplies the reported player. An unknown item is refused rather than filed + // against nobody: the row's reported player has to be someone, and a report naming an + // item that never existed isn't actionable. + const item = await getCustomAvatarItem(c.env.DB, customAvatarItemId) + if (item === null) return c.json({ success: false, error: 'No such item' }, 404) + + // A body that won't parse is not a reason to lose the report: the path already names + // what is being reported and the token names who reported it, so an unreadable body + // costs the category and the description, not the row. + const body = await c.req + .json<{ ReportCategory?: unknown; Details?: unknown }>() + .catch(() => ({}) as Record) + const category = Number(body.ReportCategory) + await createReport(c.env.DB, { + reporterPlayerId: reporterId, + reportedPlayerId: item.CreatorAccountId, + reportCategory: Number.isInteger(category) ? category : 0, + details: typeof body.Details === 'string' ? body.Details : null, + customAvatarItemId, + }) + + return c.json({ success: true, error: '' }) + } + ) + // Report an invention. Stored in the `report` table the player and event reports use — // same fields, same moderation life — with `invention_id` set. See // migrations/0016_report_invention.sql. diff --git a/apps/api/src/routes/gameplay.ts b/apps/api/src/routes/gameplay.ts index 6683ab7..63ad91d 100644 --- a/apps/api/src/routes/gameplay.ts +++ b/apps/api/src/routes/gameplay.ts @@ -227,6 +227,32 @@ export const gameplayRoutes = new Hono({ strict: false }) }), (c) => c.json(communityBoard) ) + // Circuit chip lists — the palettes the Maker Pen's circuit board groups its chips into + // (`/api/CircuitChipLists/Favorites`, `/api/CircuitChipLists/Recent`, and so on). The path + // segment names the list; nothing here records which chips a player has used or favourited, + // so every one of them is empty. + // + // EMPTY rather than 404 for a name this server doesn't know: the client asks for whichever + // palettes its build has, and an unknown one is a palette this server has no opinion about + // rather than an error — a 404 shows as a palette that failed to load, where an empty list + // shows as one with nothing in it, which is the truth for all of them. + .get( + '/api/CircuitChipLists/:list', + describeRoute({ + tags: ['Gameplay'], + summary: 'One circuit chip list', + description: + 'A palette on the Maker Pen’s circuit board, named by the path (`Favorites`, ' + + '`Recent`, …). Always empty: nothing records which chips a player has used or ' + + 'favourited yet. An unknown name is empty too rather than a 404 — the client asks ' + + 'for whichever palettes its build has, and a 404 renders as a palette that failed ' + + 'to load rather than one with nothing in it.', + parameters: [stringParam('list', 'The palette name, e.g. `Favorites`')], + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) + // Player events live in their own controller (routes/events.ts) — they're D1-backed // now, unlike the stubs around them here. .get( diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index f495cb7..fcc0f18 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1001,6 +1001,27 @@ describe('public endpoints', () => { } }) + test('GET /api/CircuitChipLists/:list is empty for any name', async () => { + // A palette on the Maker Pen's circuit board, named by the path. Nothing records which + // chips a player has used or favourited, so every one of them is empty — including names + // this server has never heard of, which the client will ask for as its build changes. + // An unknown name being a 404 would render as a palette that FAILED to load rather than + // one with nothing in it. + for (const list of [ + 'Favorites', + 'Recent', + 'All', + 'SomePaletteThisServerHasNeverHeardOf', + // Path-segment oddities: a name that needs escaping, and a numeric one. + encodeURIComponent('Weird Name/With Slash'), + '42', + ]) { + const res = await exports.default.fetch(`${ORIGIN}/api/CircuitChipLists/${list}`) + expect(res.status, list).toBe(200) + expect(await res.json(), list).toEqual([]) + } + }) + test('GET /api/inventions/v1/featureddormskins returns []', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featureddormskins`) expect(res.status).toBe(200) @@ -1052,6 +1073,209 @@ describe('public endpoints', () => { } }) + test('GET /api/customAvatarItems/v1/search filters, pages and excludes unpublished', async () => { + await env.DB.prepare('DELETE FROM custom_avatar_item').run() + + const search = async (query: string) => { + const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`) + expect(res.status, query).toBe(200) + return (await res.json()) as Array<{ + CustomAvatarItemId: string + Name: string + OutfitType: number + PurchaseInfo: null + }> + } + + // A BARE ARRAY, not the `{ Results, TotalResults }` envelope `fromCreator` uses. + expect(await search('')).toEqual([]) + + const made: Record = {} + // Six published items across three outfit types, plus one unpublished and one Coach's. + // Creation times ascend with the index so "newest first" is unambiguous. + const spec: Array<[name: string, outfitType: number, accessibility: number, creator: number]> = + [ + ['Hat A', 0, 1, 205], + ['Shirt A', 2, 1, 205], + ['Shirt B', 2, 1, 205], + ['Trousers A', 3, 1, 205], + ['Coach Hat', 0, 1, 1], + ['Hidden', 0, 0, 205], + ] + for (const [i, [name, outfitType, accessibility, creator]] of spec.entries()) { + const created = await createCustomAvatarItem( + env.DB, + { + customAvatarItemId: crypto.randomUUID(), + creatorAccountId: creator, + name, + description: '', + price: 0, + baseAvatarItemId: 1, + baseAvatarItemColor: '#fff', + accessibility, + designFilename: 'design_x.bin', + thumbnailImageFilename: 'thumb_x.png', + }, + new Date(Date.UTC(2026, 7, 1 + i)) + ) + made[name] = created.CustomAvatarItemId + // Nothing sets outfit_type on creation yet, so set it straight in the table. + await env.DB.prepare( + 'UPDATE custom_avatar_item SET outfit_type = ?2 WHERE custom_avatar_item_id = ?1' + ) + .bind(created.CustomAvatarItemId, outfitType) + .run() + } + + // Newest first, and `Hidden` never appears: Accessibility 0 is unpublished, and this is + // the shared browse surface — its creator sees it through `fromCreator`, not here. + const all = await search('') + expect(all.map((i) => i.Name)).toEqual([ + 'Coach Hat', + 'Trousers A', + 'Shirt B', + 'Shirt A', + 'Hat A', + ]) + + // `outfitTypes` repeats and acts as a whitelist — the real client sends a dozen of them. + expect((await search('?outfitTypes=2')).map((i) => i.Name)).toEqual(['Shirt B', 'Shirt A']) + expect((await search('?outfitTypes=0&outfitTypes=3')).map((i) => i.Name)).toEqual([ + 'Coach Hat', + 'Trousers A', + 'Hat A', + ]) + + // Sending NONE means no filter, not no results: the client sends every type it can render, + // so reading an absent parameter as an empty `IN ()` would empty the store. + expect((await search('?skip=0&take=100')).map((i) => i.Name)).toEqual(all.map((i) => i.Name)) + + // A non-numeric value is dropped rather than becoming NaN, which would match nothing and + // quietly empty a filter the caller believes they set. + expect((await search('?outfitTypes=2&outfitTypes=nonsense')).map((i) => i.Name)).toEqual([ + 'Shirt B', + 'Shirt A', + ]) + + // Paging, and it is STABLE: consecutive pages must not repeat or skip a row, which the + // id tiebreak in the ordering is what guarantees when timestamps collide. + const page1 = await search('?skip=0&take=2') + const page2 = await search('?skip=2&take=2') + expect(page1.map((i) => i.Name)).toEqual(['Coach Hat', 'Trousers A']) + expect(page2.map((i) => i.Name)).toEqual(['Shirt B', 'Shirt A']) + expect( + page1.some((i) => page2.some((j) => j.CustomAvatarItemId === i.CustomAvatarItemId)) + ).toBe(false) + expect(await search('?skip=99&take=10')).toEqual([]) + expect(await search('?take=0')).toEqual([]) + + // The client capitalises its booleans (`includeCoachItems=True`), so the comparison folds + // case; only a recognisable "false" turns the stock content off. + expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toContain('Coach Hat') + expect((await search('?includeCoachItems=false')).map((i) => i.Name)).not.toContain('Coach Hat') + + // The whole query the client actually sends, unchanged — the parameters that aren't acted + // on yet must be accepted rather than 400 or throw. + const real = await search( + '?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' + + '&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' + + '&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' + + '&take=100&unityAssetTarget=0&unityAssetVersion=3' + ) + expect(real.map((i) => i.Name)).toEqual(all.map((i) => i.Name)) + // `includePurchaseInfos=True` notwithstanding: nothing prices a custom item here yet, so + // the field is null on every item and the parameter changes nothing. + expect(real.every((i) => i.PurchaseInfo === null)).toBe(true) + }) + + test('GET /api/customAvatarItems/v1/search matches name or description, and bounds price', async () => { + await env.DB.prepare('DELETE FROM custom_avatar_item').run() + + const search = async (query: string) => { + const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`) + expect(res.status, query).toBe(200) + return ((await res.json()) as Array<{ Name: string }>).map((i) => i.Name) + } + + const make = async (name: string, description: string, price: number, i: number) => + createCustomAvatarItem( + env.DB, + { + customAvatarItemId: crypto.randomUUID(), + creatorAccountId: 205, + name, + description, + price, + baseAvatarItemId: 1, + baseAvatarItemColor: '#fff', + accessibility: 1, + designFilename: 'design_x.bin', + thumbnailImageFilename: 'thumb_x.png', + }, + new Date(Date.UTC(2026, 7, 1 + i)) + ) + + await make('Room Hat', '', 100, 0) + // Matched on DESCRIPTION, not name — the two are searched together. + await make('Cosy Beanie', 'Warm in any ROOM', 500, 1) + // Matched case-insensitively and as a SUBSTRING, mid-word. + await make('Ballroom Shoes', '', 9000, 2) + await make('Unrelated Cap', 'nothing to do with it', 250, 3) + // A name holding LIKE metacharacters, for the escaping below. + await make('100% Wool', 'a_b', 50, 4) + + // Newest first throughout, so the order also proves the query didn't disturb the ordering. + expect(await search('?searchQuery=room')).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat']) + // Case folds both ways: SQLite's own LIKE only folds ASCII, so both sides are lowered. + expect(await search('?searchQuery=ROOM')).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat']) + expect(await search('?searchQuery=beanie')).toEqual(['Cosy Beanie']) + expect(await search('?searchQuery=nothing%20to%20do')).toEqual(['Unrelated Cap']) + expect(await search('?searchQuery=zzzz')).toEqual([]) + + // Blank or absent is NO filter, not an empty result — a cleared search box must show the + // store rather than nothing. + expect(await search('?searchQuery=')).toHaveLength(5) + expect(await search('?searchQuery=%20%20')).toHaveLength(5) + + // A needle of LIKE metacharacters matches them LITERALLY. Unescaped, `%` would match every + // item and `_` any single character, so a player searching for "100%" would get the lot. + expect(await search('?searchQuery=%25')).toEqual(['100% Wool']) + expect(await search('?searchQuery=a_b')).toEqual(['100% Wool']) + + // Price bounds, inclusive at both ends. + expect(await search('?minPrice=250&maxPrice=9000')).toEqual([ + 'Unrelated Cap', + 'Ballroom Shoes', + 'Cosy Beanie', + ]) + expect(await search('?maxPrice=100')).toEqual(['100% Wool', 'Room Hat']) + expect(await search('?minPrice=9000')).toEqual(['Ballroom Shoes']) + expect(await search('?minPrice=100000')).toEqual([]) + + // Combined with the text search, since the client sends both together. + expect(await search('?searchQuery=room&maxPrice=500')).toEqual(['Cosy Beanie', 'Room Hat']) + + // The whole query the client actually sends. `itemTypes` and the unity asset parameters are + // accepted and not acted on; `outfitTypes=105` matches nothing, so this is empty — which is + // the filter working, not the search failing. + expect( + await search( + '?searchQuery=room&itemTypes=-1&outfitTypes=105&minPrice=0&maxPrice=10000' + + '&includePurchaseInfos=True&includeCoachItems=False&ordering=0&skip=0&take=1000' + + '&unityAssetTarget=0&unityAssetVersion=3' + ) + ).toEqual([]) + // Same query with the outfit-type filter dropped: the rest of it does match. + expect( + await search( + '?searchQuery=room&itemTypes=-1&minPrice=0&maxPrice=10000&includePurchaseInfos=True' + + '&includeCoachItems=False&ordering=0&skip=0&take=1000&unityAssetTarget=0' + + '&unityAssetVersion=3' + ) + ).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat']) + }) + test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => { await env.DB.prepare('DELETE FROM custom_avatar_item').run() const base = { @@ -1131,29 +1355,110 @@ describe('public endpoints', () => { expect(await res.json()).toEqual([]) }) - // A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page - // the sibling custom-item reads serve. Nothing stores custom items, so every id - // misses, and a miss is an absent entry rather than an error. - test('POST /api/customAvatarItems/v1/bulk returns the matching items as an array', async () => { - const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, { + /** Create a custom avatar item and return it, so a bulk lookup has something to find. */ + async function createCustomItem( + creator: string, + metadata: Record + ): Promise<{ CustomAvatarItemId: string; Name: string }> { + const form = new FormData() + form.set( + 'metadata', + JSON.stringify({ + Name: 'bulk item', + Description: '', + Price: 0, + BaseAvatarItemId: 2184, + BaseAvatarItemColor: '#F55C1A', + Accessibility: 1, + ...metadata, + }) + ) + form.set('thumbnailImage', new File([new Uint8Array([1])], 'f.bin', { type: 'image/png' })) + form.set('design', new File([new Uint8Array([2])], 'f.bin', { type: 'image/png' })) + const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, { method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - ...(await bearer()), - }, - // Repeated form field, as `[FromForm] List` binds it. - body: new URLSearchParams([ - ['customAvatarItemIds', 'a'], - ['customAvatarItemIds', 'b'], - ]), + headers: await bearer(creator), + body: form, }) expect(res.status).toBe(200) - expect(await res.json()).toEqual([]) + return ((await res.json()) as { Value: { CustomAvatarItemId: string; Name: string } }).Value + } + + /** POST the bulk lookup with `ids` as repeated form fields, as the client binds them. */ + async function bulkLookup( + ids: string[], + as = '42' + ): Promise> { + const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer(as)) }, + // Repeated form field, as `[FromForm] List` binds it. + body: new URLSearchParams(ids.map((id) => ['customAvatarItemIds', id])), + }) + expect(res.status).toBe(200) + return (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }> + } + + // A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page + // the sibling custom-item reads serve — resolved out of the `custom_avatar_item` table. + // This is what a `1.` entity in a Generic discovery row resolves through, so it + // answering `[]` (as it did while it was a stub) renders that row's items as nothing. + test('POST /api/customAvatarItems/v1/bulk resolves the posted ids against the table', async () => { + const first = await createCustomItem('205', { Name: 'bulk one' }) + const second = await createCustomItem('205', { Name: 'bulk two' }) + + // In REQUEST order, not creation order — the client reads the array positionally. + const items = await bulkLookup([second.CustomAvatarItemId, first.CustomAvatarItemId]) + expect(items.map((i) => i.CustomAvatarItemId)).toEqual([ + second.CustomAvatarItemId, + first.CustomAvatarItemId, + ]) + expect(items[0]).toMatchObject({ Name: 'bulk two', CreatorAccountId: 205, Accessibility: 1 }) + + // A miss is an absent entry, not an error: the client reads the items it got back + // rather than the ids it asked for, so an unknown id must not cost it the rest. + const mixed = await bulkLookup([ + '00000000-0000-0000-0000-000000000000', + first.CustomAvatarItemId, + ]) + expect(mixed.map((i) => i.CustomAvatarItemId)).toEqual([first.CustomAvatarItemId]) + + // Ids also ride comma-separated inside one field, and on the query string — the + // client's exact encoding here isn't pinned down, so all three spellings are read. + const commas = await bulkLookup([`${first.CustomAvatarItemId},${second.CustomAvatarItemId}`]) + expect(commas).toHaveLength(2) + const queried = await exports.default.fetch( + `${ORIGIN}/api/customAvatarItems/v1/bulk?customAvatarItemIds=${first.CustomAvatarItemId}`, + { method: 'POST', headers: await bearer() } + ) + expect(((await queried.json()) as unknown[]).length).toBe(1) + + // Over 100 ids answers EMPTY without touching the table. The client has been seen posting + // far more than a screen could draw, and empty is safe precisely because a miss here is + // already not an error. Empty rather than the first 100: the client reads the items it got + // back, not the ids it asked about, so it cannot tell a truncated batch from a batch of + // misses and would cache the difference. + const padding = Array.from({ length: 99 }, () => '00000000-0000-0000-0000-000000000000') + expect(await bulkLookup([first.CustomAvatarItemId, ...padding])).toHaveLength(1) + expect( + await bulkLookup([first.CustomAvatarItemId, second.CustomAvatarItemId, ...padding]) + ).toEqual([]) }) - // The ids are never parsed (nothing could match), so a missing body is still a 200 - // rather than the 400 a body-reading handler would produce. - test('POST /api/customAvatarItems/v1/bulk ignores the body', async () => { + // Unpublished items are held back from everyone but their creator — the same rule the + // featured/hot feeds and the creator shelf apply, so this route can't surface an item + // the feeds hide. + test('POST /api/customAvatarItems/v1/bulk hides unpublished items from everyone but the creator', async () => { + const hidden = await createCustomItem('206', { Name: 'unpublished', Accessibility: 0 }) + + expect(await bulkLookup([hidden.CustomAvatarItemId], '42')).toEqual([]) + const own = await bulkLookup([hidden.CustomAvatarItemId], '206') + expect(own.map((i) => i.Name)).toEqual(['unpublished']) + }) + + // A missing body is a 200 with an empty array rather than a 400: nothing was asked for, + // so nothing matched — the same shape as asking for ids that all miss. + test('POST /api/customAvatarItems/v1/bulk answers an empty array for an empty body', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, { method: 'POST', headers: await bearer(), @@ -1546,6 +1851,87 @@ describe('public endpoints', () => { ]) }) + test('POST /api/customAvatarItems/v1/:id/report files a report against the item’s creator', async () => { + // 205 makes an item; 42 reports it. The creator is derived FROM the item — the client + // sends `ReportedPlayerId: null` because it does not know who made it. + const item = await createCustomItem('205', { Name: 'Reportable Hat' }) + + const res = await exports.default.fetch( + `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`, + { + method: 'POST', + headers: { ...(await bearer('42')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ReportCategory: 2, + Details: 'tesfsfsdf', + ReportedPlayerId: null, + }), + } + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, error: '' }) + + // One row in the shared report table, marked as an item report by `custom_avatar_item_id`. + // `room_id` stays null — an item isn't tied to one room the way an event is — and the + // other two id columns stay null, which is what tells the kinds apart. + const row = await env.DB.prepare('SELECT * FROM report WHERE custom_avatar_item_id = ?1') + .bind(item.CustomAvatarItemId) + .first>() + expect(row).toMatchObject({ + reporter_player_id: 42, + reported_player_id: 205, // the item's creator + report_category: 2, + details: 'tesfsfsdf', + custom_avatar_item_id: item.CustomAvatarItemId, + invention_id: null, + event_id: null, + room_id: null, + banned: 0, // filed unbanned, like any report + }) + + // A body naming SOMEONE ELSE is ignored: the reported player is read off the item either + // way. Letting a client name who a report is against would let it point one at anybody. + await exports.default.fetch( + `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`, + { + method: 'POST', + headers: { ...(await bearer('42')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ ReportCategory: 1, ReportedPlayerId: 999 }), + } + ) + const reported = await env.DB.prepare( + 'SELECT reported_player_id FROM report WHERE custom_avatar_item_id = ?1' + ) + .bind(item.CustomAvatarItemId) + .all<{ reported_player_id: number }>() + // Nothing dedupes: two reports of the same item are two rows, both against the creator. + expect(reported.results.map((r) => r.reported_player_id)).toEqual([205, 205]) + + // An item that does not exist is refused rather than filed against nobody — the row's + // reported player has to be someone. + const unknown = await exports.default.fetch( + `${ORIGIN}/api/customAvatarItems/v1/00000000-0000-0000-0000-000000000000/report`, + { + method: 'POST', + headers: { ...(await bearer('42')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ ReportCategory: 0 }), + } + ) + expect(unknown.status).toBe(404) + expect(await unknown.json()).toEqual({ success: false, error: 'No such item' }) + + // Auth-gated: the reporter comes from the token, so there is no filing one signed out. + const anon = await exports.default.fetch( + `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ReportCategory: 0 }), + } + ) + expect(anon.status).toBe(401) + }) + test('POST /api/inventions/v1/report files a report row against the invention', async () => { // 5150 saves an invention; 42 reports it. The creator is derived from the invention, // so the reporter never gets to name who the report is against. @@ -1859,6 +2245,82 @@ describe('public endpoints', () => { expect(await miss.json()).toEqual([]) }) + test('GET /api/inventions/v2/search filters and pages in SQL, and does not search tags', async () => { + const published = (id: number, name: string, description: string, tags: string[]) => + ({ + InventionId: id, + ReplicationId: crypto.randomUUID(), + CreatorPlayerId: 8081, + Name: name, + Description: description, + ImageName: '', + CurrentVersionNumber: 1, + CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' }, + IsPublished: true, + HideFromPlayer: false, + CreatedAt: `2026-09-0${id - 200}T00:00:00Z`, + Tags: tags.map((Tag) => ({ Tag, Type: 2 })), + }) as unknown as SavedInvention + + for (const inv of [ + published(201, 'Devin Cube', 'i dont even know lol', ['small']), + published(202, 'Devin Cube 2', 'No description yet', ['small', 'dormanchor']), + published(203, 'Recflarian Flag', 'idk..... lol', ['medium']), + published(204, 'Smallest Table', 'a small table', ['medium']), + // Name holds LIKE metacharacters, for the escaping below. + published(205, '100% Cube_Thing', '', []), + ]) { + await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)') + .bind(JSON.stringify(inv)) + .run() + } + + const ids = async (query: string) => { + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?${query}`) + expect(res.status, query).toBe(200) + return ((await res.json()) as SavedInvention[]) + .map((i) => i.InventionId) + .filter((id) => id >= 201) + } + + // Newest first, matched against name OR description, case-insensitively. + expect(await ids('value=cube&skip=0&take=100')).toEqual([205, 202, 201]) + expect(await ids('value=CUBE&skip=0&take=100')).toEqual([205, 202, 201]) + expect(await ids('value=small&skip=0&take=100')).toEqual([204]) + expect(await ids('value=lol&skip=0&take=100')).toEqual([203, 201]) + + // Terms are ANDed, so more words narrow rather than widen. + expect(await ids(`value=${encodeURIComponent('devin cube')}&skip=0&take=100`)).toEqual([ + 202, 201, + ]) + expect(await ids(`value=${encodeURIComponent('devin flag')}&skip=0&take=100`)).toEqual([]) + + // LIKE metacharacters are escaped: unescaped, `%` would match everything and `_` any + // single character, so searching for "100%" would return the whole catalogue. + expect(await ids('value=%25&skip=0&take=100')).toEqual([205]) + expect(await ids('value=cube_thing&skip=0&take=100')).toEqual([205]) + + // TAGS ARE NOT SEARCHED. The browse screen's chips send `#small`, and no name or + // description contains it, so the term matches nothing — the tag is on 201 and 202, and a + // tag search would have returned them. Deliberate for now: matching tags needs them out of + // the JSON blob and into something indexable, and doing it in memory would mean reading + // every row to answer one page. + expect(await ids(`value=${encodeURIComponent('#small')}&skip=0&take=100`)).toEqual([]) + + // Paged in SQL: consecutive pages neither repeat nor skip a row. The `id` tiebreak in the + // ordering is what guarantees that when two inventions share a `CreatedAt`. + const page1 = await ids('value=cube&skip=0&take=2') + const page2 = await ids('value=cube&skip=2&take=2') + expect(page1).toEqual([205, 202]) + expect(page2).toEqual([201]) + expect(page1.some((id) => page2.includes(id))).toBe(false) + expect(await ids('value=cube&skip=99&take=10')).toEqual([]) + + // Cleaned up: the feeds and the tag-filter chips are derived from EVERY published + // invention, so rows left behind here would change what those tests see. + await env.DB.prepare('DELETE FROM invention WHERE id >= 201 AND id <= 205').run() + }) + test('GET /api/inventions/v1/tagfilters ranks the tags in use', async () => { // Two published inventions tagged `furniture`, one `bed` — plus a tagged draft, // whose tags must not leak into the public filter chips. @@ -2071,7 +2533,27 @@ describe('public endpoints', () => { InstantiationCost: 42, }) - // Only the current version exists; anything else 404s, as does an unknown id. + // `version=0` means "whichever is current" rather than a number to match, and gets the + // same version 1 back. Nothing has a version 0 — a fresh save is version 1 — so a caller + // sending it does not know which version it wants, and matching it literally would 404 an + // invention that exists. + const v0 = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=0` + ) + expect(v0.status).toBe(200) + expect(await v0.json()).toMatchObject({ + InventionId: Invention.InventionId, + VersionNumber: 1, + BlobName: '2026-07-12/lamp.inv', + }) + + // The 0 shortcut does NOT make up an invention: an unknown id still 404s at 0. + const zeroUnknown = await exports.default.fetch( + `${ORIGIN}/api/inventions/v1/version?inventionId=999999&version=0` + ) + expect(zeroUnknown.status).toBe(404) + + // Only the current version exists; any other NUMBER still 404s, as does an unknown id. const v2 = await exports.default.fetch( `${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=2` ) @@ -5722,6 +6204,7 @@ describe('openapi', () => { 'DELETE /api/customAvatarItems/v1/{id}', 'DELETE /api/images/v1/deletesaved', 'DELETE /api/playerevents/v2/delete/{eventId}', + 'GET /api/CircuitChipLists/{list}', 'GET /api/PlayerReporting/v1/moderationBlockDetails', 'GET /api/PlayerReporting/v1/voteToKickReasons', 'GET /api/activities/charades/v1/words/{activity}', @@ -5738,6 +6221,7 @@ describe('openapi', () => { 'GET /api/customAvatarItems/v1/isCreationEnabled', 'GET /api/customAvatarItems/v1/isRenderingEnabled', 'GET /api/customAvatarItems/v1/minPriceForPublicItem', + 'GET /api/customAvatarItems/v1/search', 'GET /api/customAvatarItems/v2/fromCreator/{accountId}', 'GET /api/equipment/v2/getUnlocked', 'GET /api/gameconfigs/v1/all', @@ -5824,6 +6308,7 @@ describe('openapi', () => { 'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems', 'POST /api/customAvatarItems/v1', 'POST /api/customAvatarItems/v1/bulk', + 'POST /api/customAvatarItems/v1/{id}/report', 'POST /api/gamesight/event', 'POST /api/images/v1/cheer', 'POST /api/images/v4/uploadsaved',