[api] add CAI reporting, CircuitChipList path

This commit is contained in:
Devin Zuczek
2026-08-27 22:42:35 -04:00
parent 2d2e2f44f3
commit 5e138a0646
8 changed files with 1064 additions and 74 deletions
@@ -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;
+97
View File
@@ -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<CustomAvatarItem[]> {
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<number | string> = []
/** 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<Row>()
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
+71 -16
View File
@@ -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<SavedInvention[]> {
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<string | number> = []
/** 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<InventionRow>()
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<InventionVersion | null> {
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
+19 -1
View File
@@ -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 items creator'),
})
export const InventionReportRequest = z.object({
InventionId: z.int().describe('The invention being reported'),
ReportCategory: z
+33 -17
View File
@@ -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<Re
`INSERT INTO report (
reporter_player_id, reported_player_id, report_category, details,
height_reporter, height_reported, room_id, room_instance_type, created_at,
event_id, invention_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
event_id, invention_id, custom_avatar_item_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
RETURNING *`
)
.bind(
@@ -135,7 +150,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
input.roomInstanceType ?? null,
new Date().toISOString(),
input.eventId ?? null,
input.inventionId ?? null
input.inventionId ?? null,
input.customAvatarItemId ?? null
)
.first<ReportRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
+288 -21
View File
@@ -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<App>): Promise<string[]> {
const raw = [...(c.req.queries('customAvatarItemIds') ?? [])]
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
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<App>({ 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 stores 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 items 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 servers 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 items 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 Coachs 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<App>({ 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.<guid>` 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<App>({ 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 clients ' +
'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<App>({ 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<App>({ 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<App>({ 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 screens 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 screens initial ' +
'request. Tags are NOT searched: a `#tag` term from the browse screens 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<App>({ 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 rows `reported_player_id` is the ' +
'items CREATOR, read from the item. The bodys `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 isnt 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 items 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<string, unknown>)
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.
+26
View File
@@ -227,6 +227,32 @@ export const gameplayRoutes = new Hono<App>({ 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 Pens 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(
+504 -19
View File
@@ -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<string, string> = {}
// 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<string, unknown>
): 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<string>` 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<Array<{ CustomAvatarItemId: string; Name: string }>> {
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<string>` 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.<guid>` 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 items 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<Record<string, unknown>>()
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',