[api,econ,img] shirts fix #35

This commit is contained in:
Devin Zuczek
2026-08-26 00:38:02 -04:00
parent 199c34d1cb
commit 7ef949bfcf
12 changed files with 1247 additions and 30 deletions
@@ -0,0 +1,33 @@
-- Custom avatar items (the player-designed shirts/hats built on a base catalog item).
-- Owned by the `api` worker: `POST /api/customAvatarItems/v1` inserts a row. Generated
-- from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
--
-- One column per field of the client's `CustomAvatarItem` DTO, so a row IS the response.
-- The two uploads (the design and the thumbnail PNG) live in the `recflare-img` bucket
-- under `avatar-item/<date>/<id>-thumb.png` / `<id>-design.png`; the filename columns
-- hold those bucket keys.
--
-- `ranking_context` and `purchase_info` are served as null and `current_saves` as an
-- empty list; none of them has a source yet, so they are not columns.
CREATE TABLE IF NOT EXISTS custom_avatar_item (
custom_avatar_item_id TEXT PRIMARY KEY,
creator_account_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price INTEGER NOT NULL DEFAULT 0,
accessibility INTEGER NOT NULL DEFAULT 0,
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
is_featured INTEGER NOT NULL DEFAULT 0,
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
base_avatar_item_id INTEGER NOT NULL,
base_avatar_item_color TEXT NOT NULL,
design_filename TEXT NOT NULL,
thumbnail_image_filename TEXT NOT NULL,
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL,
preview_orientation INTEGER NOT NULL DEFAULT 0,
outfit_type INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id);
+320
View File
@@ -0,0 +1,320 @@
/**
* Custom avatar items — player-designed items built on a base catalog item — on the
* shared `recflare` D1 database. One column per field of the client's `CustomAvatarItem`
* DTO, so a row maps straight onto the response.
*
* The two uploads that accompany a creation (the design blob and the thumbnail PNG) live
* in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; the two filename columns hold
* those bucket keys, which the `img` worker serves back by key.
*
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql,
* applied under its own `migrations_table`).
*/
/** Schema DDL (mirror of migrations/0015_custom_avatar_item.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
custom_avatar_item_id TEXT PRIMARY KEY,
creator_account_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price INTEGER NOT NULL DEFAULT 0,
accessibility INTEGER NOT NULL DEFAULT 0,
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
is_featured INTEGER NOT NULL DEFAULT 0,
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
base_avatar_item_id INTEGER NOT NULL,
base_avatar_item_color TEXT NOT NULL,
design_filename TEXT NOT NULL,
thumbnail_image_filename TEXT NOT NULL,
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL,
preview_orientation INTEGER NOT NULL DEFAULT 0,
outfit_type INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
]
/** The client's `CustomAvatarItem` record (PascalCase, as served). */
export interface CustomAvatarItem {
CustomAvatarItemId: string
CreatorAccountId: number
Name: string
Description: string
Price: number
Accessibility: number
ForceCannotPublish: boolean
IsFeatured: boolean
IsRecRoomApproved: boolean
BaseAvatarItemId: number
BaseAvatarItemColor: string
DesignFilename: string
ThumbnailImageFilename: string
CreatedAt: string
ModifiedAt: string
PreviewOrientation: number
RankingContext: null
OutfitType: number
CurrentSaves: never[]
PurchaseInfo: null
}
/** What `POST /api/customAvatarItems/v1` needs to create an item. */
export interface CreateCustomAvatarItemInput {
/** The item's id. Chosen by the caller because the upload keys are derived from it. */
customAvatarItemId: string
creatorAccountId: number
name: string
description: string
price: number
baseAvatarItemId: number
baseAvatarItemColor: string
accessibility: number
designFilename: string
thumbnailImageFilename: string
}
interface Row {
custom_avatar_item_id: string
creator_account_id: number
name: string
description: string
price: number
accessibility: number
force_cannot_publish: number
is_featured: number
is_rec_room_approved: number
base_avatar_item_id: number
base_avatar_item_color: string
design_filename: string
thumbnail_image_filename: string
created_at: string
modified_at: string
preview_orientation: number
outfit_type: number
}
function toDto(row: Row): CustomAvatarItem {
return {
CustomAvatarItemId: row.custom_avatar_item_id,
CreatorAccountId: row.creator_account_id,
Name: row.name,
Description: row.description,
Price: row.price,
Accessibility: row.accessibility,
ForceCannotPublish: row.force_cannot_publish === 1,
IsFeatured: row.is_featured === 1,
IsRecRoomApproved: row.is_rec_room_approved === 1,
BaseAvatarItemId: row.base_avatar_item_id,
BaseAvatarItemColor: row.base_avatar_item_color,
DesignFilename: row.design_filename,
ThumbnailImageFilename: row.thumbnail_image_filename,
CreatedAt: row.created_at,
ModifiedAt: row.modified_at,
PreviewOrientation: row.preview_orientation,
RankingContext: null,
OutfitType: row.outfit_type,
CurrentSaves: [],
PurchaseInfo: null,
}
}
/** Inserts a new custom avatar item and returns it as the client's DTO. */
export async function createCustomAvatarItem(
db: D1Database,
input: CreateCustomAvatarItemInput,
now: Date = new Date()
): Promise<CustomAvatarItem> {
const ts = now.toISOString()
const row = await db
.prepare(
`INSERT INTO custom_avatar_item (
custom_avatar_item_id, creator_account_id, name, description, price, accessibility,
base_avatar_item_id, base_avatar_item_color, design_filename, thumbnail_image_filename,
created_at, modified_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
RETURNING *`
)
.bind(
input.customAvatarItemId,
input.creatorAccountId,
input.name,
input.description,
input.price,
input.accessibility,
input.baseAvatarItemId,
input.baseAvatarItemColor,
input.designFilename,
input.thumbnailImageFilename,
ts
)
.first<Row>()
if (!row) throw new Error('custom_avatar_item insert returned no row')
return toDto(row)
}
/**
* The `ItemType` that names a custom avatar item in a UGC-purchasable reference
* (`POST /api/ugcPurchasables/v1/items/bulk`'s `Ids[].itemType`).
*/
export const UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM = 3
/** A custom avatar item as the client's `UgcPurchasableItem` (the store-facing view). */
export interface UgcPurchasableItem {
ItemType: number
ItemId: string
Name: string
Description: string
ImageName: string
RoomId: number
Price: number
PurchaseCurrencyId: string | null
CreatedAt: string
ModifiedAt: string
}
/**
* The store-facing projection of a custom avatar item. `RoomId` is echoed from the
* request — the item table has no room; what the client wants it for is still unknown.
* `PurchaseCurrencyId` is null (the client's field is nullable) until a currency exists.
*/
export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPurchasableItem {
return {
ItemType: UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
ItemId: item.CustomAvatarItemId,
Name: item.Name,
Description: item.Description,
ImageName: item.ThumbnailImageFilename,
RoomId: roomId,
Price: item.Price,
PurchaseCurrencyId: null,
CreatedAt: item.CreatedAt,
ModifiedAt: item.ModifiedAt,
}
}
/** Fetches the items with these ids, in the order asked; unknown ids are skipped. */
export async function getCustomAvatarItems(
db: D1Database,
ids: string[]
): Promise<CustomAvatarItem[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
.bind(...ids)
.all<Row>()
const byId = new Map(results.map((r) => [r.custom_avatar_item_id, toDto(r)]))
return ids.flatMap((id) => byId.get(id) ?? [])
}
/**
* The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
* those are excluded even when flagged. Newest first. Nothing sets the flag yet, so the
* feed is empty until an operator writes `is_featured = 1`.
*/
export async function listFeaturedCustomAvatarItems(
db: D1Database,
limit = 50
): Promise<CustomAvatarItem[]> {
const { results } = await db
.prepare(
`SELECT * FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
)
.bind(limit)
.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
* the creator looking at their own shelf: it adds the `Accessibility` 0 items everyone
* else is not shown. Paging is not applied yet (the client sends none), so `TotalResults`
* always equals the list length.
*/
export async function listCustomAvatarItemsByCreator(
db: D1Database,
creatorAccountId: number,
includeUnpublished = false
): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> {
const { results } = await db
.prepare(
`SELECT * FROM custom_avatar_item
WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2)
ORDER BY created_at DESC, custom_avatar_item_id`
)
.bind(creatorAccountId, includeUnpublished ? 1 : 0)
.all<Row>()
const items = results.map(toDto)
return { Results: items, TotalResults: items.length }
}
/** The editable fields of `PUT /api/customAvatarItems/v1/:id`; null/undefined = leave alone. */
export interface UpdateCustomAvatarItemInput {
name?: string | null
description?: string | null
price?: number | null
accessibility?: number | null
}
/**
* Applies a partial edit to one item, bumping `modified_at`. Fields the caller leaves
* null keep their value (the client sends every field, nulling the untouched ones).
* Returns the updated item, or null when no row has that id.
*/
export async function updateCustomAvatarItem(
db: D1Database,
id: string,
patch: UpdateCustomAvatarItemInput,
now: Date = new Date()
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare(
`UPDATE custom_avatar_item SET
name = COALESCE(?2, name),
description = COALESCE(?3, description),
price = COALESCE(?4, price),
accessibility = COALESCE(?5, accessibility),
modified_at = ?6
WHERE custom_avatar_item_id = ?1
RETURNING *`
)
.bind(
id,
patch.name ?? null,
patch.description ?? null,
patch.price ?? null,
patch.accessibility ?? null,
now.toISOString()
)
.first<Row>()
return row ? toDto(row) : null
}
/** Deletes one item's row. Returns the deleted item, or null when no row had that id. */
export async function deleteCustomAvatarItem(
db: D1Database,
id: string
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING *')
.bind(id)
.first<Row>()
return row ? toDto(row) : null
}
/** Fetches one item by id, or null. */
export async function getCustomAvatarItem(
db: D1Database,
id: string
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare('SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
.bind(id)
.first<Row>()
return row ? toDto(row) : null
}
+74 -2
View File
@@ -50,6 +50,13 @@ export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer t
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/**
* A bearer token is honoured but not required: anonymous is a valid alternative. For routes
* that serve public data but show more to a known caller (a creator's own unpublished
* custom avatar items) instead of 401ing.
*/
export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { bearerAuth: [] }]
/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */
export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'path', required: true, description, schema: { type: 'integer' } }
@@ -91,6 +98,9 @@ export const JsonArray = z.array(z.unknown())
/** A bare JSON boolean — several routes answer `true`/`false` with no envelope. */
export const BareBoolean = z.boolean()
/** A bare JSON integer (e.g. `/api/customAvatarItems/v1/minPriceForPublicItem`). */
export const BareInteger = z.number().int()
/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */
export const BareString = z.string()
@@ -312,7 +322,9 @@ export const CheerPlayerRequest = z.object({
export const SetSelectedCheerRequest = z.object({
CheerCategory: z
.string()
.describe('The category to pin: 0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative; -1 unpins'),
.describe(
'The category to pin: 0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative; -1 unpins'
),
})
/**
@@ -326,6 +338,66 @@ export const CheerPlayerResponse = z.object({
Message: z.string().nullable().describe('Null when the cheer landed'),
})
/** The `metadata` JSON field of a custom-avatar-item creation. */
export const CreateCustomAvatarItemMetadata = z.object({
Name: z.string(),
Description: z.string().optional(),
Price: z.number().int().optional(),
BaseAvatarItemId: z.number().int(),
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
Accessibility: z.number().int().optional(),
})
/** The multipart body `POST /api/customAvatarItems/v1` takes. */
export const CreateCustomAvatarItemRequest = z.object({
metadata: z.string().describe('JSON `CreateCustomAvatarItemMetadata`, posted as a text field'),
thumbnailImage: z.string().describe('The thumbnail PNG (binary file part)'),
design: z.string().describe('The design blob (binary file part)'),
})
/** The client's `CustomAvatarItem` record. */
export const CustomAvatarItemDto = z.object({
CustomAvatarItemId: z.string(),
CreatorAccountId: z.number().int(),
Name: z.string(),
Description: z.string(),
Price: z.number().int(),
Accessibility: z.number().int(),
ForceCannotPublish: z.boolean(),
IsFeatured: z.boolean(),
IsRecRoomApproved: z.boolean(),
BaseAvatarItemId: z.number().int(),
BaseAvatarItemColor: z.string(),
DesignFilename: z.string(),
ThumbnailImageFilename: z.string(),
CreatedAt: z.string(),
ModifiedAt: z.string(),
PreviewOrientation: z.number().int(),
RankingContext: z.null(),
OutfitType: z.number().int(),
CurrentSaves: z.array(z.unknown()),
PurchaseInfo: z.null(),
})
/** The JSON body `PUT /api/customAvatarItems/v1/:id` takes; null leaves a field unchanged. */
export const UpdateCustomAvatarItemRequest = z.object({
Name: z.string().nullable().optional(),
Description: z.string().nullable().optional(),
Price: z.number().int().nullable().optional(),
Accessibility: z.number().int().nullable().optional(),
})
/** A bare list of custom avatar items (the featured feed). */
export const CustomAvatarItemList = z.array(CustomAvatarItemDto)
/** The PascalCase `{ Value, Success, Error, error_id }` envelope custom-avatar-item routes answer with. */
export const CustomAvatarItemResponse = z.object({
Value: CustomAvatarItemDto.nullable(),
Success: z.boolean(),
Error: z.string().nullable(),
error_id: z.string().nullable(),
})
/** The `Ids` form body the bulk POST endpoints take. */
export const BulkIdsRequest = z.object({
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
@@ -507,7 +579,7 @@ export const BulkCustomAvatarItemsRequest = z.object({
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
export const CustomAvatarItemsPage = z.object({
Results: JsonArray,
Results: CustomAvatarItemList,
TotalResults: z.int(),
})
+238 -10
View File
@@ -12,6 +12,14 @@ import {
setOutfit,
} from '@repo/domain'
import {
createCustomAvatarItem,
deleteCustomAvatarItem,
getCustomAvatarItem,
listCustomAvatarItemsByCreator,
listFeaturedCustomAvatarItems,
updateCustomAvatarItem,
} from '../custom-avatar-items-db'
import { authedId, unauthorized } from '../http'
import {
createInvention,
@@ -36,7 +44,11 @@ import {
import {
AUTHED,
BareBoolean,
BareInteger,
BulkCustomAvatarItemsRequest,
CreateCustomAvatarItemRequest,
CustomAvatarItemList,
CustomAvatarItemResponse,
CustomAvatarItemsPage,
ErrorResponse,
form,
@@ -53,6 +65,7 @@ import {
JsonArray,
jsonBody,
LegacyAvatarItemSaves,
OPTIONAL_AUTHED,
OutfitSaveResponse,
OutfitsBulkRequest,
OutfitsBulkResponse,
@@ -62,10 +75,12 @@ import {
SaveInventionRequest,
SetTagsRequest,
SetTagsResponse,
stringParam,
stringQuery,
SuccessValueEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
UpdateCustomAvatarItemRequest,
UpdatePriceRequest,
} from '../openapi'
@@ -227,17 +242,208 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json(true)
)
.get(
'/api/customAvatarItems/v1/minPriceForPublicItem',
describeRoute({
tags: ['Avatar'],
summary: 'Minimum token price for a public custom item',
description:
'The floor the creation UI enforces when listing a custom item publicly. A fixed `100`.',
responses: { 200: json(BareInteger, 'A bare `100`') },
}),
(c) => c.json(100)
)
.post(
'/api/customAvatarItems/v1',
describeRoute({
tags: ['Avatar'],
summary: 'Create a custom avatar item',
description:
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
'error_id }` envelope.\n\n' +
'The two files go to the shared image bucket (`recflare-img`) under ' +
'`avatar-item/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-design.png`; those ' +
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.',
security: AUTHED,
requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'),
responses: {
200: json(CustomAvatarItemResponse, 'The created item'),
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The featured custom-avatar-item feed. No curated items yet → an empty list.
const fail = (message: string) =>
c.json({ Value: null, Success: false, Error: message, error_id: null }, 400)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
if (typeof body.metadata !== 'string') return fail('metadata is required')
let meta: Record<string, unknown>
try {
const parsed: unknown = JSON.parse(body.metadata)
if (!parsed || typeof parsed !== 'object') return fail('metadata must be a JSON object')
meta = parsed as Record<string, unknown>
} catch {
return fail('metadata is not valid JSON')
}
if (typeof meta.Name !== 'string' || meta.Name.trim() === '') return fail('Name is required')
if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
if (typeof meta.BaseAvatarItemColor !== 'string')
return fail('BaseAvatarItemColor is required')
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
if (!(body.design instanceof File)) return fail('design is required')
// Both files go to the shared image bucket, foldered by upload date and keyed by
// the item's id (chosen here so the keys can carry it). The `img` worker serves
// them back by key.
const customAvatarItemId = crypto.randomUUID()
const prefix = `avatar-item/${new Date().toISOString().slice(0, 10)}/${customAvatarItemId}`
const thumbnailImageFilename = `${prefix}-thumb.png`
const designFilename = `${prefix}-design.png`
await Promise.all([
c.env.IMAGES.put(thumbnailImageFilename, await body.thumbnailImage.arrayBuffer(), {
httpMetadata: { contentType: body.thumbnailImage.type || 'image/png' },
}),
c.env.IMAGES.put(designFilename, await body.design.arrayBuffer(), {
httpMetadata: { contentType: body.design.type || 'image/png' },
}),
])
const item = await createCustomAvatarItem(c.env.DB, {
customAvatarItemId,
creatorAccountId: id,
name: meta.Name,
description: typeof meta.Description === 'string' ? meta.Description : '',
price: typeof meta.Price === 'number' ? meta.Price : 0,
baseAvatarItemId: meta.BaseAvatarItemId,
baseAvatarItemColor: meta.BaseAvatarItemColor,
accessibility: typeof meta.Accessibility === 'number' ? meta.Accessibility : 0,
designFilename,
thumbnailImageFilename,
})
return c.json({ Value: item, Success: true, Error: null, error_id: null })
}
)
.put(
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
describeRoute({
tags: ['Avatar'],
summary: 'Edit a custom avatar item',
description:
'A partial edit of `Name`, `Description`, `Price` and `Accessibility` — the client ' +
'sends every field and nulls the ones it is not changing, so null means "leave ' +
'alone". Only the creator may edit. `ModifiedAt` is bumped. Answers the updated ' +
'item in the same `{ Value, Success, Error, error_id }` envelope as the create.',
security: AUTHED,
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'),
responses: {
200: json(CustomAvatarItemResponse, 'The updated item'),
400: json(CustomAvatarItemResponse, 'Malformed body'),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'),
404: json(CustomAvatarItemResponse, 'No such item'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const fail = (status: 400 | 403 | 404, message: string) =>
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
const itemId = c.req.param('id')
const existing = await getCustomAvatarItem(c.env.DB, itemId)
if (!existing) return fail(404, 'No such item')
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (!body) return fail(400, 'A JSON body is required')
const str = (v: unknown, field: string): string | null | undefined => {
if (v === null || v === undefined) return null
if (typeof v !== 'string') throw new TypeError(`${field} must be a string`)
return v
}
const int = (v: unknown, field: string): number | null => {
if (v === null || v === undefined) return null
if (typeof v !== 'number' || !Number.isInteger(v))
throw new TypeError(`${field} must be an integer`)
return v
}
let patch
try {
patch = {
name: str(body.Name, 'Name'),
description: str(body.Description, 'Description'),
price: int(body.Price, 'Price'),
accessibility: int(body.Accessibility, 'Accessibility'),
}
} catch (e) {
return fail(400, (e as Error).message)
}
if (patch.name !== null && patch.name?.trim() === '')
return fail(400, 'Name must not be blank')
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
if (!item) return fail(404, 'No such item')
return c.json({ Value: item, Success: true, Error: null, error_id: null })
}
)
.delete(
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
describeRoute({
tags: ['Avatar'],
summary: 'Delete a custom avatar item',
description:
'Removes the item and its two bucket objects (thumbnail and design). Only the ' +
'creator may delete. Answers the deleted item in the `{ Value, Success, Error, ' +
'error_id }` envelope.',
security: AUTHED,
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
responses: {
200: json(CustomAvatarItemResponse, 'The deleted item'),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'),
404: json(CustomAvatarItemResponse, 'No such item'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const fail = (status: 403 | 404, message: string) =>
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
const itemId = c.req.param('id')
const existing = await getCustomAvatarItem(c.env.DB, itemId)
if (!existing) return fail(404, 'No such item')
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
const item = await deleteCustomAvatarItem(c.env.DB, itemId)
if (!item) return fail(404, 'No such item')
// The row is gone; the objects follow. A missing key is a no-op for R2.
await c.env.IMAGES.delete([item.ThumbnailImageFilename, item.DesignFilename])
return c.json({ Value: item, Success: true, Error: null, error_id: null })
}
)
// The featured custom-avatar-item feed: flagged (`is_featured`) AND published
// (`Accessibility` != 0) items from the `custom_avatar_item` table.
.get(
'/api/customAvatarItems/v1/featured',
describeRoute({
tags: ['Avatar'],
summary: 'Featured custom avatar items',
description: 'The curated feed. Nothing is curated yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
description:
'The curated feed: items with `IsFeatured` set that are also published ' +
'(`Accessibility` 0 is unpublished and is excluded even when flagged), newest first, ' +
'up to 50. Nothing sets the flag yet, so it stays empty until an operator does.',
responses: { 200: json(CustomAvatarItemList, 'The items, newest first') },
}),
(c) => c.json([])
async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB))
)
// The "hot" (trending) custom-avatar-item feed. No items yet → an empty list.
@@ -288,20 +494,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Custom avatar items created by a given account. No storage yet → an empty
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
// Custom avatar items created by a given account, from the `custom_avatar_item` table,
// in the paginated shape (matches the econ `customAvatarItems/v1/owned` shape). Auth is
// optional: the creator themselves also sees their unpublished (`Accessibility` 0) items.
.get(
'/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}',
describeRoute({
tags: ['Avatar'],
summary: 'A creators custom avatar items',
description:
'The items an account has authored. Nothing stores custom items yet, so this is an ' +
'empty page — in the same shape as the `econ` workers `customAvatarItems/v1/owned`.',
'The items an account has authored, newest first, in the same page shape as the ' +
'`econ` workers `customAvatarItems/v1/owned`. Published items only — unless the ' +
'bearer token is the creators, in which case their unpublished (`Accessibility` 0) ' +
'items are included too. Paging is not applied (the client sends none), so ' +
'`TotalResults` is the length of `Results`.',
security: OPTIONAL_AUTHED,
parameters: [idParam('accountId', 'Creator account id')],
responses: { 200: json(CustomAvatarItemsPage, 'An empty page') },
responses: { 200: json(CustomAvatarItemsPage, 'The creators items') },
}),
(c) => c.json({ Results: [], TotalResults: 0 })
async (c) => {
const accountId = Number.parseInt(c.req.param('accountId'), 10)
const viewer = await authedId(c)
return c.json(await listCustomAvatarItemsByCreator(c.env.DB, accountId, viewer === accountId))
}
)
// The client asks which legacy avatar items have been rebuilt as custom items, so it
@@ -984,6 +1199,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The featured dorm-skin feed (inventions that reskin the dorm). Nothing curates these
// yet → an empty list, so the client's shelf renders empty rather than 404ing.
.get(
'/api/inventions/v1/featureddormskins',
describeRoute({
tags: ['Inventions'],
summary: 'The featured dorm-skin feed',
description: 'Curated dorm-skin inventions. Nothing is curated yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
// shelf, and the "from creators you follow" row, from.
//
+364 -9
View File
@@ -30,6 +30,10 @@ import '../../api.app'
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
import { banEvasionMatch, resolveBan } from '../../bans-db'
import {
createCustomAvatarItem,
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
} from '../../custom-avatar-items-db'
import {
countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
@@ -148,6 +152,9 @@ beforeAll(async () => {
// Reputation + cheer credit (owned by the api worker) — cheering writes both.
for (const stmt of REPUTATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Custom avatar items (owned by the api worker).
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -516,7 +523,12 @@ describe('public endpoints', () => {
expect(allAnonymous[0]).toMatchObject({
playerId: 7109,
notificationType: 2, // NotificationType.MessageReceived
data: { FromPlayerId: 0, ToPlayerId: 7109, Type: MessageType.PlayerCheerAnonymous, Data: '10' },
data: {
FromPlayerId: 0,
ToPlayerId: 7109,
Type: MessageType.PlayerCheerAnonymous,
Data: '10',
},
})
const anonymous = allAnonymous.slice(1)
expect(anonymous[0]).toMatchObject({
@@ -571,7 +583,11 @@ describe('public endpoints', () => {
// reputation — all durable and all addressed: nothing was broadcast.
expect(frames.map((f) => f.playerId)).toEqual([7131, 7131, 7130])
expect(frames.some((f) => f.ephemeral)).toBe(false)
expect(frames[0]!.data).toMatchObject({ ToPlayerId: 7131, Type: MessageType.PlayerCheer, Data: '40' })
expect(frames[0]!.data).toMatchObject({
ToPlayerId: 7131,
Type: MessageType.PlayerCheer,
Data: '40',
})
expect(frames[1]!.data).toMatchObject({ AccountId: 7131, CheerCreative: 1 })
})
@@ -607,7 +623,9 @@ describe('public endpoints', () => {
// The pin survives a cheer landing on the row, and a cheer's frame carries it.
expect((await cheer({ PlayerIdTo: '7140', CheerCategory: '0' }, '7141')).status).toBe(200)
expect(await reputationOf(7140)).toMatchObject({ CheerGeneral: 1 })
expect(await getReputation(env.DB, 7140)).toMatchObject({ SelectedCheer: CheerCategory.GreatHost })
expect(await getReputation(env.DB, 7140)).toMatchObject({
SelectedCheer: CheerCategory.GreatHost,
})
// -1 (`None`) unpins, read back as 0; a made-up category is refused.
expect(await (await pin('-1', '7140')).json()).toEqual({ Success: true, Message: null })
@@ -616,7 +634,7 @@ describe('public endpoints', () => {
Success: false,
Message: 'CheerCategory is not a cheer category',
})
expect((await pin('0', '7140').then((r) => r.status))).toBe(200)
expect(await pin('0', '7140').then((r) => r.status)).toBe(200)
expect(
(
await exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/SetSelectedCheer`, {
@@ -936,9 +954,56 @@ describe('public endpoints', () => {
expect(await res.json()).toBe(true)
})
test('GET /api/customAvatarItems/v1/featured returns []', async () => {
test('GET /api/customAvatarItems/v1/featured lists flagged, published items, newest first', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const older = await createCustomAvatarItem(
env.DB,
item('Older', 1),
new Date('2026-08-01T00:00:00Z')
)
const newer = await createCustomAvatarItem(
env.DB,
item('Newer', 1),
new Date('2026-08-02T00:00:00Z')
)
const unpublished = await createCustomAvatarItem(env.DB, item('Unpublished', 0))
const unflagged = await createCustomAvatarItem(env.DB, item('Unflagged', 1))
// Nothing flags items yet, so flag straight in the table — the unflagged one stays.
await env.DB.prepare(
'UPDATE custom_avatar_item SET is_featured = 1 WHERE custom_avatar_item_id != ?1'
)
.bind(unflagged.CustomAvatarItemId)
.run()
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/featured`)
expect(res.status).toBe(200)
const got = (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
expect(got.map((i) => i.CustomAvatarItemId)).toEqual([
newer.CustomAvatarItemId,
older.CustomAvatarItemId,
])
expect(got.map((i) => i.CustomAvatarItemId)).not.toContain(unpublished.CustomAvatarItemId)
expect(got[0]).toMatchObject({ Name: 'Newer', IsFeatured: true, CurrentSaves: [] })
function item(name: string, accessibility: number) {
return {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name,
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
}
}
})
test('GET /api/inventions/v1/featureddormskins returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featureddormskins`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
@@ -948,10 +1013,70 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual([])
})
test('GET /api/customAvatarItems/v2/fromCreator/:id returns an empty paginated result', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
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 = {
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
}
const pub = await createCustomAvatarItem(
env.DB,
{
...base,
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Published',
accessibility: 1,
},
new Date('2026-08-01T00:00:00Z')
)
const draft = await createCustomAvatarItem(
env.DB,
{
...base,
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Draft',
accessibility: 0,
},
new Date('2026-08-02T00:00:00Z')
)
await createCustomAvatarItem(env.DB, {
...base,
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 9,
name: 'Other',
accessibility: 0,
})
type Page = { Results: Array<{ CustomAvatarItemId: string }>; TotalResults: number }
const url = `${ORIGIN}/api/customAvatarItems/v2/fromCreator/205`
// Anonymous, or someone else: only the published (Accessibility != 0) item.
for (const headers of [{}, await bearer('9')]) {
const res = await exports.default.fetch(url, { headers })
expect(res.status).toBe(200)
const page = (await res.json()) as Page
expect(page.TotalResults).toBe(1)
expect(page.Results.map((i) => i.CustomAvatarItemId)).toEqual([pub.CustomAvatarItemId])
}
// The creator: their unpublished item too, newest first.
const own = (await (
await exports.default.fetch(url, { headers: await bearer('205') })
).json()) as Page
expect(own.TotalResults).toBe(2)
expect(own.Results.map((i) => i.CustomAvatarItemId)).toEqual([
draft.CustomAvatarItemId,
pub.CustomAvatarItemId,
])
const none = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
expect(await none.json()).toEqual({ Results: [], TotalResults: 0 })
})
// Nothing locks avatar items here, so the array is empty and the posted ids are never
@@ -2354,6 +2479,231 @@ describe('auth-gated endpoints', () => {
})
})
describe('custom avatar items', () => {
test('minPriceForPublicItem is a bare 100', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/minPriceForPublicItem`
)
expect(res.status).toBe(200)
expect(await res.json()).toBe(100)
})
test('POST creates an item from the multipart form and returns it', async () => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({
Name: 'custom shirt 1',
Description: 'custom shirt 2',
Price: 0,
BaseAvatarItemId: 2184,
BaseAvatarItemColor: '#F55C1A',
Accessibility: 0,
})
)
form.set(
'thumbnailImage',
new File([new Uint8Array([1, 2, 3])], 'file.bin', { type: 'image/png' })
)
form.set('design', new File([new Uint8Array([4, 5, 6])], 'file.bin', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Value: Record<string, unknown>
Success: boolean
Error: null
error_id: null
}
expect(body.Success).toBe(true)
expect(body.Error).toBeNull()
expect(body.error_id).toBeNull()
expect(body.Value).toMatchObject({
CreatorAccountId: 205,
Name: 'custom shirt 1',
Description: 'custom shirt 2',
Price: 0,
Accessibility: 0,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: false,
BaseAvatarItemId: 2184,
BaseAvatarItemColor: '#F55C1A',
PreviewOrientation: 0,
RankingContext: null,
OutfitType: 0,
CurrentSaves: [],
PurchaseInfo: null,
})
const itemId = body.Value.CustomAvatarItemId as string
expect(itemId).toMatch(/^[0-9a-f-]{36}$/)
const date = (body.Value.CreatedAt as string).slice(0, 10)
expect(body.Value.ThumbnailImageFilename).toBe(`avatar-item/${date}/${itemId}-thumb.png`)
expect(body.Value.DesignFilename).toBe(`avatar-item/${date}/${itemId}-design.png`)
expect(body.Value.CreatedAt).toBe(body.Value.ModifiedAt)
// Both uploads landed in the image bucket under those keys.
const thumb = await env.IMAGES.get(body.Value.ThumbnailImageFilename as string)
expect(new Uint8Array((await thumb!.arrayBuffer()) as ArrayBuffer)).toEqual(
new Uint8Array([1, 2, 3])
)
expect(thumb!.httpMetadata?.contentType).toBe('image/png')
const design = await env.IMAGES.get(body.Value.DesignFilename as string)
expect(new Uint8Array((await design!.arrayBuffer()) as ArrayBuffer)).toEqual(
new Uint8Array([4, 5, 6])
)
const row = await env.DB.prepare(
'SELECT name, creator_account_id FROM custom_avatar_item WHERE custom_avatar_item_id = ?1'
)
.bind(body.Value.CustomAvatarItemId)
.first()
expect(row).toEqual({ name: 'custom shirt 1', creator_account_id: 205 })
})
test('PUT edits the creators item, leaving nulled fields alone', async () => {
const item = await createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Visor',
description: 'shiny',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 0,
designFilename: 'd',
thumbnailImageFilename: 't',
},
new Date('2026-08-01T00:00:00Z')
)
const url = `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}`
const res = await exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Name: null, Description: null, Price: 100, Accessibility: 1 }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Value: Record<string, unknown>
Success: boolean
Error: null
}
expect(body.Success).toBe(true)
expect(body.Value).toMatchObject({
CustomAvatarItemId: item.CustomAvatarItemId,
Name: 'Visor',
Description: 'shiny',
Price: 100,
Accessibility: 1,
CreatedAt: '2026-08-01T00:00:00.000Z',
})
expect(body.Value.ModifiedAt).not.toBe(item.ModifiedAt)
// Someone else can't edit it; an unknown id 404s; a bad type 400s.
const other = await exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('9')), 'content-type': 'application/json' },
body: JSON.stringify({ Price: 5 }),
})
expect(other.status).toBe(403)
const missing = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/${crypto.randomUUID()}`,
{
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Price: 5 }),
}
)
expect(missing.status).toBe(404)
const bad = await exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Price: 'lots' }),
})
expect(bad.status).toBe(400)
expect(await bad.json()).toMatchObject({ Success: false, Value: null })
expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401)
})
test('DELETE removes the creators item and its bucket objects', async () => {
// Create through the endpoint so the objects really exist in the bucket.
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'Gone', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
form.set('thumbnailImage', new File([new Uint8Array([1])], 'file.bin', { type: 'image/png' }))
form.set('design', new File([new Uint8Array([2])], 'file.bin', { type: 'image/png' }))
const created = (await (
await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
).json()) as {
Value: { CustomAvatarItemId: string; ThumbnailImageFilename: string; DesignFilename: string }
}
const { CustomAvatarItemId, ThumbnailImageFilename, DesignFilename } = created.Value
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
const url = `${ORIGIN}/api/customAvatarItems/v1/${CustomAvatarItemId}`
// Not the creator → 403 and nothing changes.
const other = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('9') })
expect(other.status).toBe(403)
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
const res = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('205') })
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({
Success: true,
Error: null,
Value: { CustomAvatarItemId, Name: 'Gone' },
})
expect(await env.IMAGES.get(ThumbnailImageFilename)).toBeNull()
expect(await env.IMAGES.get(DesignFilename)).toBeNull()
expect(
await env.DB.prepare('SELECT 1 FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
.bind(CustomAvatarItemId)
.first()
).toBeNull()
// Gone now → 404; no token → 401.
const again = await exports.default.fetch(url, {
method: 'DELETE',
headers: await bearer('205'),
})
expect(again.status).toBe(404)
expect((await exports.default.fetch(url, { method: 'DELETE' })).status).toBe(401)
})
test('POST 400s without the files', async () => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'x', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer(),
body: form,
})
expect(res.status).toBe(400)
expect(await res.json()).toMatchObject({ Success: false, Value: null })
})
test('POST 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
})
expect(res.status).toBe(401)
})
})
describe('player reports', () => {
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
@@ -5269,6 +5619,7 @@ describe('openapi', () => {
)
)
expect([...documented].sort()).toEqual([
'DELETE /api/customAvatarItems/v1/{id}',
'DELETE /api/images/v1/deletesaved',
'DELETE /api/playerevents/v2/delete/{eventId}',
'GET /api/PlayerReporting/v1/moderationBlockDetails',
@@ -5286,6 +5637,7 @@ describe('openapi', () => {
'GET /api/customAvatarItems/v1/isCreationAllowedForAccount',
'GET /api/customAvatarItems/v1/isCreationEnabled',
'GET /api/customAvatarItems/v1/isRenderingEnabled',
'GET /api/customAvatarItems/v1/minPriceForPublicItem',
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gameconfigs/v1/all',
@@ -5301,6 +5653,7 @@ describe('openapi', () => {
'GET /api/inventions/v1',
'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured',
'GET /api/inventions/v1/featureddormskins',
'GET /api/inventions/v1/fromcreators',
'GET /api/inventions/v1/fulllineageowner',
'GET /api/inventions/v1/personaldetails/{inventionId}',
@@ -5369,6 +5722,7 @@ describe('openapi', () => {
'POST /api/avatar/v1/lockeditems/bulk',
'POST /api/avatar/v2/gifts/generate',
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
'POST /api/customAvatarItems/v1',
'POST /api/customAvatarItems/v1/bulk',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
@@ -5409,6 +5763,7 @@ describe('openapi', () => {
'POST /api/v1/progression/bulk',
'POST /outfits/bulk',
'POST /statsigUserProperties',
'PUT /api/customAvatarItems/v1/{id}',
'PUT /api/playerevents/v2/{eventId}/accessibility',
'PUT /api/playerevents/v2/{eventId}/description',
'PUT /api/playerevents/v2/{eventId}/name',