diff --git a/apps/api/migrations/0015_custom_avatar_item.sql b/apps/api/migrations/0015_custom_avatar_item.sql new file mode 100644 index 0000000..c51d3b0 --- /dev/null +++ b/apps/api/migrations/0015_custom_avatar_item.sql @@ -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//-thumb.png` / `-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); diff --git a/apps/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts new file mode 100644 index 0000000..d4ff42e --- /dev/null +++ b/apps/api/src/custom-avatar-items-db.ts @@ -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//-thumb.png` and `-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 { + 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() + 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 { + 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() + 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 { + 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() + 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() + 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 { + 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() + 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 { + const row = await db + .prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING *') + .bind(id) + .first() + return row ? toDto(row) : null +} + +/** Fetches one item by id, or null. */ +export async function getCustomAvatarItem( + db: D1Database, + id: string +): Promise { + const row = await db + .prepare('SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id = ?1') + .bind(id) + .first() + return row ? toDto(row) : null +} diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index e98c1d6..5991635 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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(), }) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index db4465e..c9c27ff 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -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({ 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//-thumb.png` and `avatar-item//-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) + if (typeof body.metadata !== 'string') return fail('metadata is required') + let meta: Record + 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 + } 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 | 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({ 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 creator’s 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` worker’s `customAvatarItems/v1/owned`.', + 'The items an account has authored, newest first, in the same page shape as the ' + + '`econ` worker’s `customAvatarItems/v1/owned`. Published items only — unless the ' + + 'bearer token is the creator’s, 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 creator’s 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({ 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. // diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 8205f62..4796316 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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 + 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 creator’s 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 + 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 creator’s 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, headers?: Record) => 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', diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index c8b3b95..c84b7c2 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -21,6 +21,13 @@ import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' // Invention storage (owned by the `api` worker, on this same `recflare` database). // Imported directly rather than copied: these are plain D1 helpers with no bindings of // their own, and buyInvention has to read the very rows `api` writes. +// Custom avatar items likewise live in an `api`-owned table; the UGC-purchasable bulk +// lookup is the store's view of those rows. +import { + getCustomAvatarItems, + toUgcPurchasable, + UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM, +} from '../../api/src/custom-avatar-items-db' import { getInventionById, toSaveResult } from '../../api/src/inventions-db' // The notification-type ids the hub carries, and the payload shapes recovered from the // client's own decoder (both owned by the `notify` worker). Imported rather than copied so @@ -92,6 +99,8 @@ import { SaveOutfitRequest, SaveOutfitV4Response, SubscriptionResponse, + UgcPurchasableBulkRequest, + UgcPurchasableItemList, UNAUTHORIZED_RESPONSE, UpdateObjectiveRequest, UpdateObjectiveResponse, @@ -2216,6 +2225,46 @@ const app = new Hono({ strict: false }) (c) => c.json([]) ) + // Bulk lookup of UGC purchasables by `{ itemType, itemId }`. Only custom avatar items + // (type 3) exist to resolve; they come off the api-owned `custom_avatar_item` table. + .post( + '/api/ugcPurchasables/v1/items/bulk', + describeRoute({ + tags: ['Rooms'], + summary: 'Look up UGC purchasables by id', + description: + 'Resolves `Ids[]` (`{ itemType, itemId }`) against the `custom_avatar_item` table and ' + + 'answers the store-facing `UgcPurchasableItem` view of each, in request order. ' + + 'Only `itemType` 3 (custom avatar item) is served; other types and unknown ids are ' + + 'dropped. `RoomId` is echoed onto every item — what the client wants it for is ' + + 'not yet known. `PurchaseCurrencyId` is null until a currency exists.', + security: AUTHED, + requestBody: jsonBody(UgcPurchasableBulkRequest, 'The room and the ids to resolve'), + responses: { + 200: json(UgcPurchasableItemList, 'The resolved items (unknown ids omitted)'), + 400: json(ErrorResponse, 'Malformed body'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = (await c.req.json().catch(() => null)) as Record | null + if (!body || !Array.isArray(body.Ids)) return c.json({ error: 'Ids is required' }, 400) + const roomId = typeof body.RoomId === 'number' ? body.RoomId : 0 + const ids = (body.Ids as unknown[]).flatMap((ref) => { + if (!ref || typeof ref !== 'object') return [] + const { itemType, itemId } = ref as Record + return itemType === UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM && typeof itemId === 'string' + ? [itemId] + : [] + }) + const items = await getCustomAvatarItems(c.env.DB, ids) + return c.json(items.map((item) => toUgcPurchasable(item, roomId))) + } + ) + // Unlocked consumables. [Authorize]. The consumables the player has bought (from // `buyItem`, stored in the `consumable` table), grouped by item into the client's // unlocked-consumable DTO. A player who has bought none gets an empty list. diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 5038237..bed7e87 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -401,6 +401,34 @@ export const BuyInventionResponse = z.object({ }) /** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */ +/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */ +export const UgcPurchasableBulkRequest = z.object({ + RoomId: z.number().int().describe('Echoed back on each item; not otherwise used'), + Ids: z.array( + z.object({ + itemType: z.number().int().describe('3 = custom avatar item (the only type served)'), + itemId: z.string().describe('The `CustomAvatarItemId`'), + }) + ), +}) + +/** The client's `UgcPurchasableItem` — a store-facing view of a custom avatar item. */ +export const UgcPurchasableItemDto = z.object({ + ItemType: z.number().int(), + ItemId: z.string(), + Name: z.string(), + Description: z.string(), + ImageName: z.string(), + RoomId: z.number().int(), + Price: z.number().int(), + PurchaseCurrencyId: z.string().nullable(), + CreatedAt: z.string(), + ModifiedAt: z.string(), +}) + +/** What the bulk lookup answers: the resolved items, unknown ids omitted. */ +export const UgcPurchasableItemList = z.array(UgcPurchasableItemDto) + export const ErrorResponse = z.object({ error: z.string() }) // ---- Request schemas ------------------------------------------------------- diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 47ea02e..ee2907b 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -15,6 +15,10 @@ import { // The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL // is built here too (see the same cross-worker import in econ.app.ts). +import { + createCustomAvatarItem, + SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL, +} from '../../../../api/src/custom-avatar-items-db' import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db' // The notification-type ids the hub carries, from the worker that owns them — asserting // against the enum rather than a copied number is what keeps these frames honest. @@ -69,6 +73,7 @@ beforeAll(async () => { for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run() await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') .bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' })) .run() @@ -675,6 +680,63 @@ describe('econ endpoints', () => { } }) + test('POST /api/ugcPurchasables/v1/items/bulk resolves custom avatar items, echoing RoomId', async () => { + const item = await createCustomAvatarItem(env.DB, { + customAvatarItemId: crypto.randomUUID(), + creatorAccountId: 205, + name: 'Neon Visor', + description: '', + price: 250, + baseAvatarItemId: 1, + baseAvatarItemColor: '#fff', + accessibility: 0, + designFilename: 'design_x.bin', + thumbnailImageFilename: 'thumb_x.png', + }) + const res = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, { + method: 'POST', + headers: { ...(await bearer()), 'content-type': 'application/json' }, + body: JSON.stringify({ + RoomId: 92, + Ids: [ + { itemType: 3, itemId: item.CustomAvatarItemId }, + { itemType: 3, itemId: 'does-not-exist' }, + { itemType: 1, itemId: item.CustomAvatarItemId }, + ], + }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([ + { + ItemType: 3, + ItemId: item.CustomAvatarItemId, + Name: 'Neon Visor', + Description: '', + ImageName: 'thumb_x.png', + RoomId: 92, + Price: 250, + PurchaseCurrencyId: null, + CreatedAt: item.CreatedAt, + ModifiedAt: item.ModifiedAt, + }, + ]) + }) + + test('POST /api/ugcPurchasables/v1/items/bulk 400s without Ids and 401s without a token', async () => { + const bad = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, { + method: 'POST', + headers: { ...(await bearer()), 'content-type': 'application/json' }, + body: JSON.stringify({ RoomId: 92 }), + }) + expect(bad.status).toBe(400) + const anon = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ RoomId: 92, Ids: [] }), + }) + expect(anon.status).toBe(401) + }) + test('GET /econ/roomEconConfig/:roomId echoes the room and disables sorting tabs', async () => { const anon = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/92`) expect(anon.status).toBe(401) @@ -2868,6 +2930,7 @@ describe('econ endpoints', () => { 'POST /api/objectives/v1/cleargroup', 'POST /api/objectives/v1/updateobjective', 'POST /api/storefronts/v2/buyItem', + 'POST /api/ugcPurchasables/v1/items/bulk', 'PUT /api/equipment/v1/update', ]) diff --git a/apps/img/src/img.app.ts b/apps/img/src/img.app.ts index 742e8d8..33395c7 100644 --- a/apps/img/src/img.app.ts +++ b/apps/img/src/img.app.ts @@ -36,6 +36,18 @@ const ALLOWED_DIMENSIONS = new Set([128, 256, 512, 1024]) /** JPEG quality used when re-encoding a resized image. */ const RESIZE_JPEG_QUALITY = 90 +/** The eight-byte PNG signature. */ +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + +/** + * Whether these bytes are a PNG, by signature rather than the stored content-type: the + * type on an R2 object is whatever the uploader claimed, and a custom avatar item's + * design is posted under a `.bin` filename. + */ +function isPng(bytes: Uint8Array): boolean { + return PNG_SIGNATURE.every((b, i) => bytes[i] === b) +} + /** A requested transform, from `?width=`/`?height=`/`?cropSquare=1`. At least one applies. */ interface Transform { width?: number @@ -65,13 +77,22 @@ function parseTransform( return { width: w, height: h, cropSquare: square } } +/** A transformed image and the type it was encoded as. */ +interface Transformed { + bytes: Uint8Array + contentType: 'image/png' | 'image/jpeg' +} + /** * Decode `input`, apply the requested transform (optional center-crop to a * square, then resize preserving aspect ratio when only one dimension is given), - * and re-encode as JPEG. Runs the Photon WASM codec in-isolate; edge caching + * and re-encode. A PNG source stays PNG — JPEG has no alpha channel, and flattening + * a transparent design onto black is what broke custom avatar items in-game; + * anything else becomes JPEG. Runs the Photon WASM codec in-isolate; edge caching * (see `wrangler.jsonc`) means each variant only pays this cost once. */ -function resizeImage(input: Uint8Array, transform: Transform): Uint8Array { +function resizeImage(input: Uint8Array, transform: Transform): Transformed { + const png = isPng(input) let img = PhotonImage.new_from_byteslice(input) // Every PhotonImage we allocate (source + each stage) must be freed. const owned = [img] @@ -99,7 +120,9 @@ function resizeImage(input: Uint8Array, transform: Transform): Uint8Array { owned.push(img) } - return img.get_bytes_jpeg(RESIZE_JPEG_QUALITY) + return png + ? { bytes: img.get_bytes(), contentType: 'image/png' } + : { bytes: img.get_bytes_jpeg(RESIZE_JPEG_QUALITY), contentType: 'image/jpeg' } } finally { for (const image of owned) image.free() } @@ -238,9 +261,11 @@ async function finalizeImage( ): Promise { let body: BufferSource = bytes if (transform) { - body = resizeImage(new Uint8Array(bytes), transform) - // Output is always JPEG, and the source etag no longer describes the body. - headers.set('content-type', 'image/jpeg') + const out = resizeImage(new Uint8Array(bytes), transform) + body = out.bytes + // The output is re-encoded (PNG stays PNG, everything else is JPEG), and the + // source etag no longer describes the body. + headers.set('content-type', out.contentType) headers.delete('etag') } @@ -370,8 +395,9 @@ app.get( 'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded', 'image is never rewritten in place, a new image gets a new key.', '', - '`?width`/`?height`/`?cropSquare=1` run the body through the Photon codec and always', - 'return JPEG with no `ETag` (the source etag no longer describes the body), and the', + '`?width`/`?height`/`?cropSquare=1` run the body through the Photon codec and', + 're-encode it — a PNG source stays PNG (alpha preserved), anything else becomes JPEG —', + 'with no `ETag` (the source etag no longer describes the body), and the', '`If-None-Match` precondition is skipped. An out-of-range or non-integer dimension is', 'ignored and the original is served — never an error.', '', diff --git a/apps/img/src/openapi.ts b/apps/img/src/openapi.ts index 2fe3e2f..10bee31 100644 --- a/apps/img/src/openapi.ts +++ b/apps/img/src/openapi.ts @@ -28,7 +28,8 @@ export function json(schema: z.ZodType, description: string) { /** * An image-bytes response. The stored object's own content type is served verbatim * (usually `image/jpeg`, occasionally `image/png`); any response that went through the - * Photon resize/crop path is re-encoded and is always `image/jpeg`. + * Photon resize/crop path is re-encoded: `image/png` when the source is a PNG (so alpha + * survives), `image/jpeg` otherwise. */ export function imageBytes(description: string): OpenAPIV3_1.ResponseObject { const schema: OpenAPIV3_1.SchemaObject = { type: 'string', format: 'binary' } diff --git a/apps/img/src/test/integration/api.test.ts b/apps/img/src/test/integration/api.test.ts index 2826542..be7953a 100644 --- a/apps/img/src/test/integration/api.test.ts +++ b/apps/img/src/test/integration/api.test.ts @@ -344,6 +344,45 @@ describe('img endpoints', () => { expect(size.height).toBe(256) }) + it('keeps a PNG source as PNG when resizing, alpha intact', async () => { + // A 64×64 RGBA PNG: left half opaque red, right half fully transparent. Built + // through Photon so the bytes are a real PNG with an alpha channel. + const side = 64 + const rgba = new Uint8Array(side * side * 4) + for (let y = 0; y < side; y++) { + for (let x = 0; x < side; x++) { + const i = (y * side + x) * 4 + rgba[i] = 255 + rgba[i + 3] = x < side / 2 ? 255 : 0 + } + } + const src = new PhotonImage(rgba, side, side) + const pngBytes = src.get_bytes() + src.free() + // Stored under a `.bin` name with a claimed PNG type, like a custom avatar item's + // design; the codec must go by the bytes, not the name. + await env.IMAGES.put('avatar-item/2026-08-26/abc-design.bin', pngBytes, { + httpMetadata: { contentType: 'image/png' }, + }) + + const res = await SELF.fetch(`${ORIGIN}/avatar-item/2026-08-26/abc-design.bin?width=128`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('image/png') + expect(res.headers.get('etag')).toBeNull() + + const out = PhotonImage.new_from_byteslice(new Uint8Array(await res.arrayBuffer())) + try { + expect(out.get_width()).toBe(128) + const px = out.get_raw_pixels() + const alphaAt = (x: number, y: number) => px[(y * 128 + x) * 4 + 3] + // Deep inside each half, away from the Lanczos edge: opaque left, transparent right. + expect(alphaAt(16, 64)).toBe(255) + expect(alphaAt(112, 64)).toBe(0) + } finally { + out.free() + } + }) + it('crops to a square at native size with ?cropSquare=1 alone', async () => { const full = jpegSize( new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer()) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c57230..17956cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -672,6 +672,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt '@standard-community/standard-json': specifier: 0.3.5 version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)