[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
+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.