mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
7add875223
Co-authored-by: Nexi (CWN) <communityshieldofficial@gmail.com>
2184 lines
95 KiB
TypeScript
2184 lines
95 KiB
TypeScript
import { Hono } from 'hono'
|
||
import { describeRoute } from 'hono-openapi'
|
||
|
||
import {
|
||
CURRENT_OUTFIT_SLOT,
|
||
getOutfit,
|
||
getOutfits,
|
||
getOutfitsByAccounts,
|
||
inventionDescriptionRejection,
|
||
inventionLongDescriptionRejection,
|
||
inventionNameRejection,
|
||
inventionTagRejection,
|
||
MAX_BULK_OUTFIT_ACCOUNTS,
|
||
setOutfit,
|
||
} from '@repo/domain'
|
||
|
||
import {
|
||
createCustomAvatarItem,
|
||
deleteCustomAvatarItem,
|
||
getCustomAvatarItem,
|
||
getCustomAvatarItems,
|
||
listCustomAvatarItemsByCreator,
|
||
listFeaturedCustomAvatarItems,
|
||
listHotCustomAvatarItems,
|
||
searchCustomAvatarItems,
|
||
updateCustomAvatarItem,
|
||
} from '../custom-avatar-items-db'
|
||
import { authedId, unauthorized } from '../http'
|
||
import {
|
||
createInvention,
|
||
deleteInvention,
|
||
getFeaturedInventions,
|
||
getInventionById,
|
||
getInventionsByIds,
|
||
getInventionsByRoom,
|
||
getInventionTagFilters,
|
||
getInventionTags,
|
||
getInventionVersion,
|
||
getMyInventions,
|
||
getTopInventions,
|
||
INVENTION_TAG_RESULT,
|
||
inventionDeleteResult,
|
||
inventionSaveV9Failure,
|
||
isInventionCheered,
|
||
normalizeInventionTags,
|
||
ownsAllInventions,
|
||
parsePermissionLevel,
|
||
publishInvention,
|
||
searchInventions,
|
||
setInventionCheer,
|
||
setInventionPrice,
|
||
setInventionTags,
|
||
toSaveResult,
|
||
toSaveResultV9,
|
||
updateInvention,
|
||
} from '../inventions-db'
|
||
import {
|
||
AUTHED,
|
||
BareBoolean,
|
||
BareInteger,
|
||
BulkCustomAvatarItemsRequest,
|
||
CreateCustomAvatarItemRequest,
|
||
CustomAvatarItemList,
|
||
CustomAvatarItemReportRequest,
|
||
CustomAvatarItemResponse,
|
||
CustomAvatarItemsPage,
|
||
DeleteInventionRequest,
|
||
ErrorResponse,
|
||
form,
|
||
GeneratedGift,
|
||
GenerateGiftRequest,
|
||
idParam,
|
||
intQuery,
|
||
InventionCheerRequest,
|
||
InventionDeleteResult,
|
||
InventionDetails,
|
||
InventionDto,
|
||
InventionPersonalDetails,
|
||
InventionReportRequest,
|
||
InventionSaveResult,
|
||
InventionSaveV9Result,
|
||
InventionVersionDto,
|
||
json,
|
||
JsonArray,
|
||
jsonBody,
|
||
LegacyAvatarItemSaves,
|
||
OPTIONAL_AUTHED,
|
||
OutfitSaveResponse,
|
||
OutfitsBulkRequest,
|
||
OutfitsBulkResponse,
|
||
OutfitsMeRequest,
|
||
OutfitsMeResponse,
|
||
pageParams,
|
||
PublishInventionRequest,
|
||
SaveInventionRequest,
|
||
SaveInventionV9Request,
|
||
SetTagsRequest,
|
||
SetTagsResponse,
|
||
stringParam,
|
||
stringQuery,
|
||
SuccessErrorEnvelope,
|
||
SuccessValueEnvelope,
|
||
TagFilters,
|
||
UNAUTHORIZED_RESPONSE,
|
||
UpdateCustomAvatarItemRequest,
|
||
UpdateInventionMetadataRequest,
|
||
UpdatePriceRequest,
|
||
} from '../openapi'
|
||
import { createReport } from '../reports-db'
|
||
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
|
||
|
||
import type { Context } from 'hono'
|
||
import type { App } from '../context'
|
||
import type { InventionTag, SavedInvention } from '../inventions-db'
|
||
|
||
/**
|
||
* The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers
|
||
* EMPTY rather than being truncated.
|
||
*
|
||
* Empty rather than the first 100, because a truncated answer is indistinguishable from the
|
||
* items simply not existing — the client reads the items it got back, not the ids it asked
|
||
* about, so it cannot tell a cut-off batch from a batch of misses and would cache the
|
||
* difference. Nothing renders this many custom items at once, so a batch this size is the
|
||
* client doing something other than filling a screen.
|
||
*/
|
||
const BULK_CUSTOM_AVATAR_ITEM_CAP = 100
|
||
|
||
/**
|
||
* The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated
|
||
* `customAvatarItemIds` form fields, and the same spelling is read off the query string
|
||
* too — the client's exact encoding here has not been pinned down, so both are accepted
|
||
* rather than guessing one and answering nothing when it's the other.
|
||
*
|
||
* Each value may itself be a comma-separated list, and blanks are dropped rather than
|
||
* failing the request: a stray id must not cost the caller the rest of the batch. The order
|
||
* asked for is preserved, since `getCustomAvatarItems` answers in it.
|
||
*/
|
||
async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
|
||
const raw = [...(c.req.queries('customAvatarItemIds') ?? [])]
|
||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'customavataritemids')
|
||
const posted = key === undefined ? [] : body[key]
|
||
for (const value of Array.isArray(posted) ? posted : [posted]) {
|
||
if (typeof value === 'string') raw.push(value)
|
||
}
|
||
return raw
|
||
.flatMap((value) => value.split(','))
|
||
.map((v) => v.trim())
|
||
.filter((v) => v !== '')
|
||
}
|
||
|
||
/**
|
||
* The gate every invention write runs through: the caller must be signed in, the
|
||
* invention must exist, and it must be theirs. Yields the loaded invention, or why not —
|
||
* as a reason and the status it maps to, so that a caller answering an envelope can put
|
||
* the reason where its client will read it instead of in a body that client can't parse.
|
||
* {@link creatorsInvention} is the rendering the older routes want.
|
||
*/
|
||
async function creatorsInventionResult(
|
||
c: Context<App>,
|
||
inventionId: number
|
||
): Promise<
|
||
{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }
|
||
> {
|
||
const playerId = await authedId(c)
|
||
if (playerId === null) return { rejection: 'Unauthorized', status: 401 }
|
||
if (Number.isNaN(inventionId)) return { rejection: 'inventionId is required', status: 400 }
|
||
|
||
const invention = await getInventionById(c.env.DB, inventionId)
|
||
if (invention === null) return { rejection: 'No such invention', status: 404 }
|
||
if (invention.CreatorPlayerId !== playerId) {
|
||
return { rejection: 'Not your invention', status: 403 }
|
||
}
|
||
return { invention }
|
||
}
|
||
|
||
/**
|
||
* {@link creatorsInventionResult} as the older invention writes answer it: the loaded
|
||
* invention, or the response to return as-is (400 / 401 / 403 / 404).
|
||
*/
|
||
async function creatorsInvention(
|
||
c: Context<App>,
|
||
inventionId: number
|
||
): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> {
|
||
const gate = await creatorsInventionResult(c, inventionId)
|
||
if ('invention' in gate) return gate
|
||
if (gate.status === 401) return { response: unauthorized(c) }
|
||
if (gate.status === 404) return { response: c.notFound() }
|
||
return { response: c.json({ error: gate.rejection }, gate.status) }
|
||
}
|
||
|
||
/**
|
||
* The tags a `{ AutoTags, CustomTags }` request asks for, and whether they were taken —
|
||
* the block the v9 save sends as `tagsRequest` and `v2/metadata` sends as `TagsRequest`.
|
||
* Null when the client named no block at all, which each caller reads its own way: a save
|
||
* stores no tags, an edit leaves the stored ones alone.
|
||
*
|
||
* Tags are held to the same rule `v1/settags` applies, but a tag that breaks it costs the
|
||
* TAGS and not the write: both replies carry a tag result of their own precisely because
|
||
* the two outcomes are separate, and refusing a save would make the player redo a build
|
||
* over a hyphen. All the tags go rather than the offending one alone, so nothing is
|
||
* silently half-applied — the creator re-submits the list and sees what took. Blanks are
|
||
* skipped rather than counted against it; the client pads its lists with empties.
|
||
*/
|
||
function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: number } | null {
|
||
if (typeof request !== 'object' || request === null) return null
|
||
|
||
const lists = request as Record<string, unknown>
|
||
const strings = (v: unknown): string[] =>
|
||
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
||
const autoTags = strings(lists.AutoTags)
|
||
const customTags = strings(lists.CustomTags)
|
||
|
||
const rejected = [...autoTags, ...customTags].some((raw) => {
|
||
const tag = raw.trim().toLowerCase()
|
||
return tag !== '' && inventionTagRejection(tag) !== null
|
||
})
|
||
return rejected
|
||
? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected }
|
||
: { tags: normalizeInventionTags(autoTags, customTags), tagResult: INVENTION_TAG_RESULT.success }
|
||
}
|
||
|
||
/**
|
||
* What an invention save produced: the stored record and how its tags fared, or the one
|
||
* message that refuses it. Both save routes go through {@link createInventionFromBody} to
|
||
* get one of these and then render it their own way — v6 bare, v9 enveloped — because the
|
||
* two versions disagree about the shape of a reply, not about what a save is.
|
||
*/
|
||
type InventionSaveOutcome =
|
||
| { rejection: string }
|
||
| { invention: SavedInvention; tags: InventionTag[]; tagResult: number }
|
||
|
||
/**
|
||
* The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does
|
||
* plus what the invention points at (`referencedUnityAssetIds`), what it says about itself
|
||
* (`longDescription`, `displayMetadataJson`, `convertedFromInventionId`), `ugcVersion` and
|
||
* `hasBetaContent`, and the tags that until now needed a second `v1/settags` call. One
|
||
* reader takes them all: a v6 client sends none of them, and each is optional, so parsing
|
||
* them here changes nothing about the record a v6 save stores.
|
||
*/
|
||
async function createInventionFromBody(
|
||
c: Context<App>,
|
||
creatorPlayerId: number,
|
||
body: Record<string, unknown>
|
||
): Promise<InventionSaveOutcome> {
|
||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||
const bool = (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : undefined)
|
||
const list = <T>(v: unknown, is: (x: unknown) => x is T): T[] | undefined =>
|
||
Array.isArray(v) ? v.filter(is) : undefined
|
||
const isString = (v: unknown): v is string => typeof v === 'string'
|
||
const isNumber = (v: unknown): v is number => typeof v === 'number'
|
||
|
||
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
|
||
if (!inventionDataFilename) return { rejection: 'inventionDataFilename is required' }
|
||
|
||
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
|
||
// "No description yet"), so only a supplied one is held to the rules — otherwise
|
||
// saving an unnamed invention would fail the 3-character minimum on a name the
|
||
// player never typed.
|
||
const name = str(body.name)?.trim()
|
||
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
|
||
if (nameRejection !== null) return { rejection: nameRejection }
|
||
|
||
const description = str(body.description)
|
||
const descriptionRejection =
|
||
description === undefined ? null : inventionDescriptionRejection(description)
|
||
if (descriptionRejection !== null) return { rejection: descriptionRejection }
|
||
|
||
// v9 folds `v1/settags` into the save; a client that names no tags gets none.
|
||
const requested = requestedTags(body.tagsRequest) ?? {
|
||
tags: [],
|
||
tagResult: INVENTION_TAG_RESULT.success,
|
||
}
|
||
|
||
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
||
creatorPlayerId,
|
||
inventionDataFilename,
|
||
name,
|
||
description,
|
||
imageName: str(body.imageName),
|
||
instantiationCost: num(body.instantiationCost),
|
||
lightsCost: num(body.lightsCost),
|
||
chipsCost: num(body.chipsCost),
|
||
cloudVariablesCost: num(body.cloudVariablesCost),
|
||
aiCost: num(body.aiCost),
|
||
creationRoomId: num(body.creationRoomId),
|
||
referencedInventions: list(body.referencedInventions, isNumber),
|
||
ugcVersion: num(body.ugcVersion),
|
||
hasBetaContent: bool(body.hasBetaContent),
|
||
referencedUnityAssetIds: list(body.referencedUnityAssetIds, isString),
|
||
longDescription: str(body.longDescription),
|
||
displayMetadataJson: str(body.displayMetadataJson),
|
||
convertedFromInventionId: num(body.convertedFromInventionId),
|
||
tags: requested.tags,
|
||
})
|
||
return { invention, ...requested }
|
||
}
|
||
|
||
/**
|
||
* The `?id=1&id=2` list the invention batch endpoints take. `id` repeats, and each
|
||
* value may itself be a comma-separated list; anything non-numeric is dropped.
|
||
*/
|
||
function inventionIdQuery(c: Context<App>): number[] {
|
||
return (
|
||
c.req
|
||
.queries('id')
|
||
?.flatMap((raw) => raw.split(','))
|
||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||
.filter((id) => !Number.isNaN(id)) ?? []
|
||
)
|
||
}
|
||
|
||
// ---- Avatar gifts ----------------------------------------------------------
|
||
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) and
|
||
// gift-box consume live in the `econ` worker, which the client calls on the econ host
|
||
// — not here. Only the gift `generate` action remains on this worker.
|
||
export const avatarRoutes = new Hono<App>({ strict: false })
|
||
.post(
|
||
'/api/avatar/v2/gifts/generate',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Generate a gift box',
|
||
description:
|
||
'Mint the gift box a player earned (levelling up, a room reward). With no ' +
|
||
'EarnableRewards catalog wired up this always falls back to a token gift of a ' +
|
||
'random amount, and the box is not persisted — its `Id` is 0 and it cannot be ' +
|
||
'opened through the `econ` worker’s consume endpoint.',
|
||
security: AUTHED,
|
||
requestBody: form(GenerateGiftRequest, 'Where the gift was earned'),
|
||
responses: {
|
||
200: json(GeneratedGift, 'The generated (unpersisted) gift'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
|
||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||
const giftContext =
|
||
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
|
||
const message = typeof body.Message === 'string' ? body.Message : ''
|
||
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
|
||
|
||
// No EarnableRewards binding → always fall back to a token gift.
|
||
const tokenAmounts = [10, 25, 50, 100, 250, 500]
|
||
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
|
||
|
||
return c.json({
|
||
Id: 0, // TODO: real id once gifts are persisted
|
||
FromPlayerId: 1,
|
||
ConsumableItemDesc: '',
|
||
AvatarItemDesc: '',
|
||
FriendlyName: '',
|
||
AvatarItemType: 0,
|
||
EquipmentPrefabName: '',
|
||
EquipmentModificationGuid: '',
|
||
CurrencyType: 2,
|
||
Currency: currency,
|
||
Xp: xp,
|
||
Level: 0,
|
||
Platform: -1,
|
||
PlatformsToSpawnOn: -1,
|
||
BalanceType: 0,
|
||
GiftContext: giftContext,
|
||
GiftRarity: 20,
|
||
Message: message,
|
||
})
|
||
}
|
||
)
|
||
|
||
// A batch lookup of LOCKED avatar items — the items the client shows greyed out, so it
|
||
// posts the ids it wants the locked state for. Nothing here locks avatar items (the
|
||
// catalogs `econ` serves are all unlocked), so nothing comes back and the client renders
|
||
// none as locked. Unlike its custom-item sibling above this one is NOT auth-gated: the
|
||
// reference answers the empty array outright, without validating a token first.
|
||
.post(
|
||
'/api/avatar/v1/lockeditems/bulk',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Locked avatar items in bulk',
|
||
description:
|
||
'Resolves a batch of avatar-item ids to the ones that are LOCKED for the caller, as ' +
|
||
'a bare array. Nothing on this server locks avatar items, so it is always `[]` and ' +
|
||
'the posted ids are not parsed — a miss is not an error, the client simply renders ' +
|
||
'nothing as locked.\n\n' +
|
||
'No auth, matching the reference, which returns the empty array without checking a ' +
|
||
'token — in contrast to `/api/customAvatarItems/v1/bulk`, which validates one first.',
|
||
responses: { 200: json(JsonArray, 'The locked items — always empty here') },
|
||
}),
|
||
(c) => c.json([])
|
||
)
|
||
|
||
// Custom avatar item gates — real Rec Room client endpoints with no backing
|
||
// implementation yet; we enable them. Flip to `false` to disable the
|
||
// corresponding flow. `isCreationAllowedForAccount` wraps its answer in the
|
||
// success/value envelope; the other two return a bare JSON boolean.
|
||
.get(
|
||
'/api/customAvatarItems/v1/isCreationAllowedForAccount',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'May this account create custom items?',
|
||
description:
|
||
'A feature gate with no backing implementation — we answer yes. Note this one ' +
|
||
'wraps its answer in the `{ success, value }` envelope while the two gates below ' +
|
||
'return a bare boolean.',
|
||
responses: { 200: json(SuccessValueEnvelope, 'Allowed') },
|
||
}),
|
||
(c) => c.json({ success: true, value: null })
|
||
)
|
||
.get(
|
||
'/api/customAvatarItems/v1/isCreationEnabled',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Is custom-item creation enabled?',
|
||
description: 'A server-wide feature gate. Enabled; flip to `false` to disable the flow.',
|
||
responses: { 200: json(BareBoolean, 'A bare `true`') },
|
||
}),
|
||
(c) => c.json(true)
|
||
)
|
||
.get(
|
||
'/api/customAvatarItems/v1/isRenderingEnabled',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Is custom-item rendering enabled?',
|
||
description: 'A server-wide feature gate. Enabled; flip to `false` to disable the flow.',
|
||
responses: { 200: json(BareBoolean, 'A bare `true`') },
|
||
}),
|
||
(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,
|
||
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
|
||
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')
|
||
const limit = maxApiUploadBytes(c.env)
|
||
// Each file gets the full per-file ceiling. Check both before either is copied into
|
||
// an ArrayBuffer or written, so a rejected request never leaves half an item in R2.
|
||
if (exceedsApiUploadLimit(body.thumbnailImage, limit)) {
|
||
return c.json(
|
||
{
|
||
Value: null,
|
||
Success: false,
|
||
Error: `thumbnailImage exceeds the ${limit}-byte upload limit`,
|
||
error_id: null,
|
||
},
|
||
413
|
||
)
|
||
}
|
||
if (exceedsApiUploadLimit(body.design, limit)) {
|
||
return c.json(
|
||
{
|
||
Value: null,
|
||
Success: false,
|
||
Error: `design exceeds the ${limit}-byte upload limit`,
|
||
error_id: null,
|
||
},
|
||
413
|
||
)
|
||
}
|
||
|
||
// 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: 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') },
|
||
}),
|
||
async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB))
|
||
)
|
||
|
||
// The store's item search. The client sends the full set of `outfitTypes` it can render
|
||
// plus paging, and expects a BARE ARRAY of items back — not the `{ Results, TotalResults }`
|
||
// envelope `fromCreator` uses.
|
||
//
|
||
// Several parameters are accepted and not yet acted on; they are listed in the description
|
||
// rather than dropped silently, because a caller cannot tell the difference between a filter
|
||
// that was applied and one that was ignored by looking at the results.
|
||
.get(
|
||
'/api/customAvatarItems/v1/search',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Search custom avatar items',
|
||
description: [
|
||
'The store’s item search: published items (`Accessibility` 0 is unpublished and is',
|
||
'left out, from its creator too — `fromCreator` is where they see their own),',
|
||
'newest first, as a BARE ARRAY.',
|
||
'`searchQuery` matches an item’s NAME or its DESCRIPTION, case-insensitively, as a',
|
||
'substring; `%` and `_` in it are literal.',
|
||
'`outfitTypes` may repeat and acts as a whitelist; sending none means no filter',
|
||
'rather than no results, since the client sends every type it can render.',
|
||
'`minPrice`/`maxPrice` bound the price, inclusive.',
|
||
'`skip`/`take` page the results, `take` capped at 200.',
|
||
'`includeCoachItems=false` leaves out this server’s stock content.',
|
||
'`itemTypes`, `ordering`, `unityAssetTarget` and `unityAssetVersion` are accepted and',
|
||
'NOT yet acted on — nothing records purchase or wear counts to rank by, no per-target',
|
||
'asset variants are stored, and custom avatar items are the only item type there is.',
|
||
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
|
||
'whatever it says.',
|
||
].join(' '),
|
||
parameters: [
|
||
{
|
||
name: 'searchQuery',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'Free text matched against the item’s name or description',
|
||
schema: { type: 'string' },
|
||
},
|
||
{
|
||
name: 'outfitTypes',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'OutfitType to include; repeat for several. None means all.',
|
||
schema: { type: 'array', items: { type: 'integer' } },
|
||
},
|
||
{
|
||
name: 'skip',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'Rows to skip (default 0)',
|
||
schema: { type: 'integer', minimum: 0 },
|
||
},
|
||
{
|
||
name: 'take',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'Rows to return (default 50, capped at 200)',
|
||
schema: { type: 'integer', minimum: 0 },
|
||
},
|
||
{
|
||
name: 'minPrice',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'Lowest price to include, inclusive',
|
||
schema: { type: 'integer', minimum: 0 },
|
||
},
|
||
{
|
||
name: 'maxPrice',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'Highest price to include, inclusive',
|
||
schema: { type: 'integer', minimum: 0 },
|
||
},
|
||
{
|
||
name: 'includeCoachItems',
|
||
in: 'query',
|
||
required: false,
|
||
description: 'Include the Coach’s stock items (default true)',
|
||
schema: { type: 'boolean' },
|
||
},
|
||
],
|
||
responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
|
||
}),
|
||
async (c) => {
|
||
// `?outfitTypes=0&outfitTypes=2&…` — repeated, so read every value. A non-numeric one is
|
||
// dropped rather than turned into NaN, which would match nothing and quietly empty a
|
||
// filter the caller believes they set.
|
||
const outfitTypes = c.req
|
||
.queries('outfitTypes')
|
||
?.map((v) => Number.parseInt(v, 10))
|
||
.filter((n) => Number.isInteger(n))
|
||
|
||
// The client capitalises its booleans (`includeCoachItems=True`), so this is folded
|
||
// before comparing; anything that isn't recognisably false leaves the default alone.
|
||
const includeCoachItems = c.req.query('includeCoachItems')?.toLowerCase() !== 'false'
|
||
|
||
const int = (name: string): number | undefined => {
|
||
const raw = c.req.query(name)
|
||
if (raw === undefined) return undefined
|
||
const n = Number.parseInt(raw, 10)
|
||
return Number.isInteger(n) ? n : undefined
|
||
}
|
||
|
||
return c.json(
|
||
await searchCustomAvatarItems(c.env.DB, {
|
||
searchQuery: c.req.query('searchQuery'),
|
||
outfitTypes,
|
||
includeCoachItems,
|
||
minPrice: int('minPrice'),
|
||
maxPrice: int('maxPrice'),
|
||
skip: int('skip'),
|
||
take: int('take'),
|
||
})
|
||
)
|
||
}
|
||
)
|
||
|
||
// The "hot" (trending) custom-avatar-item feed: every published (`Accessibility` != 0)
|
||
// item from the `custom_avatar_item` table. There is nothing to rank a trend from yet,
|
||
// so it is the accessible items, newest first.
|
||
.get(
|
||
'/api/customAvatarItems/v1/hot',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Trending custom avatar items',
|
||
description:
|
||
'The “hot” feed: the published items (`Accessibility` 0 is unpublished and is left ' +
|
||
'out), newest first, up to 50. No purchase or wear counts are recorded, so there is ' +
|
||
'no trend to rank by and recency stands in for one.',
|
||
responses: { 200: json(CustomAvatarItemList, 'The items, newest first') },
|
||
}),
|
||
async (c) => c.json(await listHotCustomAvatarItems(c.env.DB))
|
||
)
|
||
|
||
// A batch lookup of custom avatar items by id, out of the `custom_avatar_item` table.
|
||
// The reference filters its catalog down to the posted ids and returns the MATCHES AS A
|
||
// BARE ARRAY — not the `{ Results, TotalResults }` page that catalog is written in, and
|
||
// not a 404 for ids it doesn't hold.
|
||
//
|
||
// This is how a `1.<guid>` entity in a GENERIC discovery row (`lists`
|
||
// `/algorithmiclists/:list?type=5`) gets resolved, so a row naming a custom item renders
|
||
// nothing at all when this doesn't answer. It stubbed out `[]` while nothing stored custom
|
||
// items; the table has existed since migration 0015 and the stub outlived it.
|
||
//
|
||
// Auth-gated, and the token is checked before anything else, as the reference does.
|
||
//
|
||
// A batch over {@link BULK_CUSTOM_AVATAR_ITEM_CAP} ids answers EMPTY. The client has been
|
||
// seen posting far more ids than a screen could draw, and serving those is both a large
|
||
// query and a large response for a request that is already not what it looks like. Empty is
|
||
// the safe answer because a miss here is not an error: unknown ids are simply absent, so the
|
||
// client already handles getting back fewer items than it asked about.
|
||
.post(
|
||
'/api/customAvatarItems/v1/bulk',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Custom avatar items in bulk',
|
||
description:
|
||
'Resolves a batch of custom-avatar-item ids to their items: the posted ' +
|
||
'`customAvatarItemIds` filtered against the `custom_avatar_item` table, returned ' +
|
||
'as a BARE ARRAY of the ones that matched, in the order they were asked for. Not ' +
|
||
'the `{ Results, TotalResults }` page the sibling custom-item reads serve — the ' +
|
||
'reference keeps its catalog in that shape but answers this route with the ' +
|
||
'filtered array alone.\n\n' +
|
||
'A miss is not an error: unknown ids are simply absent from the response, and the ' +
|
||
'client reads the items it got back rather than the ids it asked for. Unpublished ' +
|
||
'items (`Accessibility` 0) miss for everyone but their creator, the same rule the ' +
|
||
'feeds and the creator shelf apply.\n\n' +
|
||
'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' +
|
||
'and the same spelling on the query string are both accepted, since the client’s ' +
|
||
'exact encoding here has not been pinned down.\n\n' +
|
||
'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' +
|
||
'client has been seen posting more than a screen could draw, and a miss is already ' +
|
||
'not an error here.',
|
||
security: AUTHED,
|
||
requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'),
|
||
responses: {
|
||
200: json(CustomAvatarItemList, 'The items that matched, in request order'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
|
||
const ids = await bulkCustomAvatarItemIds(c)
|
||
|
||
// Over the cap: empty, and the table is not touched. Answering the batch would be a
|
||
// large query and a large response for a request that is already not what it looks
|
||
// like — a screen does not draw this many items.
|
||
if (ids.length > BULK_CUSTOM_AVATAR_ITEM_CAP) return c.json([])
|
||
|
||
const items = await getCustomAvatarItems(c.env.DB, ids)
|
||
// Unpublished items are held back from everyone but their creator — the same rule
|
||
// the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
|
||
// through this route that the feeds hide.
|
||
return c.json(
|
||
items.filter((item) => item.Accessibility !== 0 || item.CreatorAccountId === id)
|
||
)
|
||
}
|
||
)
|
||
|
||
// 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, 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, 'The creator’s items') },
|
||
}),
|
||
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
|
||
// can render the custom version instead. Nothing stores custom items yet, so nothing
|
||
// has a save — an empty list means "use the legacy items as-is".
|
||
.post(
|
||
'/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Custom-item saves for legacy avatar items',
|
||
description:
|
||
'Given a set of legacy avatar items, the custom-item saves that replace them, keyed ' +
|
||
'by the legacy item’s `AvatarItemDesc`. Nothing stores custom items yet, so the map ' +
|
||
'is always empty — which the client reads as “render the legacy items as-is”. The ' +
|
||
'request body is ignored.\n\n' +
|
||
'The value shape is the official one, recorded here for documentation; we never ' +
|
||
'emit one until custom items are stored.',
|
||
responses: { 200: json(LegacyAvatarItemSaves, 'An empty map') },
|
||
}),
|
||
(c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} })
|
||
)
|
||
|
||
// The newer outfit read, on a bare (un-prefixed) path. Auth-gated. The outfit the
|
||
// player is wearing is slot 0 of the shared `outfit` table (the same table the `econ`
|
||
// worker's saved-outfit slots live in); a player who has never saved gets the
|
||
// brand-new-account envelope instead.
|
||
.get(
|
||
'/outfits/me',
|
||
describeRoute({
|
||
tags: ['Avatar', '2025'],
|
||
summary: 'The caller’s outfit',
|
||
description:
|
||
'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' +
|
||
'`outfit` table — the newer client treats slot 0 as the outfit currently worn — and ' +
|
||
'handed back exactly as it was saved, since the payload’s heavy fields are the ' +
|
||
'client’s own JSON-in-a-string documents.\n\n' +
|
||
'A player who has never saved gets the brand-new-account envelope, which is a ' +
|
||
'different, flatter shape than a stored outfit: the four empty-string fields ' +
|
||
'`FaceFeatures`, `HairColor`, `OutfitSelections` and `SkinColor`, and nothing else.',
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(OutfitsMeResponse, 'The stored outfit, or the empty envelope'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
|
||
const outfit = await getOutfit(c.env.DB, id, CURRENT_OUTFIT_SLOT)
|
||
if (outfit !== null) return c.json(outfit)
|
||
|
||
return c.json({
|
||
FaceFeatures: '',
|
||
HairColor: '',
|
||
OutfitSelections: '',
|
||
SkinColor: '',
|
||
})
|
||
}
|
||
)
|
||
|
||
// Saving an outfit through the same bare path — into the slot the body names, which
|
||
// is slot 0 for the outfit being worn. Stored verbatim: the heavy fields are the
|
||
// client's own JSON-in-a-string documents, and re-encoding risks changing a payload
|
||
// it has to parse back.
|
||
//
|
||
// Answers the bare `{ Success, Error, error_id }` envelope — no `Value` key, and NOT the
|
||
// outfit that was just saved: the client keeps what it sent and only reads whether the
|
||
// save worked.
|
||
.put(
|
||
'/outfits/me',
|
||
describeRoute({
|
||
tags: ['Avatar', '2025'],
|
||
summary: 'Save the caller’s outfit',
|
||
description:
|
||
'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' +
|
||
'outfit worn, which is what the GET reads. Re-saving a slot overwrites it.\n\n' +
|
||
'The payload is stored verbatim: its heavy fields (`SelectionsV2`, `FaceFeatures`, ' +
|
||
'`CustomizationSettings`) are whole JSON documents encoded as strings by the ' +
|
||
'client’s own serializer, so nothing here parses or re-encodes them.\n\n' +
|
||
'The response is the base envelope with no `Value` key — three keys, and the outfit ' +
|
||
'is not echoed back. The mixed casing (`Success`/`Error` but `error_id`) is the ' +
|
||
'reference’s, not a typo.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'),
|
||
responses: {
|
||
200: json(OutfitSaveResponse, 'Saved — `{ Success: true, Error: null, error_id: null }`'),
|
||
400: json(ErrorResponse, 'Unparseable 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<string, unknown> | null
|
||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||
|
||
// The client sends `Slot`; a body without one saves the worn outfit.
|
||
const outfit = {
|
||
...body,
|
||
Slot: typeof body.Slot === 'number' ? body.Slot : CURRENT_OUTFIT_SLOT,
|
||
}
|
||
await setOutfit(c.env.DB, id, outfit)
|
||
return c.json({ Success: true, Error: null, error_id: null })
|
||
}
|
||
)
|
||
|
||
// Several players' worn outfits at once — what the client calls to dress everyone in a
|
||
// room rather than asking per player. POST because the account list rides in the body.
|
||
//
|
||
// The answer is a MAP keyed by account id, not a list: the client looks each player up by
|
||
// id, and a list would make it match up the order itself. An account with nothing saved is
|
||
// left out of the map — see `getOutfitsByAccounts`.
|
||
//
|
||
// `UnityAssetTarget` / `UnityAssetVersion` name the baked-asset build the client would
|
||
// like the outfits for. Nothing here bakes assets, so both are accepted and ignored.
|
||
.post(
|
||
'/outfits/bulk',
|
||
describeRoute({
|
||
tags: ['Avatar'],
|
||
summary: 'Several players’ outfits',
|
||
description:
|
||
'The worn outfit (slot 0) of each account in `AccountIds`, keyed by account id — the ' +
|
||
'call the client makes to dress a room full of players in one request.\n\n' +
|
||
'A MAP rather than a list: the client looks each player up by id. The key is the id ' +
|
||
'as a string, and the value is the same stored outfit `GET /outfits/me` serves, ' +
|
||
'handed back exactly as it was saved. An account with nothing saved in slot 0 is ' +
|
||
'ABSENT from the map rather than carrying a null — a map says “no outfit” by not ' +
|
||
'having the key, and inventing one for a player who has never saved would dress them ' +
|
||
'in something they never chose.\n\n' +
|
||
'Repeated ids collapse, and at most 99 distinct accounts may be named — one query, ' +
|
||
'one round trip, and a room holds nothing like that many players. A longer list is ' +
|
||
'a 400 rather than a partial answer, which would read as “those players have no ' +
|
||
'outfit”. `UnityAssetTarget` / `UnityAssetVersion` name a baked-asset build and are ' +
|
||
'accepted and ignored: nothing here bakes assets.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(OutfitsBulkRequest, 'The accounts whose outfits are wanted'),
|
||
responses: {
|
||
200: json(OutfitsBulkResponse, 'The outfits that exist, keyed by account id'),
|
||
400: json(ErrorResponse, 'Unparseable body, or more than 99 accounts'),
|
||
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<string, unknown> | null
|
||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||
|
||
// Only the integers survive: the field is the client's, and a malformed entry is
|
||
// dropped rather than turned into a NaN lookup that can never match a row.
|
||
const accountIds = Array.isArray(body.AccountIds)
|
||
? body.AccountIds.filter((v): v is number => Number.isInteger(v))
|
||
: []
|
||
// One query, one round trip — so the list has to fit D1's parameter cap. A room
|
||
// holds nothing like this many players; a longer list is refused rather than
|
||
// quietly answered in part, which would look like those accounts have no outfit.
|
||
if (new Set(accountIds).size > MAX_BULK_OUTFIT_ACCOUNTS) {
|
||
return c.json({ error: `At most ${MAX_BULK_OUTFIT_ACCOUNTS} accounts per request` }, 400)
|
||
}
|
||
|
||
const outfits = await getOutfitsByAccounts(c.env.DB, accountIds, CURRENT_OUTFIT_SLOT)
|
||
const OutfitsByAccountId: Record<string, unknown> = {}
|
||
for (const [accountId, outfit] of outfits) OutfitsByAccountId[String(accountId)] = outfit
|
||
return c.json({ OutfitsByAccountId })
|
||
}
|
||
)
|
||
|
||
// The caller's outfit wardrobe — every slot they have saved, ordered by slot. The same
|
||
// read as `econ`'s `GET /api/avatar/v3/saved`, on the bare path the newer client uses:
|
||
// both worker's write paths land in the shared `outfit` table, so both list endpoints
|
||
// serve the same rows.
|
||
//
|
||
// Slot 0 is INCLUDED. It is the outfit being worn (what `/outfits/me` reads), but it is
|
||
// also a saved slot: the newer client picks the slot it saves into (`/api/avatar/v4/saved/set`
|
||
// 400s without one), so filtering slot 0 out would hide a real saved outfit whenever a
|
||
// wardrobe entry lands there. Showing the worn outfit as a wardrobe entry is the cheaper
|
||
// mistake of the two.
|
||
//
|
||
// Rows are served exactly as they were stored, unprojected — see the note atop
|
||
// `outfits-db.ts`: econ's saved slots hold the old flat PascalCase outfit while
|
||
// `/outfits/me` holds the newer envelope, and neither is converted into the other.
|
||
.get(
|
||
'/outfits/me/saved',
|
||
describeRoute({
|
||
tags: ['Avatar', '2025'],
|
||
summary: 'The caller’s saved outfits',
|
||
description:
|
||
'The wardrobe behind the newer outfit screen: every slot the caller has saved, ' +
|
||
'ordered by slot, and `[]` when they have saved none. The same rows `econ`’s ' +
|
||
'`GET /api/avatar/v3/saved` serves — both write paths land in the shared `outfit` ' +
|
||
'table.\n\n' +
|
||
'Slot 0 is included. It is the outfit being worn (what `GET /outfits/me` reads) but ' +
|
||
'it is a saved slot too, and the client chooses the slot it saves into, so omitting ' +
|
||
'it would hide a real outfit whenever a wardrobe entry lands there.\n\n' +
|
||
'Each outfit is served exactly as it was stored, unprojected: slots written through ' +
|
||
'`PUT /outfits/me` hold the newer envelope while `econ`’s saved-set slots hold the ' +
|
||
'old flat shape, and neither is converted into the other.',
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(JsonArray, 'The saved outfits, ordered by slot (empty when none)'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json(await getOutfits(c.env.DB, id))
|
||
}
|
||
)
|
||
|
||
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
|
||
// or 404 when there's no such invention.
|
||
.get(
|
||
'/api/inventions/v1',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'One invention by id',
|
||
description: 'The stored `RRInvention`. Public — an unpublished invention is served too.',
|
||
parameters: [intQuery('inventionId', 'Invention id; required')],
|
||
responses: {
|
||
200: json(InventionDto, 'The invention'),
|
||
400: json(ErrorResponse, 'Missing or non-numeric inventionId'),
|
||
404: { description: 'No such invention' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||
const invention = await getInventionById(c.env.DB, inventionId)
|
||
return invention ? c.json(invention) : c.notFound()
|
||
}
|
||
)
|
||
|
||
// The tag filter chips on the invention browse screen. Derived from the tags in
|
||
// use on published inventions — most popular first, top few pinned. Public.
|
||
.get(
|
||
'/api/inventions/v1/tagfilters',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Invention browse filter chips',
|
||
description:
|
||
'The filter chips on the invention browse screen, derived from the tags actually in ' +
|
||
'use on published inventions — most popular first, the top few pinned. ' +
|
||
'`TrendingFilters` is null: that needs recent-activity data we do not keep, and the ' +
|
||
'client treats null as absent.',
|
||
responses: { 200: json(TagFilters, 'The chips in use') },
|
||
}),
|
||
async (c) => c.json(await getInventionTagFilters(c.env.DB))
|
||
)
|
||
|
||
// A batch of inventions by id (`?id=1&id=2`, and each `id` may itself be a
|
||
// comma-separated list). Unknown ids are dropped rather than 404ing, and an empty
|
||
// request is an empty list. Auth is optional and only widens what you see: an
|
||
// unpublished invention comes back only to its creator. Bare array.
|
||
.get(
|
||
'/api/inventions/v2/batch',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Inventions by id, in bulk',
|
||
description:
|
||
'Look up several inventions at once. Unknown ids are dropped rather than 404ing, ' +
|
||
'and an empty request is an empty list. Auth is optional and only widens what you ' +
|
||
'see: an unpublished invention comes back only to its creator.',
|
||
parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')],
|
||
responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') },
|
||
}),
|
||
async (c) => {
|
||
const ids = inventionIdQuery(c)
|
||
if (ids.length === 0) return c.json([])
|
||
|
||
const playerId = await authedId(c)
|
||
const inventions = await getInventionsByIds(c.env.DB, ids)
|
||
return c.json(
|
||
inventions.filter(
|
||
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
|
||
)
|
||
)
|
||
}
|
||
)
|
||
|
||
// Whether the caller owns every invention in a lineage (`?id=101&id=102&id=103`) —
|
||
// the invention plus everything nested inside it, as the client enumerates it. One
|
||
// bare `true`/`false` for the whole set, not a verdict per id. Auth-gated: the
|
||
// question is about the caller.
|
||
.get(
|
||
'/api/inventions/v1/fulllineageowner',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Does the caller own this whole lineage?',
|
||
description:
|
||
'Asked when saving an invention built out of other inventions: may this player use ' +
|
||
'every piece? The client sends the whole lineage as repeated `id`s, and this ' +
|
||
'answers a single bare `true`/`false` for the set — false as soon as one is not the ' +
|
||
'caller’s. An invention is theirs if they created it or acquired it; an id with no ' +
|
||
'invention behind it is not owned. Price and permission don’t enter into it — a ' +
|
||
'free invention still has to be picked up, and that writes the same inventory row ' +
|
||
'a paid one does.\n\n' +
|
||
'Only the ids asked about are checked — this does not walk `ReferencedInventions` ' +
|
||
'to widen the lineage, since the client knows what the thing it is holding is ' +
|
||
'actually made of. No ids at all is `true`: nothing in an empty lineage is unowned.',
|
||
security: AUTHED,
|
||
parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')],
|
||
responses: {
|
||
200: json(BareBoolean, 'Whether the caller owns every invention asked about'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const playerId = await authedId(c)
|
||
if (playerId === null) return unauthorized(c)
|
||
return c.json(await ownsAllInventions(c.env.DB, playerId, inventionIdQuery(c)))
|
||
}
|
||
)
|
||
|
||
// A room's inventions (`?id=76`) — published inventions created in that room,
|
||
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
|
||
.get(
|
||
'/api/inventions/v1/room',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'A room’s inventions',
|
||
description: 'Published inventions created in that room, newest first.',
|
||
parameters: [intQuery('id', 'Room id; required'), ...pageParams(100)],
|
||
responses: {
|
||
200: json(InventionDto.array(), 'The room’s inventions'),
|
||
400: json(ErrorResponse, 'Missing or non-numeric id'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const roomId = Number.parseInt(c.req.query('id') ?? '', 10)
|
||
if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400)
|
||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||
return c.json(await getInventionsByRoom(c.env.DB, roomId, skip, take))
|
||
}
|
||
)
|
||
|
||
// The signed-in player's own relationship to an invention (`/personaldetails/2`) —
|
||
// just whether they're cheering it. Signed-out callers read false: there is no player
|
||
// whose interaction could be looked up, and the client still needs a flag to render.
|
||
.get(
|
||
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'The caller’s own relation to an invention',
|
||
description:
|
||
'Whether the caller is cheering this invention. Signed-out callers receive false, ' +
|
||
'since there is no player interaction to look up.',
|
||
parameters: [idParam('inventionId', 'Invention id')],
|
||
responses: { 200: json(InventionPersonalDetails, 'The caller’s cheer state') },
|
||
}),
|
||
async (c) => {
|
||
const playerId = await authedId(c)
|
||
if (playerId === null) return c.json({ IsCheering: false })
|
||
const inventionId = Number.parseInt(c.req.param('inventionId'), 10)
|
||
return c.json({ IsCheering: await isInventionCheered(c.env.DB, playerId, inventionId) })
|
||
}
|
||
)
|
||
|
||
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
||
// RRInventionVersion, which carries the blob name the client downloads and the
|
||
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
|
||
// version history yet), so any other version number 404s rather than naming a
|
||
// blob that isn't there — except `version=0`, which means "whichever is current"
|
||
// rather than a number to match. Nothing has a version 0, so a caller sending it
|
||
// doesn't know which version it wants, and matching it literally 404s an invention
|
||
// that exists.
|
||
.get(
|
||
'/api/inventions/v1/version',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'One version of an invention',
|
||
description:
|
||
'The bare `RRInventionVersion`, which carries the blob name the client downloads ' +
|
||
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
|
||
'never uploaded). Only the current version exists — nothing writes version ' +
|
||
'history yet — so any other version number 404s rather than naming a blob that ' +
|
||
'is not there.\n\n' +
|
||
'`version=0` is the exception: it means “whichever is current” rather than a ' +
|
||
'number to match, and gets the current version. No invention has a version 0 — a ' +
|
||
'fresh save is version 1 — so a caller sending it does not know which version it ' +
|
||
'wants, and matching it literally 404s an invention that exists.',
|
||
parameters: [
|
||
intQuery('inventionId', 'Invention id; required'),
|
||
intQuery('version', 'Version number; required. `0` means the current version'),
|
||
],
|
||
responses: {
|
||
200: json(InventionVersionDto, 'The version'),
|
||
400: json(ErrorResponse, 'Missing inventionId or version'),
|
||
404: { description: 'No such invention, or a version number that is not the current one' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
|
||
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
|
||
|
||
const version = await getInventionVersion(
|
||
c.env.DB,
|
||
c.env.CDN_ASSETS,
|
||
inventionId,
|
||
versionNumber
|
||
)
|
||
return version === null ? c.notFound() : c.json(version)
|
||
}
|
||
)
|
||
|
||
// Edit an invention's metadata. The fields to change ride as QUERY PARAMS on both
|
||
// verbs (`?inventionId=1&description=my+description`) — the client sends this as a
|
||
// GET that writes in some places and as a bodyless POST in others (the permission
|
||
// picker posts `?inventionId=84&permission=Publish`), so both are registered and
|
||
// neither reads a body. Absent params keep their stored value; `permission` sets
|
||
// what other players may do with it. An empty `description` clears it, but an empty
|
||
// `name`/`imageName` is ignored rather than blanking the invention. Publishing and
|
||
// pricing are separate endpoints. Auth-gated, creator only; answers the save envelope.
|
||
.on(
|
||
['GET', 'POST'],
|
||
'/api/inventions/v1/update',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Edit an invention’s metadata',
|
||
description:
|
||
'GET or POST — the client sends both, and the fields to change ride as query ' +
|
||
'params either way; no body is read. Absent params keep their stored value. An ' +
|
||
'empty `description` clears it, but an empty `name`/`imageName` is ignored rather ' +
|
||
'than blanking the invention. A supplied name/description must satisfy the same ' +
|
||
'rules `v6/save` enforces. Publishing and pricing are separate endpoints.',
|
||
security: AUTHED,
|
||
parameters: [
|
||
intQuery('inventionId', 'Invention id; required'),
|
||
stringQuery('name', '3–24 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
|
||
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
|
||
stringQuery('imageName', 'New thumbnail; empty is ignored'),
|
||
stringQuery('allowTrial', '`true`/`1` to allow trials'),
|
||
stringQuery(
|
||
'permission',
|
||
'What other players get (`GeneralPermission`). The picker sends `UseOnly`, ' +
|
||
'`EditAndSave` or `Publish`; any ladder name (case- and underscore-insensitive) ' +
|
||
'or the raw number is accepted'
|
||
),
|
||
],
|
||
responses: {
|
||
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
|
||
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||
404: { description: 'No such invention' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
|
||
if ('response' in gate) return gate.response
|
||
|
||
// Query params arrive as strings; only the ones actually present are applied.
|
||
const nonEmpty = (name: string): string | undefined => {
|
||
const v = c.req.query(name)?.trim()
|
||
return v === undefined || v === '' ? undefined : v
|
||
}
|
||
const allowTrial = c.req.query('allowTrial')
|
||
const permission = c.req.query('permission')
|
||
|
||
// Only a name that's actually being changed is checked — an absent or empty one
|
||
// keeps the stored name, which was already validated when it was set.
|
||
const name = nonEmpty('name')
|
||
const nameRejection = name === undefined ? null : inventionNameRejection(name)
|
||
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
|
||
|
||
// The description is checked on presence, not emptiness: empty is how a creator
|
||
// clears it, and the length rule accepts that.
|
||
const description = c.req.query('description')
|
||
const descriptionRejection =
|
||
description === undefined ? null : inventionDescriptionRejection(description)
|
||
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
|
||
|
||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||
name,
|
||
// Present-but-empty clears the description, so this checks presence.
|
||
description,
|
||
imageName: nonEmpty('imageName'),
|
||
allowTrial:
|
||
allowTrial === undefined
|
||
? undefined
|
||
: allowTrial.toLowerCase() === 'true' || allowTrial === '1',
|
||
generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission),
|
||
})
|
||
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
|
||
}
|
||
)
|
||
|
||
// Publish an invention — this is what puts it into search and the feeds. Sets the
|
||
// permission other players get (`permissionLevel`, defaulting to UseOnly) and its
|
||
// `price`. Auth-gated, creator only; answers the save envelope.
|
||
.get(
|
||
'/api/inventions/v3/publish',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Publish an invention',
|
||
description:
|
||
'What puts an invention into search and the feeds. Sets the permission other ' +
|
||
'players get (defaulting to UseOnly) and its price. Another GET that writes.',
|
||
security: AUTHED,
|
||
parameters: [
|
||
intQuery('inventionId', 'Invention id; required'),
|
||
stringQuery('permissionLevel', 'A name like `useonly`, or the raw number'),
|
||
intQuery('price', 'Price in tokens; negative is ignored'),
|
||
],
|
||
responses: {
|
||
200: json(InventionSaveResult, 'The published invention, in the save envelope'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||
404: { description: 'No such invention' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
|
||
if ('response' in gate) return gate.response
|
||
|
||
const permissionLevel = c.req.query('permissionLevel')
|
||
const price = Number.parseInt(c.req.query('price') ?? '', 10)
|
||
|
||
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
|
||
permissionLevel:
|
||
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
|
||
price: Number.isNaN(price) || price < 0 ? undefined : price,
|
||
})
|
||
return published === null ? c.notFound() : c.json(toSaveResult(published))
|
||
}
|
||
)
|
||
|
||
// Set an invention's price. Unlike update/publish this one POSTs a JSON body.
|
||
// Auth-gated, creator only; answers the save envelope.
|
||
.post(
|
||
'/api/inventions/v1/updateprice',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Set an invention’s price',
|
||
description:
|
||
'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' +
|
||
'is rejected.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'),
|
||
responses: {
|
||
200: json(InventionSaveResult, 'The repriced invention, in the save envelope'),
|
||
400: json(ErrorResponse, 'Unparseable body, or a price below 0'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||
404: { description: 'No such invention' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||
|
||
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||
const gate = await creatorsInvention(c, inventionId)
|
||
if ('response' in gate) return gate.response
|
||
|
||
const price = typeof body.Price === 'number' ? body.Price : Number.NaN
|
||
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
|
||
|
||
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
|
||
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
|
||
}
|
||
)
|
||
|
||
// Replace an invention's tags. `CustomTags` are the creator's own (Type 0),
|
||
// `AutoTags` the ones the client derives from the invention (Type 2); both lists
|
||
// are replaced wholesale. Auth-gated, and only the creator may retag their own
|
||
// invention. Answers `{ Result, Tags }` — `Result` 0 is success, and `Tags` is the
|
||
// flat list of tag *names* (auto first, then custom); the typed `{ Tag, Type }`
|
||
// objects are what `v1/details` serves.
|
||
.post(
|
||
'/api/inventions/v1/settags',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Replace an invention’s tags',
|
||
description:
|
||
'`CustomTags` are the creator’s own (Type 0), `AutoTags` the ones the client ' +
|
||
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
|
||
'only.\n\n' +
|
||
'Every tag in either list must be at most 15 letters (a–z once lowercased); one ' +
|
||
'that isn’t fails the whole call, so no tag is ever silently dropped.\n\n' +
|
||
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
|
||
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
|
||
responses: {
|
||
200: json(SetTagsResponse, 'The resulting tag names'),
|
||
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||
404: { description: 'No such invention' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||
|
||
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||
const gate = await creatorsInvention(c, inventionId)
|
||
if ('response' in gate) return gate.response
|
||
|
||
const strings = (v: unknown): string[] =>
|
||
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
||
|
||
const autoTags = strings(body.AutoTags)
|
||
const customTags = strings(body.CustomTags)
|
||
|
||
// Both lists are held to the tag rule, and one bad tag fails the whole call rather
|
||
// than being dropped — a silently missing tag looks to the creator like a tag that
|
||
// saved. Checked against the normalized form `setInventionTags` will store, so the
|
||
// rejection quotes the tag as it would have been stored, not as it was typed.
|
||
// Blanks are skipped, not rejected: the store already drops them, and the client
|
||
// pads its list with empties.
|
||
for (const raw of [...autoTags, ...customTags]) {
|
||
const tag = raw.trim().toLowerCase()
|
||
if (tag === '') continue
|
||
const rejection = inventionTagRejection(tag)
|
||
if (rejection !== null) {
|
||
return c.json({ error: `${rejection} (“${tag}”)` }, 400)
|
||
}
|
||
}
|
||
|
||
const tags = await setInventionTags(
|
||
c.env.DB,
|
||
gate.invention.InventionId,
|
||
autoTags,
|
||
customTags
|
||
)
|
||
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
|
||
}
|
||
)
|
||
|
||
// An invention's detail card (`?inventionId=…`) — just its tags, as `{ Tags }`.
|
||
// Untagged inventions report an empty list. 404s on unknown ids.
|
||
.get(
|
||
'/api/inventions/v1/details',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'An invention’s detail card',
|
||
description:
|
||
'Which in practice is just its tags, as typed `{ Tag, Type }` objects. An untagged ' +
|
||
'invention reports an empty list.',
|
||
parameters: [intQuery('inventionId', 'Invention id; required')],
|
||
responses: {
|
||
200: json(InventionDetails, 'The invention’s tags'),
|
||
400: json(ErrorResponse, 'Missing or non-numeric inventionId'),
|
||
404: { description: 'No such invention' },
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||
const tags = await getInventionTags(c.env.DB, inventionId)
|
||
return tags === null ? c.notFound() : c.json({ Tags: tags })
|
||
}
|
||
)
|
||
|
||
// The "top today" invention feed — the inventions most acquired in the last 24 hours,
|
||
// counted from the purchase rows the `econ` worker writes. A real day window, so an
|
||
// empty list is a quiet day rather than a bug. Paginated via skip/take (take defaults
|
||
// to 50, as the client asks for). Bare array.
|
||
.get(
|
||
'/api/inventions/v1/toptoday',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'The “top today” feed',
|
||
description:
|
||
'Published inventions ranked by how many players acquired them in the last 24 ' +
|
||
'hours, counted from the purchase records — free grants included, one per ' +
|
||
'player per invention. Genuinely a window: an invention nobody has picked up ' +
|
||
'since yesterday falls off, and a day with no acquisitions at all serves an ' +
|
||
'empty list. It trails the clock rather than resetting at midnight.',
|
||
parameters: pageParams(50),
|
||
responses: { 200: json(InventionDto.array(), 'The top inventions') },
|
||
}),
|
||
async (c) => {
|
||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||
return c.json(await getTopInventions(c.env.DB, skip, take))
|
||
}
|
||
)
|
||
|
||
// The featured invention feed — the curated (`IsFeatured`) inventions and nothing
|
||
// else, newest first. Empty until someone flags one. Bare array, like toptoday.
|
||
.get(
|
||
'/api/inventions/v1/featured',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'The featured feed',
|
||
description:
|
||
'Curated (`IsFeatured`) inventions, newest first — published and non-hidden only. ' +
|
||
'Serves an empty list while nothing is flagged rather than standing in the top ' +
|
||
'feed: the client presents these as hand-picked, so a fallback would be a lie.',
|
||
parameters: pageParams(50),
|
||
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
|
||
}),
|
||
async (c) => {
|
||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||
return c.json(await getFeaturedInventions(c.env.DB, skip, take))
|
||
}
|
||
)
|
||
|
||
// 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.
|
||
//
|
||
// STUB: an empty list for now. It is the honest answer rather than a placeholder, since
|
||
// the client reads it as "this creator has published nothing" and renders an empty
|
||
// shelf, where a 404 would read as a row that failed to load. When it becomes real it is
|
||
// a filter on the invention table's creator column, the same feed shape as `toptoday`
|
||
// and `featured` above — `id` is repeatable, and `skip`/`take` page it.
|
||
.get(
|
||
'/api/inventions/v1/fromcreators',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Inventions by particular creators (stub)',
|
||
description:
|
||
'The published inventions of the accounts named by `id` (repeatable), newest first — ' +
|
||
'a creator’s shelf, and the "from creators you follow" row. STUB: always an empty ' +
|
||
'array for now, which the client renders as "nothing published" rather than as a ' +
|
||
'failed load. `id`, `skip` and `take` are accepted and, for the moment, ignored.',
|
||
parameters: [
|
||
intQuery('id', 'Creator account id; repeatable. Accepted and ignored by the stub'),
|
||
...pageParams(100),
|
||
],
|
||
responses: { 200: json(InventionDto.array(), 'Empty — nothing is served here yet') },
|
||
}),
|
||
(c) => c.json([])
|
||
)
|
||
|
||
// Invention search/browse: published inventions matching `value` (matched against
|
||
// name + description; absent → browse everything published), newest first.
|
||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||
//
|
||
// Filtered, ordered and paged in SQL — it must not read the catalogue into memory to
|
||
// answer one page.
|
||
.get(
|
||
'/api/inventions/v2/search',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Search / browse inventions',
|
||
description:
|
||
'Published inventions matching `value`, newest first. `value` is split into terms ' +
|
||
'and every term must match, each against the name and the description. An absent ' +
|
||
'`value` browses everything published — that is the browse screen’s initial ' +
|
||
'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' +
|
||
'chips is treated as text and matches nothing.',
|
||
parameters: [
|
||
stringQuery('value', 'Search text; absent browses everything'),
|
||
...pageParams(100),
|
||
],
|
||
responses: { 200: json(InventionDto.array(), 'The matching inventions') },
|
||
}),
|
||
async (c) => {
|
||
const value = c.req.query('value') ?? ''
|
||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||
return c.json(await searchInventions(c.env.DB, value, skip, take))
|
||
}
|
||
)
|
||
|
||
// The signed-in player's invention shelf ("my inventions"), newest first — the ones
|
||
// they created AND the ones they bought (`inventory_invention`, written by the `econ`
|
||
// worker's buyInvention). A bought invention stays on the shelf whatever happens to it
|
||
// afterwards: unpublished or hidden since, the buyer paid for it.
|
||
// Auth-gated; returns a bare array (empty when the player has neither).
|
||
.get(
|
||
'/api/inventions/v2/mine',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'The caller’s own inventions',
|
||
description:
|
||
'“My inventions”, newest first — the ones the caller created plus the ones they ' +
|
||
'bought. Includes unpublished ones, which nobody else can see, and keeps a bought ' +
|
||
'invention listed even if it has since been unpublished or hidden. Not paginated.',
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(InventionDto.array(), 'The caller’s inventions'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
return c.json(await getMyInventions(c.env.DB, id))
|
||
}
|
||
)
|
||
|
||
// Report a custom avatar item. Stored in the `report` table the player, event and invention
|
||
// reports use — same fields, same moderation life — with `custom_avatar_item_id` set. See
|
||
// migrations/0017_report_custom_avatar_item.sql.
|
||
//
|
||
// The item is named by the PATH, not the body, which is what distinguishes this from its
|
||
// siblings; the body's `ReportedPlayerId` arrives NULL and is ignored, since the client does
|
||
// not know who made the item.
|
||
.post(
|
||
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}/report',
|
||
describeRoute({
|
||
tags: ['Avatar', 'Moderation'],
|
||
summary: 'Report a custom avatar item',
|
||
description:
|
||
'Files a report against a custom avatar item, named by the PATH. Stored as a row in ' +
|
||
'the same `report` table a player report goes to (`POST /api/PlayerReporting/v3/create`), ' +
|
||
'an event report and an invention report — the same submission with the same ' +
|
||
'moderation life, which a moderator converts into a ban the same way. What marks it ' +
|
||
'as an item report is `custom_avatar_item_id`; the row’s `reported_player_id` is the ' +
|
||
'item’s CREATOR, read from the item. The body’s `ReportedPlayerId` is sent as null ' +
|
||
'and IGNORED even when set — the client does not know who made the item, and letting ' +
|
||
'a client name who a report is against would let it point one at anybody. Nothing ' +
|
||
'fills `room_id`: an item isn’t tied to one room the way an event is.\n\n' +
|
||
'The reporter is the caller (from the bearer token), never a body field. ' +
|
||
'`ReportCategory` is stored verbatim — the enum is not mapped here. Nothing dedupes ' +
|
||
'the rows: reporting the same item twice files two reports, and reporting your own ' +
|
||
'is allowed rather than being a special case.\n\n' +
|
||
'Answers the `{ success, error }` envelope the event and invention reports use, ' +
|
||
'`error` being an empty string rather than null, on the rejected branches too so ' +
|
||
'there is only one shape to parse.',
|
||
security: AUTHED,
|
||
parameters: [idParam('id', 'The custom avatar item’s guid')],
|
||
requestBody: jsonBody(CustomAvatarItemReportRequest, 'The report'),
|
||
responses: {
|
||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
404: json(SuccessErrorEnvelope, 'No such custom avatar item'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const reporterId = await authedId(c)
|
||
if (reporterId === null) return unauthorized(c)
|
||
|
||
const customAvatarItemId = c.req.param('id')
|
||
|
||
// The item supplies the reported player. An unknown item is refused rather than filed
|
||
// against nobody: the row's reported player has to be someone, and a report naming an
|
||
// item that never existed isn't actionable.
|
||
const item = await getCustomAvatarItem(c.env.DB, customAvatarItemId)
|
||
if (item === null) return c.json({ success: false, error: 'No such item' }, 404)
|
||
|
||
// A body that won't parse is not a reason to lose the report: the path already names
|
||
// what is being reported and the token names who reported it, so an unreadable body
|
||
// costs the category and the description, not the row.
|
||
const body = await c.req
|
||
.json<{ ReportCategory?: unknown; Details?: unknown }>()
|
||
.catch(() => ({}) as Record<string, unknown>)
|
||
const category = Number(body.ReportCategory)
|
||
await createReport(c.env.DB, {
|
||
reporterPlayerId: reporterId,
|
||
reportedPlayerId: item.CreatorAccountId,
|
||
reportCategory: Number.isInteger(category) ? category : 0,
|
||
details: typeof body.Details === 'string' ? body.Details : null,
|
||
customAvatarItemId,
|
||
})
|
||
|
||
return c.json({ success: true, error: '' })
|
||
}
|
||
)
|
||
|
||
// Cheer or un-cheer an invention. The interaction row is per player and the stored
|
||
// invention's public CheerCount is derived from all active cheers.
|
||
.post(
|
||
'/api/inventions/v1/cheer',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Cheer or un-cheer an invention',
|
||
description:
|
||
'Persists the caller’s cheer state and resyncs the invention’s `CheerCount`. ' +
|
||
'Repeating the same state is idempotent.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(InventionCheerRequest, 'The invention and new cheer state'),
|
||
responses: {
|
||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||
400: json(SuccessErrorEnvelope, 'Invalid body'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
404: json(SuccessErrorEnvelope, 'No such invention'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const playerId = await authedId(c)
|
||
if (playerId === null) return unauthorized(c)
|
||
const body = await c.req
|
||
.json<{ InventionId?: unknown; Cheer?: unknown }>()
|
||
.catch(() => ({}) as Record<string, unknown>)
|
||
const inventionId = Number(body.InventionId)
|
||
if (!Number.isInteger(inventionId) || typeof body.Cheer !== 'boolean') {
|
||
return c.json({ success: false, error: 'InventionId and Cheer are required' }, 400)
|
||
}
|
||
if ((await getInventionById(c.env.DB, inventionId)) === null) {
|
||
return c.json({ success: false, error: 'No such invention' }, 404)
|
||
}
|
||
await setInventionCheer(c.env.DB, playerId, inventionId, body.Cheer)
|
||
return c.json({ success: true, error: '' })
|
||
}
|
||
)
|
||
// Report an invention. Stored in the `report` table the player and event reports use —
|
||
// same fields, same moderation life — with `invention_id` set. See
|
||
// migrations/0016_report_invention.sql.
|
||
.post(
|
||
'/api/inventions/v1/report',
|
||
describeRoute({
|
||
tags: ['Inventions', 'Moderation'],
|
||
summary: 'Report an invention',
|
||
description:
|
||
'Files a report against an invention. Stored as a row in the same `report` table a ' +
|
||
'player report goes to (`POST /api/PlayerReporting/v3/create`) and an event report ' +
|
||
'(`POST /api/playerevents/v1/report`) — it is the same submission with the same ' +
|
||
'moderation life, and a moderator converts any of them into a ban the same way. ' +
|
||
'What marks it as an invention report is `invention_id`; the row’s ' +
|
||
'`reported_player_id` is the invention’s CREATOR — who a moderator would act ' +
|
||
'against — read from the invention rather than sent by the client. Nothing fills ' +
|
||
'`room_id`: an invention isn’t tied to one room the way an event is.\n\n' +
|
||
'The reporter is the caller (from the bearer token), never a body field. ' +
|
||
'`ReportCategory` is stored verbatim — the enum is not mapped here. Nothing ' +
|
||
'dedupes the rows: reporting the same invention twice files two reports, and ' +
|
||
'reporting your own is allowed rather than being a special case.\n\n' +
|
||
'Answers the same `{ success, error }` envelope as the event report, `error` being ' +
|
||
'an empty string rather than null, on the rejected branches too so there is only ' +
|
||
'one shape to parse.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(InventionReportRequest, 'The report'),
|
||
responses: {
|
||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||
400: json(SuccessErrorEnvelope, 'No usable `InventionId` in the body'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
404: json(SuccessErrorEnvelope, 'No such invention'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const reporterId = await authedId(c)
|
||
if (reporterId === null) return unauthorized(c)
|
||
|
||
const body = await c.req
|
||
.json<{ InventionId?: unknown; ReportCategory?: unknown; Details?: unknown }>()
|
||
.catch(() => ({}) as Record<string, unknown>)
|
||
const inventionId = Number(body.InventionId)
|
||
if (!Number.isInteger(inventionId)) {
|
||
return c.json({ success: false, error: 'InventionId is required' }, 400)
|
||
}
|
||
|
||
// The invention supplies the reported player. An unknown invention is refused rather
|
||
// than filed against nobody: the row's reported player has to be someone, and a
|
||
// report naming an invention that never existed isn't actionable.
|
||
const invention = await getInventionById(c.env.DB, inventionId)
|
||
if (invention === null) return c.json({ success: false, error: 'No such invention' }, 404)
|
||
|
||
const category = Number(body.ReportCategory)
|
||
await createReport(c.env.DB, {
|
||
reporterPlayerId: reporterId,
|
||
reportedPlayerId: invention.CreatorPlayerId,
|
||
reportCategory: Number.isInteger(category) ? category : 0,
|
||
details: typeof body.Details === 'string' ? body.Details : null,
|
||
inventionId,
|
||
})
|
||
|
||
return c.json({ success: true, error: '' })
|
||
}
|
||
)
|
||
|
||
// Save an invention's metadata. The data file itself is uploaded separately
|
||
// through the `storage` worker and referenced here by `inventionDataFilename` —
|
||
// the one required field, since an invention with no data blob is unusable. An
|
||
// omitted name/description is defaulted rather than rejected. Auth-gated; returns
|
||
// the `{ Status, Invention, InventionVersion }` envelope the client expects (the
|
||
// invention carries its assigned inventionId).
|
||
.post(
|
||
'/api/inventions/v6/save',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Save a new invention',
|
||
description:
|
||
'Records an invention’s metadata. The data file itself is uploaded separately ' +
|
||
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
|
||
'one required field, since an invention with no data blob is unusable. An omitted ' +
|
||
'name/description is defaulted rather than rejected; a supplied one must be 3–24 ' +
|
||
'characters of letters, digits, spaces, dashes and colons (name) or at most 512 ' +
|
||
'characters (description).\n\n' +
|
||
'A freshly saved invention is private: it shows up only in the creator’s own list ' +
|
||
'until they call `v3/publish`.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
|
||
responses: {
|
||
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
|
||
400: json(
|
||
ErrorResponse,
|
||
'Unparseable body, no inventionDataFilename, or an invalid name/description'
|
||
),
|
||
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<string, unknown> | null
|
||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||
|
||
const outcome = await createInventionFromBody(c, id, body)
|
||
if ('rejection' in outcome) return c.json({ error: outcome.rejection }, 400)
|
||
return c.json(toSaveResult(outcome.invention))
|
||
}
|
||
)
|
||
|
||
// The same save as the newer client sends it: v6's body plus the invention's
|
||
// references, its long description and display metadata, what the saved blob is, and
|
||
// the tags — which v6 clients set afterwards through `v1/settags`. It stores the same
|
||
// record; what differs is the REPLY, which is enveloped. See `InventionSaveV9Result`:
|
||
// the client reads `Success` and then `Value.Invention.InventionId`, and a body that
|
||
// isn't this envelope — a bare `{ error }`, or the empty 401 the other routes answer —
|
||
// takes it down rather than failing it, which is why every branch below answers one.
|
||
.post(
|
||
'/api/inventions/v9/save',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Save a new invention (v9)',
|
||
description:
|
||
'`v6/save` plus the fields the newer client sends: `referencedUnityAssetIds`, ' +
|
||
'`longDescription`, `displayMetadataJson`, `convertedFromInventionId`, ' +
|
||
'`ugcVersion`, `hasBetaContent`, and a `tagsRequest` carrying the same ' +
|
||
'`AutoTags`/`CustomTags` lists `v1/settags` takes. Every one is optional and is ' +
|
||
'stored only when sent, so a body v6 would accept produces the same record here.' +
|
||
'\n\n' +
|
||
'The reply is where the two versions part: v9 is ENVELOPED as ' +
|
||
'`{ Value, Success, Error, error_id }`, with v6’s ' +
|
||
'`{ Status, Invention, InventionVersion }` inside `Value` alongside a ' +
|
||
'`TagsResponse`. The client reads `Success` and then ' +
|
||
'`Value.Invention.InventionId`; `Error` is the only text it ever shows a human.' +
|
||
'\n\n' +
|
||
'So a refusal is *also* a 200 carrying `{ Success: false, Error, Value: null }` — ' +
|
||
'the client dereferences `Value` unguarded when `Success` is true, and treats ' +
|
||
'anything that isn’t this envelope as a null one. Tags are held to the ' +
|
||
'`v1/settags` rule (at most 15 letters each), but one that breaks it costs the ' +
|
||
'tags and not the save: `TagsResponse.Result` comes back non-zero and the creator ' +
|
||
're-submits them through `v1/settags`.\n\n' +
|
||
'A freshly saved invention is private: it shows up only in the creator’s own list ' +
|
||
'until they call `v3/publish`.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(SaveInventionV9Request, 'The invention metadata (camelCase)'),
|
||
responses: {
|
||
200: json(
|
||
InventionSaveV9Result,
|
||
'The envelope — the stored invention under `Value`, or `Success: false` with ' +
|
||
'`Error` when the save was refused'
|
||
),
|
||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return c.json(inventionSaveV9Failure('Unauthorized'), 401)
|
||
|
||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||
|
||
const outcome = await createInventionFromBody(c, id, body)
|
||
return c.json(
|
||
'rejection' in outcome
|
||
? inventionSaveV9Failure(outcome.rejection)
|
||
: toSaveResultV9(outcome.invention, outcome.tags, outcome.tagResult)
|
||
)
|
||
}
|
||
)
|
||
|
||
// Edit an invention's metadata, as the newer client sends it: one PUT with a PascalCase
|
||
// body where every field but the id is nullable, and NULL means "leave this alone" —
|
||
// the client sends the whole shape every time and marks the fields it isn't touching.
|
||
// The tags ride along the way they do on `v9/save`, and the reply is that same
|
||
// envelope: `v1/update` is the older client's version of this endpoint, query params
|
||
// and a bare body and all.
|
||
.put(
|
||
'/api/inventions/v2/metadata',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Edit an invention’s metadata (v2)',
|
||
description:
|
||
'Creator only. Every field but `InventionId` is nullable and a null one is left ' +
|
||
'as it is — the client sends the whole shape on every edit — so this is a patch, ' +
|
||
'not a replace. An empty string is not a null: it is how a creator CLEARS a ' +
|
||
'description, long description or image. `Name` is the exception, since a nameless ' +
|
||
'invention isn’t a thing the client can draw: it is held to the same 3–24 ' +
|
||
'character rule `v6/save` enforces, which an empty name fails.\n\n' +
|
||
'`TagsRequest` replaces both tag lists wholesale, exactly as `v1/settags` does; a ' +
|
||
'null one leaves the stored tags alone. A tag that breaks the tag rule costs the ' +
|
||
'tags and not the edit — `TagsResponse.Result` comes back non-zero.\n\n' +
|
||
'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' +
|
||
'the client re-renders the detail page from `Value.Invention`. Refusals — an ' +
|
||
'unknown invention and someone else’s alike — are `Success: false` with a null ' +
|
||
'`Value` rather than a bare error body, which that client cannot parse.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(UpdateInventionMetadataRequest, 'The fields to change'),
|
||
responses: {
|
||
200: json(
|
||
InventionSaveV9Result,
|
||
'The envelope — the updated invention under `Value`, or `Success: false` with ' +
|
||
'`Error` when the edit was refused'
|
||
),
|
||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||
|
||
// The id rides in the body here, not the query string.
|
||
const gate = await creatorsInventionResult(
|
||
c,
|
||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||
)
|
||
// Only a missing token is answered as a transport failure. An unknown invention
|
||
// or someone else's is a domain answer the client is meant to read — its own
|
||
// status enum has DoesNotExist and NotCreator members — so it goes in the
|
||
// envelope, where the message reaches a human.
|
||
if ('rejection' in gate) {
|
||
return gate.status === 401
|
||
? c.json(inventionSaveV9Failure(gate.rejection), 401)
|
||
: c.json(inventionSaveV9Failure(gate.rejection))
|
||
}
|
||
|
||
// Null is "leave it"; a string, empty or not, is an edit.
|
||
const edited = (key: string): string | undefined =>
|
||
typeof body[key] === 'string' ? body[key] : undefined
|
||
const name = edited('Name')?.trim()
|
||
const description = edited('Description')
|
||
const longDescription = edited('LongDescription')
|
||
|
||
for (const rejection of [
|
||
name === undefined ? null : inventionNameRejection(name),
|
||
description === undefined ? null : inventionDescriptionRejection(description),
|
||
longDescription === undefined
|
||
? null
|
||
: inventionLongDescriptionRejection(longDescription),
|
||
]) {
|
||
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
|
||
}
|
||
|
||
// A null TagsRequest leaves the stored tags alone, and the reply still reports
|
||
// them: the client reads the list back as the tags the invention now has, not as
|
||
// the ones this call changed.
|
||
const requested = requestedTags(body.TagsRequest)
|
||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||
name,
|
||
description,
|
||
longDescription,
|
||
imageName: edited('ImageName'),
|
||
tags: requested?.tags,
|
||
})
|
||
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
|
||
return c.json(
|
||
toSaveResultV9(
|
||
updated,
|
||
updated.Tags ?? [],
|
||
requested?.tagResult ?? INVENTION_TAG_RESULT.success
|
||
)
|
||
)
|
||
}
|
||
)
|
||
|
||
// Publish an invention, as the newer client sends it: a PascalCase body instead of a
|
||
// query string, and an Accessibility of its own — where `v3/publish` only ever flipped
|
||
// the published flag, this decides whether the result can be FOUND. Same enveloped
|
||
// reply as `v9/save`, carrying the published invention.
|
||
.post(
|
||
'/api/inventions/v4/publish',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Publish an invention (v4)',
|
||
description:
|
||
'What puts an invention into search and the feeds. Creator only.\n\n' +
|
||
'`Permission` is the `GeneralPermission` other players get, as a raw ladder ' +
|
||
'number (the publish sheet sends 20, UseOnly). `Accessibility` says where it can ' +
|
||
'be found — 1 (Public) lists it, 2 (Unlisted) publishes it reachable by id but ' +
|
||
'keeps it out of browse and search. A null `Price` leaves the price alone rather ' +
|
||
'than zeroing it, so re-publishing something that was for sale doesn’t give it ' +
|
||
'away; every field but `InventionId` is nullable and an omitted one keeps what ' +
|
||
'the invention has.\n\n' +
|
||
'Publishing is not undone here, and re-publishing doesn’t re-date the first ' +
|
||
'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
|
||
'`v9/save` does.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(PublishInventionRequest, 'What the publish decides'),
|
||
responses: {
|
||
200: json(
|
||
InventionSaveV9Result,
|
||
'The envelope — the published invention under `Value`, or `Success: false` ' +
|
||
'with `Error` when the publish was refused'
|
||
),
|
||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||
|
||
const gate = await creatorsInventionResult(
|
||
c,
|
||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||
)
|
||
// As on `v2/metadata`: only a missing token is a transport failure. The rest are
|
||
// answers the client is meant to read out of the envelope.
|
||
if ('rejection' in gate) {
|
||
return gate.status === 401
|
||
? c.json(inventionSaveV9Failure(gate.rejection), 401)
|
||
: c.json(inventionSaveV9Failure(gate.rejection))
|
||
}
|
||
|
||
// Null is "leave it". The permission and accessibility are taken as sent rather
|
||
// than checked against the ladder, the way `parsePermissionLevel` already accepts
|
||
// a raw number: the ladders are the client's, and a level this server hasn't heard
|
||
// of is better stored than swapped for one the creator didn't pick.
|
||
const int = (key: string): number | undefined =>
|
||
typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined
|
||
const price = int('Price')
|
||
|
||
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
|
||
permissionLevel: int('Permission'),
|
||
accessibility: int('Accessibility'),
|
||
// A negative price is dropped rather than stored, as it is on `v3/publish`.
|
||
price: price !== undefined && price < 0 ? undefined : price,
|
||
})
|
||
if (published === null) return c.json(inventionSaveV9Failure('No such invention'))
|
||
return c.json(toSaveResultV9(published, published.Tags ?? []))
|
||
}
|
||
)
|
||
|
||
// Delete an invention. The newer client's shape: a POST with a PascalCase body
|
||
// carrying nothing but the id. Auth-gated, creator only — the only thing that may
|
||
// remove an invention is the account that made it, not a co-owner and not a buyer.
|
||
//
|
||
// The record and everything inside it (versions, tags, referenced-invention lists)
|
||
// go in one DELETE; the data blob in R2 and the `inventory_invention` rows of
|
||
// players who bought it are left alone. See `deleteInvention` for why.
|
||
.post(
|
||
'/api/inventions/v2/delete',
|
||
describeRoute({
|
||
tags: ['Inventions'],
|
||
summary: 'Delete an invention',
|
||
description:
|
||
'Creator only — a buyer or a co-owner cannot delete someone else’s invention. ' +
|
||
'The record goes entirely: its versions, tags and referenced-invention lists live ' +
|
||
'in the same row.\n\n' +
|
||
'What survives is deliberate. The data blob stays in storage, because nothing ' +
|
||
'here knows whether another record still points at that filename. The ownership ' +
|
||
'rows of players who bought it stay too — a delete must not rewrite what someone ' +
|
||
'else paid for — and they fall out of every list on their own, since an owned id ' +
|
||
'with no invention row behind it is skipped.\n\n' +
|
||
'Answers the `{ Value, Success, Error, error_id }` envelope the other v2+ ' +
|
||
'invention routes use, with `Value` NULL: the invention is gone, so there is ' +
|
||
'nothing to redraw from and the client reads only `Success`. Refusals — an ' +
|
||
'unknown invention and someone else’s alike — are `Success: false` with a ' +
|
||
'message, not a bare error body that client cannot parse.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(DeleteInventionRequest, 'The invention to delete'),
|
||
responses: {
|
||
200: json(InventionDeleteResult, 'The delete envelope, `Value` null either way'),
|
||
401: json(InventionDeleteResult, 'The same envelope, refused — not an empty body'),
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||
if (body === null) return c.json(inventionDeleteResult('Invalid request body'))
|
||
|
||
// The id rides in the body, as it does on `v2/metadata` and `v4/publish`.
|
||
const gate = await creatorsInventionResult(
|
||
c,
|
||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||
)
|
||
// As on those two: only a missing token is a transport failure. An unknown
|
||
// invention or someone else's is a domain answer the client reads out of the
|
||
// envelope, where the message reaches a human.
|
||
if ('rejection' in gate) {
|
||
return gate.status === 401
|
||
? c.json(inventionDeleteResult(gate.rejection), 401)
|
||
: c.json(inventionDeleteResult(gate.rejection))
|
||
}
|
||
|
||
// The gate already loaded the row, so a null here is a race — someone deleted it
|
||
// between the two reads — and lands where the client would put it anyway: gone.
|
||
const deleted = await deleteInvention(c.env.DB, gate.invention.InventionId)
|
||
return c.json(
|
||
deleted === null ? inventionDeleteResult('No such invention') : inventionDeleteResult()
|
||
)
|
||
}
|
||
)
|