diff --git a/apps/econ/README.md b/apps/econ/README.md index a72f32c..defd797 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -1,77 +1,116 @@ # econ -Economy Worker served on the `econ` subdomain. Hosts the avatar/economy -endpoints the game client calls on the `econ` service (distinct from the main -`api` worker). DB-backed data is stubbed for now — no bindings yet. +Economy Worker served on the `econ` subdomain (`econ.recflare.net`). Hosts the +avatar/economy endpoints the game client calls on the `econ` service (distinct from the +main `api` worker, which also serves many of them — the client may call either host). -## Endpoints +Balances, inventory, consumables, saved outfits, avatars and gift boxes are D1-backed; +storefront catalogs are static assets (`static/storefronts/sf{N}.json`) served via the +ASSETS binding. Several routes are still empty-list stubs. -- `GET /api/avatar/v1/defaultunlocked` — default-unlocked avatar items, served - from the bundled `static/default-avatar-items.json` catalog. -- `GET /api/avatar/v1/defaultbaseavataritems` — default base avatar items. Reads - the same source file as `defaultunlocked`, so it returns the identical - catalog. -- `GET /api/avatar/v4/items` — `[Authorize]`. The player's avatar items: the - items they've bought (from `buyItem`, in the `inventory` table) prepended to - the default catalog. A player who has bought nothing gets just the catalog. -- `GET /api/avatar/v2` — `[Authorize]`. The player's avatar. No DB binding yet, - so it returns the default `{ OutfitSelections, FaceFeatures, SkinColor, -HairColor }` seeded for a new player. -- `GET /econ/customAvatarItems/v1/owned` — the player's owned custom avatar - items. No auth; returns `{ items: [] }` with no DB binding. - The client requests this when custom-item creation is allowed, so a missing - route here shows up as "Failed to download unlocked avatar items". -- `GET /api/objectives/v1/myprogress` — objectives progress. No auth (serves a - static JSON file verbatim); returns the bundled - `static/my-progress.json` default for all players until a DB binding exists. -- `GET /api/avatar/v3/saved` — `[Authorize]`. Saved outfits; `[]` without a DB. -- `GET /api/avatar/v2/gifts` — `[Authorize]`. The player's unopened gift boxes - (from their purchases), out of the shared `received_gift` table; `[]` when - they have none. -- `POST /api/avatar/v2/gifts/consume` — open a box (form body `Id=&UnlockedLevel=`, - posted with a trailing slash). Deletes the box scoped to the caller; the item was - already granted at purchase, so this is cosmetic. Always answers the success envelope - `{ error: "", success: true, value: null }` (a captured real consume returns this, not - an empty body — the client parses it to finish opening the box), even for a - missing/already-opened box, so a fire-and-forget re-open never errors. Also served by - the `api` worker (the client may call either host). -- `POST /api/storefronts/v2/buyItem` — `[Authorize]`. Buy a storefront item. - Looks the item up in `static/storefronts/sf{StorefrontType}.json`, confirms the - client's `RequestedPrice` still matches, debits the buyer atomically, grants the - item, and returns a gift box. An avatar-item drop goes into the `inventory` table - (own-once); a consumable drop goes into the `consumable` table (each buy stacks a - new instance). The response's `Balance` is the change applied (the negated price), - not the resulting total — the client reads its new total from `GET /balance/:type`. - `409` on a stale price, `404` on an unknown item, `400` on insufficient balance. -- `GET /api/equipment/v2/getUnlocked` — unlocked equipment; `[]` (no auth). -- `POST /api/settings/v2/set` — `[Authorize]`. Persist settings; 200 ack only. -- `GET /api/consumables/v2/getUnlocked` — `[Authorize]`. The consumables the - player has bought (from `buyItem`, in the `consumable` table), grouped by item - into the unlocked-consumable DTO (`Ids`/`CreatedAts` per instance, `Count` their - sum); `[]` when they've bought none. -- `GET /api/storefronts/v4/balance/2` — `[Authorize]`. Token balance; `[]`. -- `GET /api/storefronts/v3/giftdropstore/3` — gift-drop storefront, served from - the bundled `static/storefronts-v3-giftdropstore-3.json`. -- `GET /api/storefronts/v1/adcarouselitems` — storefront ad-carousel items, - served from the bundled `static/ad-carousel-items.json` (one placeholder - banner until real promo data exists). -- `GET /api/challenge/v2/getCurrent` — current weekly challenge, served from the - bundled `static/weekly-challenge.json`. -- `GET /api/gamerewards/v1/pending` — pending rewards; `[]`. -- `GET /api/roomkeys/v1/mine` — the player's room keys; `[]`. -- `POST /api/CampusCard/v1/UpdateAndGetSubscription` — subscription lookup; - `{ subscription: null, platformAccountSubscribedPlayerId: null }`. -- Stubbed: `GET /api/roomconsumables/v1/roomConsumable/room/:id` - and `GET /api/roomcurrencies/v1/currencies` both return `[]`. +## Routes -These economy routes are also served by the `api` worker; they're -duplicated here because the client calls them on the `econ` host. +`✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when +missing/invalid). -## TODO before production +| Method | Path | Auth | Description | +| -------- | ---------------------------------------------------- | ---- | --------------------------------------- | +| GET | `/api/avatar/v1/defaultunlocked` | | Default-unlocked avatar items (static) | +| GET | `/api/avatar/v1/defaultbaseavataritems` | | Default base avatar items (stub `[]`) | +| GET | `/api/avatar/v4/items` | ✓ | Owned items + the default catalog | +| GET | `/econ/customAvatarItems/v1/owned` | ✓ | Owned custom avatar items (stub) | +| GET | `/api/objectives/v1/myprogress` | | Objectives progress (static) | +| GET/POST | `/api/objectives/v1/cleargroup` | | Clear an objectives group (no-op `[]`) | +| GET | `/api/avatar/v2` | ✓ | The player's own avatar | +| POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar | +| GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) | +| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | Item wishlist (stub `[]`) | +| GET | `/api/avatar/v3/saved` | ✓ | Saved outfits | +| POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot | +| GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes | +| POST | `/api/avatar/v2/gifts/consume` | | Open a gift box → success envelope | +| GET | `/api/avatar/v2/:id` | | Another player's avatar (render subset) | +| GET | `/api/equipment/v2/getUnlocked` | | Unlocked equipment (stub `[]`) | +| GET | `/api/roomconsumables/v1/roomConsumable/room/:id` | | Room consumables (stub `[]`) | +| GET | `/api/roomconsumables/v1/roomConsumable/room/:id/me` | | Caller's room consumables (stub `[]`) | +| GET | `/api/roomcurrencies/v1/currencies` | | Room currencies (stub `[]`) | +| GET | `/api/roomcurrencies/v1/getAllBalances` | | Room balances (stub `[]`) | +| POST | `/api/settings/v2/set` | ✓ | Persist settings (accept-and-ack) | +| GET | `/api/consumables/v2/getUnlocked` | ✓ | Unlocked consumables | +| POST | `/api/consumables/v1/consume` | ✓ | Consume an owned consumable | +| GET | `/api/storefronts/v4/balance/:currencyType` | ✓ | Currency balance | +| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog | +| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item | +| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) | +| GET | `/api/challenge/v2/getCurrent` | | Current weekly challenge (static) | +| POST | `/api/challenge/v2/updateProgress` | | Report challenge progress (stub) | +| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) | +| POST | `/api/gamerewards/v1/request` | | Request a game reward (stub `[]`) | +| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) | +| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) | +| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) | +| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) | -- Gifting to another player (`buyItem` with a `Gift` block) grants the item and - box to the recipient, but there's no notification. `buyItem` grants avatar-item - and consumable drops; currency/xp drops aren't granted yet. -- Consumables are granted and listed but never spent — nothing consumes them, so - `Count` only ever grows (each purchase grants `1`; catalogs don't specify a - per-item quantity). +The app runs with `strict: false`, so trailing-slash variants match (the client posts +`/gifts/consume/` with a trailing slash). + +## API documentation + +`GET /openapi.json` serves a spec generated from `describeRoute` blocks alongside each +handler, with the schemas in `src/openapi.ts`. **Descriptive, not enforced** — same +rationale as the `auth`/`accounts`/`match` workers. A test asserts every route appears +in the spec, so adding one without documenting it fails. + +## Purchases (`buyItem`) + +The core flow. The client posts the storefront/item ids, the currency, and the +`RequestedPrice` it rendered; the handler: + +1. looks the item up in `static/storefronts/sf{StorefrontType}.json`; +2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a + price the catalog no longer offers; +3. debits the buyer **atomically** (`400` on insufficient balance); +4. grants the drop — an avatar item into the `inventory` table (own-once), a consumable + into the `consumable` table (each buy stacks a new instance); currency/xp drops + aren't granted yet; +5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket. + +Two things are easy to get wrong: + +- **`Balance` in the response is the _change_ applied** (the negated price), not the + resulting total. The client reads its new total from `GET /balance/:type`. +- **Ownership is persisted at purchase**, not when the box is opened. Opening a box + (`/gifts/consume`) just deletes it — the item was already granted. So the grant never + waits on the cosmetic "open it" moment. + +A `Gift` block routes the item (and box) to another player, but the caller always pays. +A self-buy or anonymous gift is attributed to the "Coach" system account (id 1). + +## Consume envelopes + +Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200 +with `{ error: "", success: true, value: null }` — even for a missing or already-gone +target. A captured real consume returns this envelope, not an empty body: the client +parses it to finish the action, so a bare 200 reads as a failure and the item never +finishes unlocking. Deletes are scoped to the caller, so an unauthenticated or +mismatched call is a harmless no-op (opening _another_ player's box is a 403). + +## Bindings + +| Binding | Type | Notes | +| ---------------------------- | -------------- | -------------------------------------------------------- | +| `DB` | D1 | Shared `recflare` database — balances, inventory, etc. | +| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) | +| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs | +| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub | +| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) | + +Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change. + +## Known gaps + +- Gifting to another player grants the item and box but does not notify the recipient. +- `buyItem` grants avatar-item and consumable drops; currency/xp drops aren't granted. +- Consumables are granted and listed but never spent by gameplay, so `Count` only grows. +- Several routes (room keys, wishlist, equipment, room consumables/currencies, game + rewards) are empty-list stubs pending their own stores. diff --git a/apps/econ/package.json b/apps/econ/package.json index f90ed35..387d9e3 100644 --- a/apps/econ/package.json +++ b/apps/econ/package.json @@ -19,8 +19,13 @@ "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", + "@standard-community/standard-json": "0.3.5", + "@standard-community/standard-openapi": "0.2.9", "hono": "4.12.27", - "workers-tagged-logger": "1.0.1" + "hono-openapi": "1.3.1", + "openapi-types": "12.1.3", + "workers-tagged-logger": "1.0.1", + "zod": "4.4.3" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "0.16.20", diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 08e0a1b..33d5bd0 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain' @@ -8,7 +9,6 @@ import { validateAndGetAccountId } from '@repo/jwt' // The notification-type ids the hub carries (owned by the `notify` worker). Imported // as a value — the enum has no runtime dependencies. import { NotificationType } from '../../notify/src/notification-types' - import adCarouselItems from '../static/ad-carousel-items.json' import defaultAvatarItems from '../static/default-avatar-items.json' import defaultAvatar from '../static/default-avatar.json' @@ -22,8 +22,36 @@ import { isSpendable, spendCurrency, } from './balance-db' -import { consumeConsumable, countConsumable, getConsumables, grantConsumable } from './consumables-db' +import { + consumeConsumable, + countConsumable, + getConsumables, + grantConsumable, +} from './consumables-db' import { getInventory, grantItem } from './inventory-db' +import { + AUTHED, + AvatarV2Dto, + BalanceEntry, + BuyItemRequest, + BuyItemResponse, + ChallengeProgressRequest, + ChallengeProgressResponse, + ConsumeConsumableRequest, + ConsumeEnvelope, + ConsumeGiftRequest, + CustomAvatarItemsResponse, + ErrorResponse, + form, + json, + JsonArray, + jsonBody, + JsonObject, + OpaqueJsonBody, + SaveOutfitRequest, + SubscriptionResponse, + UNAUTHORIZED_RESPONSE, +} from './openapi' import { getOutfits, setOutfit } from './outfit-db' import type { Context } from 'hono' @@ -36,10 +64,12 @@ import type { Outfit } from './outfit-db' /** * Economy Worker. Hosts the avatar/economy endpoints the game client calls on - * the `econ` service (these are separate from the main `api` worker). DB-backed - * data is stubbed for now — no bindings yet. + * the `econ` service (these are separate from the main `api` worker). Balances, + * inventory, consumables, saved outfits, avatars and gift boxes are D1-backed; + * storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS + * binding. Some routes are still empty-list stubs (room keys, wishlist, …). * - * Auth-gated routes still validate the Bearer JWT issued by the `auth` worker. + * Auth-gated routes validate the Bearer JWT issued by the `auth` worker. */ /** @@ -280,6 +310,24 @@ function toGiftContent( } } +/** + * A concise `describeRoute` spec for a route that serves an opaque JSON array — either + * a static catalog served verbatim or an empty-list stub. `auth` adds the bearer + * requirement + a 401 response. + */ +function listRoute(summary: string, description: string, auth = false) { + return describeRoute({ + tags: ['Econ'], + summary, + description, + ...(auth ? { security: AUTHED } : {}), + responses: { + 200: json(JsonArray, description), + ...(auth ? { 401: UNAUTHORIZED_RESPONSE } : {}), + }, + }) +} + // strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client // posts with a trailing slash) match either form. Mirrors the `api` worker. const app = new Hono({ strict: false }) @@ -297,85 +345,196 @@ const app = new Hono({ strict: false }) .notFound(withNotFound()) // Default-unlocked avatar items, served from the bundled static JSON. - .get('/api/avatar/v1/defaultunlocked', (c) => c.json(defaultAvatarItems)) + .get( + '/api/avatar/v1/defaultunlocked', + listRoute('Default-unlocked avatar items', 'The bundled default avatar-item catalog'), + (c) => c.json(defaultAvatarItems) + ) // Default base avatar items — empty stub for now. No auth. - .get('/api/avatar/v1/defaultbaseavataritems', (c) => c.json([])) + .get( + '/api/avatar/v1/defaultbaseavataritems', + listRoute('Default base avatar items', 'Empty stub for now'), + (c) => c.json([]) + ) // The player's avatar items — the items they've bought (from `buyItem`, stored in // the inventory table) prepended to the default catalog. A player who has bought // nothing gets just the catalog. - .get('/api/avatar/v4/items', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const owned = await getInventory(c.env.DB, id) - return c.json([...owned, ...defaultAvatarItems]) - }) + .get( + '/api/avatar/v4/items', + describeRoute({ + tags: ['Avatar'], + summary: 'The player’s avatar items', + description: + 'The items the player has bought (from buyItem, in the inventory table) prepended ' + + 'to the default catalog. A player who has bought nothing gets just the catalog.', + security: AUTHED, + responses: { + 200: json(JsonArray, 'Owned items followed by the default catalog'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const owned = await getInventory(c.env.DB, id) + return c.json([...owned, ...defaultAvatarItems]) + } + ) // The player's owned custom avatar items. [Authorize]; paginated. Empty stub for // now (no DB binding). The client downloads these when custom-item creation is // allowed; a 404 here surfaces as "Failed to download unlocked avatar items". - .get('/econ/customAvatarItems/v1/owned', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - return c.json({ Results: [], TotalResults: 0 }) - }) + .get( + '/econ/customAvatarItems/v1/owned', + describeRoute({ + tags: ['Avatar'], + summary: 'Owned custom avatar items', + description: + 'Paginated owned custom items. Empty stub for now. The client requests this when ' + + 'custom-item creation is allowed; a 404 shows as “Failed to download unlocked ' + + 'avatar items”.', + security: AUTHED, + responses: { + 200: json(CustomAvatarItemsResponse, 'Paginated results (empty for now)'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json({ Results: [], TotalResults: 0 }) + } + ) // The player's objectives progress. Serves a static JSON file verbatim with // no auth — same default for everyone until there's a DB binding to track // per-player progress. - .get('/api/objectives/v1/myprogress', (c) => c.json(myProgress)) + .get( + '/api/objectives/v1/myprogress', + describeRoute({ + tags: ['Econ'], + summary: 'Objectives progress', + description: + 'Serves the bundled static progress verbatim (no per-player store yet). No auth.', + responses: { 200: json(JsonObject, 'The bundled objectives-progress default') }, + }), + (c) => c.json(myProgress) + ) // Clears a group of objectives. No per-player progress to clear yet, so this // is a no-op that returns an empty array (a 404 here breaks the client). Accepts // GET or POST since the client may use either. - .on(['GET', 'POST'], '/api/objectives/v1/cleargroup', (c) => c.json([])) + .on( + ['GET', 'POST'], + '/api/objectives/v1/cleargroup', + describeRoute({ + tags: ['Econ'], + summary: 'Clear an objectives group (no-op)', + description: 'No per-player progress to clear yet → []. Accepts GET or POST.', + responses: { 200: json(JsonArray, 'Always empty for now') }, + }), + (c) => c.json([]) + ) // The player's avatar, stored as a JSON blob on their account row. Falls back // to the default outfit when they haven't saved one — the client's parser NREs // on an empty OutfitSelections (real RecNet never returns one). - .get('/api/avatar/v2', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - return c.json((await getAvatar(c.env.DB, id)) ?? defaultAvatar) - }) + .get( + '/api/avatar/v2', + describeRoute({ + tags: ['Avatar'], + summary: 'The player’s own avatar', + description: + 'The avatar JSON blob stored on the account row, or the default outfit when none is ' + + 'saved (the client NREs on an empty OutfitSelections).', + security: AUTHED, + responses: { + 200: json(JsonObject, 'The stored avatar blob (or the default)'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json((await getAvatar(c.env.DB, id)) ?? defaultAvatar) + } + ) // Save the player's avatar. [Authorize]. Stores the posted JSON payload verbatim // on the account row and echoes it back. 400 on a non-object body; 404 when the // caller has no account row to attach it to. - .post('/api/avatar/v2/set', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const avatar = (await c.req.json().catch(() => null)) as Record | null - if (avatar === null || typeof avatar !== 'object' || Array.isArray(avatar)) { - return c.body(null, 400) + .post( + '/api/avatar/v2/set', + describeRoute({ + tags: ['Avatar'], + summary: 'Save the player’s avatar', + description: 'Stores the posted JSON blob verbatim on the account row and echoes it back.', + security: AUTHED, + requestBody: jsonBody(OpaqueJsonBody, 'The avatar blob'), + responses: { + 200: json(JsonObject, 'The saved avatar (echoed back)'), + 400: { description: 'Body was not a JSON object (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + 404: { description: 'No account row to attach it to (empty body)' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const avatar = (await c.req.json().catch(() => null)) as Record | null + if (avatar === null || typeof avatar !== 'object' || Array.isArray(avatar)) { + return c.body(null, 400) + } + if (!(await setAvatar(c.env.DB, id, avatar))) return c.body(null, 404) + return c.json(avatar) } - if (!(await setAvatar(c.env.DB, id, avatar))) return c.body(null, 404) - return c.json(avatar) - }) + ) // NUX checklist — the client fetches this on the econ host during load. [] // with no DB. A 404 here can abort the load orchestration before matchmake. - .get('/api/checklist/v1/current', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - return c.json([]) - }) + .get( + '/api/checklist/v1/current', + listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json([]) + } + ) // The player's item wishlist. [Authorize]; empty without a DB binding. - .get('/api/itemWishlists/v1/wishlist/me', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - return c.json([]) - }) + .get( + '/api/itemWishlists/v1/wishlist/me', + listRoute('The player’s item wishlist', 'Empty for now', true), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json([]) + } + ) // The player's saved outfits. [Authorize]. Served back as the client posted them // (see /saved/set); a player who has saved none gets []. - .get('/api/avatar/v3/saved', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - return c.json(await getOutfits(c.env.DB, id)) - }) + .get( + '/api/avatar/v3/saved', + describeRoute({ + tags: ['Avatar'], + summary: 'The player’s saved outfits', + description: 'Served back as the client posted them (see /saved/set); [] when none.', + security: AUTHED, + responses: { + 200: json(JsonArray, 'Saved outfits (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)) + } + ) // Save an outfit into one of the player's slots. [Authorize]. The posted `Slot` is // the slot to write, and re-saving a slot overwrites it — that's the avatar screen's @@ -385,28 +544,62 @@ const app = new Hono({ strict: false }) // // A missing/non-integer `Slot` is a 400 rather than a default slot — guessing would // silently overwrite an outfit the player didn't mean to touch. - .post('/api/avatar/v3/saved/set', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const body = (await c.req.json().catch(() => null)) as Record | null - if (body === null || typeof body !== 'object' || Array.isArray(body)) { - return c.body(null, 400) + .post( + '/api/avatar/v3/saved/set', + describeRoute({ + tags: ['Avatar'], + summary: 'Save an outfit into a slot', + description: + 'Writes the posted outfit into the given `Slot` (overwriting it) and echoes it back. ' + + 'The payload is stored verbatim — its inner fields are JSON-in-a-string from the ' + + 'client’s own serializer. A missing/non-integer `Slot` is a 400 (guessing would ' + + 'silently overwrite another outfit).', + security: AUTHED, + requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'), + responses: { + 200: json(JsonObject, 'The saved outfit (echoed back)'), + 400: { description: 'Non-object body or missing/non-integer Slot (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return c.body(null, 400) + } + if (!Number.isInteger(body.Slot)) return c.body(null, 400) + const outfit = body as Outfit + await setOutfit(c.env.DB, id, outfit) + return c.json(outfit) } - if (!Number.isInteger(body.Slot)) return c.body(null, 400) - const outfit = body as Outfit - await setOutfit(c.env.DB, id, outfit) - return c.json(outfit) - }) + ) // Pending avatar gifts for the player — the unopened gift boxes from their purchases // (and, once gifting lands, from other players). [Authorize]. The client opens each // box and consumes it via the consume route below; the item itself was already // granted at purchase, so an unopened box is cosmetic. - .get('/api/avatar/v2/gifts', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - return c.json(await getPendingGifts(c.env.DB, id)) - }) + .get( + '/api/avatar/v2/gifts', + describeRoute({ + tags: ['Gifts'], + summary: 'Pending gift boxes', + description: + 'The player’s unopened gift boxes from their purchases (and, later, from other ' + + 'players). The item was already granted at purchase, so an unopened box is cosmetic.', + security: AUTHED, + responses: { + 200: json(JsonArray, 'Unopened gift boxes (empty when none)'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json(await getPendingGifts(c.env.DB, id)) + } + ) // Open (consume) a gift box. [Authorize]. The client posts this on the econ host after // the box animation, form-encoded as `Id=&UnlockedLevel=`. Opening just @@ -421,86 +614,188 @@ const app = new Hono({ strict: false }) // as a failure and the consumable never finishes unlocking. The delete is scoped to the // caller's account, so an unauthenticated or mismatched call is simply a no-op. Mirrors // the same route on the `api` worker (the client may call either host). - .post('/api/avatar/v2/gifts/consume', async (c) => { - const id = await authedId(c) - const body = await c.req.parseBody().catch(() => ({}) as Record) - const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0 - if (id !== null && giftId !== 0) { - // Scoped delete: only the box's owner deletes it. A returned box means it was - // theirs and is now consumed. - const gift = await consumeGift(c.env.DB, id, giftId) - if (gift !== null) { - // If the box carried a consumable, tell the client it now has it (so it shows - // up in inventory without a refetch). Avatar-item boxes carry no ConsumableItemDesc. - if (gift.ConsumableItemDesc !== '') await pushConsumableAdded(c, id, gift) - } else { - // Nothing was consumed: either the box is already gone (a harmless no-op — - // re-opening your own consumed box still succeeds) or it belongs to another - // player, which is forbidden. - const other = await getGift(c.env.DB, giftId) - if (other !== null && other.accountId !== id) return c.body(null, 403) + .post( + '/api/avatar/v2/gifts/consume', + describeRoute({ + tags: ['Gifts'], + summary: 'Open (consume) a gift box', + description: + 'Deletes the box (the item was already granted at purchase). Always answers the ' + + '`{ error, success, value }` envelope with HTTP 200 — even with no token, a zero id, ' + + 'or a box already gone — because the client parses it to finish opening the box. The ' + + 'delete is scoped to the caller; opening someone else’s box is 403. Also served by ' + + 'the `api` worker.', + requestBody: form(ConsumeGiftRequest, 'The gift-box id'), + responses: { + 200: json(ConsumeEnvelope, 'Success envelope'), + 403: { description: 'The box belongs to another player (empty body)' }, + }, + }), + async (c) => { + const id = await authedId(c) + const body = await c.req.parseBody().catch(() => ({}) as Record) + const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0 + if (id !== null && giftId !== 0) { + // Scoped delete: only the box's owner deletes it. A returned box means it was + // theirs and is now consumed. + const gift = await consumeGift(c.env.DB, id, giftId) + if (gift !== null) { + // If the box carried a consumable, tell the client it now has it (so it shows + // up in inventory without a refetch). Avatar-item boxes carry no ConsumableItemDesc. + if (gift.ConsumableItemDesc !== '') await pushConsumableAdded(c, id, gift) + } else { + // Nothing was consumed: either the box is already gone (a harmless no-op — + // re-opening your own consumed box still succeeds) or it belongs to another + // player, which is forbidden. + const other = await getGift(c.env.DB, giftId) + if (other !== null && other.accountId !== id) return c.body(null, 403) + } } + return c.json({ error: '', success: true, value: null }) } - return c.json({ error: '', success: true, value: null }) - }) + ) // A player's avatar by account id, projected to the public render subset (used // to draw other players' avatars). No auth — like the accounts `/account/:id` // lookup. Falls back to the default outfit when the player hasn't saved one. // Registered after the static `/api/avatar/v2/*` routes so `:id` can't shadow them. - .get('/api/avatar/v2/:id', async (c) => { - const accountId = Number.parseInt(c.req.param('id'), 10) - if (Number.isNaN(accountId)) return c.body(null, 400) - return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar)) - }) + .get( + '/api/avatar/v2/:id', + describeRoute({ + tags: ['Avatar'], + summary: 'Another player’s avatar (render subset)', + description: + 'The public render subset used to draw another player’s avatar. No auth. Falls back ' + + 'to the default outfit when the player hasn’t saved one.', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + description: 'Account id; non-numeric is 400', + schema: { type: 'string' }, + }, + ], + responses: { + 200: json(AvatarV2Dto, 'The render subset'), + 400: { description: 'Non-numeric id (empty body)' }, + }, + }), + async (c) => { + const accountId = Number.parseInt(c.req.param('id'), 10) + if (Number.isNaN(accountId)) return c.body(null, 400) + return c.json(toAvatarV2Dto((await getAvatar(c.env.DB, accountId)) ?? defaultAvatar)) + } + ) // Unlocked equipment. Returns "[]" with no auth. - .get('/api/equipment/v2/getUnlocked', (c) => c.json([])) + .get('/api/equipment/v2/getUnlocked', listRoute('Unlocked equipment', 'Empty for now'), (c) => + c.json([]) + ) // Room consumables/currencies for a given room. Stubbed as empty lists so the // client doesn't 404. - .get('/api/roomconsumables/v1/roomConsumable/room/:roomId', (c) => c.json([])) - .get('/api/roomconsumables/v1/roomConsumable/room/:roomId/me', (c) => c.json([])) - .get('/api/roomcurrencies/v1/currencies', (c) => c.json([])) - .get('/api/roomcurrencies/v1/getAllBalances', (c) => c.json([])) + .get( + '/api/roomconsumables/v1/roomConsumable/room/:roomId', + listRoute('Room consumables', 'Empty stub so the client doesn’t 404'), + (c) => c.json([]) + ) + .get( + '/api/roomconsumables/v1/roomConsumable/room/:roomId/me', + listRoute('The caller’s room consumables', 'Empty stub'), + (c) => c.json([]) + ) + .get('/api/roomcurrencies/v1/currencies', listRoute('Room currencies', 'Empty stub'), (c) => + c.json([]) + ) + .get('/api/roomcurrencies/v1/getAllBalances', listRoute('Room balances', 'Empty stub'), (c) => + c.json([]) + ) // Persist player settings. [Authorize]; would replace the player's settings. // No DB binding yet, so accept-and-ack. - .post('/api/settings/v2/set', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - // TODO: replace stored settings for `id` once a DB binding exists. - return c.body(null, 200) - }) + .post( + '/api/settings/v2/set', + describeRoute({ + tags: ['Econ'], + summary: 'Persist player settings', + description: 'Accept-and-ack — no settings store yet. Empty 200.', + security: AUTHED, + requestBody: jsonBody(OpaqueJsonBody, 'The settings payload (currently ignored)'), + responses: { + 200: { description: 'Acknowledged (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + // TODO: replace stored settings for `id` once a DB binding exists. + return c.body(null, 200) + } + ) // Unlocked consumables. [Authorize]. The consumables the player has bought (from // `buyItem`, stored in the `consumable` table), grouped by item into the client's // unlocked-consumable DTO. A player who has bought none gets an empty list. - .get('/api/consumables/v2/getUnlocked', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - return c.json(await getConsumables(c.env.DB, id)) - }) + .get( + '/api/consumables/v2/getUnlocked', + describeRoute({ + tags: ['Consumables'], + summary: 'Unlocked consumables', + description: + 'The consumables the player has bought (from buyItem, in the consumable table), ' + + 'grouped by item into the unlocked-consumable DTO (Ids/CreatedAts per instance, ' + + 'Count their sum). [] when they’ve bought none.', + security: AUTHED, + responses: { + 200: json(JsonArray, 'Grouped unlocked consumables (empty when none)'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json(await getConsumables(c.env.DB, id)) + } + ) // Consume a quantity of an owned consumable instance. [Authorize]. Body is JSON // `{ Id, DeltaCount }` where `Id` is the consumable row id. Reduces that instance's // count by DeltaCount, deleting the row once it hits zero. Scoped to the caller so // they can only consume their own. Envelope mirrors the gift-consume ack. - .post('/api/consumables/v1/consume', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const body = await c.req - .json<{ Id?: unknown; DeltaCount?: unknown }>() - .catch(() => ({}) as { Id?: unknown; DeltaCount?: unknown }) - const consumableId = typeof body.Id === 'number' ? body.Id : Number.NaN - const delta = typeof body.DeltaCount === 'number' ? body.DeltaCount : 1 - if (!Number.isNaN(consumableId) && delta > 0) { - const consumed = await consumeConsumable(c.env.DB, id, consumableId, delta) - // Notify the player so their client removes/updates the item in inventory. - if (consumed !== null) await pushConsumableRemoved(c, id, consumed) + .post( + '/api/consumables/v1/consume', + describeRoute({ + tags: ['Consumables'], + summary: 'Consume a quantity of an owned consumable', + description: + 'Reduces the given consumable instance’s count by `DeltaCount` (default 1), deleting ' + + 'the row at zero. Scoped to the caller. Pushes a ConsumableMappingRemoved socket ' + + 'notification. Envelope mirrors the gift-consume ack.', + security: AUTHED, + requestBody: jsonBody(ConsumeConsumableRequest, 'The consumable id and delta'), + responses: { + 200: json(ConsumeEnvelope, 'Success envelope'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const body = await c.req + .json<{ Id?: unknown; DeltaCount?: unknown }>() + .catch(() => ({}) as { Id?: unknown; DeltaCount?: unknown }) + const consumableId = typeof body.Id === 'number' ? body.Id : Number.NaN + const delta = typeof body.DeltaCount === 'number' ? body.DeltaCount : 1 + if (!Number.isNaN(consumableId) && delta > 0) { + const consumed = await consumeConsumable(c.env.DB, id, consumableId, delta) + // Notify the player so their client removes/updates the item in inventory. + if (consumed !== null) await pushConsumableRemoved(c, id, consumed) + } + return c.json({ error: '', success: true, value: null }) } - return c.json({ error: '', success: true, value: null }) - }) + ) // Currency balance. [Authorize]. The trailing int is a CurrencyType — the client // fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a @@ -509,30 +804,77 @@ const app = new Hono({ strict: false }) // An unknown or non-account-scoped currency (a room currency, ProgressionEvent, // Invalid) returns a 0 balance rather than 404: the client treats a failed balance // fetch as a load error, and "you have none of that" is the honest answer anyway. - .get('/api/storefronts/v4/balance/:currencyType', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const currencyType = Number.parseInt(c.req.param('currencyType'), 10) - if (Number.isNaN(currencyType)) return c.body(null, 400) - const amount = isSpendable(currencyType) - ? await getBalance( - c.env.DB, - id, - currencyType, - intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) - ) - : 0 - return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }]) - }) + .get( + '/api/storefronts/v4/balance/:currencyType', + describeRoute({ + tags: ['Storefront'], + summary: 'Currency balance', + description: + 'The player’s balance in a CurrencyType (the client fetches `/balance/2`, ' + + 'RecCenterTokens, on load). A first read seeds their starting balance. An unknown or ' + + 'non-account currency returns a 0 balance rather than 404.', + security: AUTHED, + parameters: [ + { + name: 'currencyType', + in: 'path', + required: true, + description: 'CurrencyType integer; non-numeric is 400', + schema: { type: 'string' }, + }, + ], + responses: { + 200: json(BalanceEntry.array(), 'A single-entry balance array'), + 400: { description: 'Non-numeric currencyType (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const currencyType = Number.parseInt(c.req.param('currencyType'), 10) + if (Number.isNaN(currencyType)) return c.body(null, 400) + const amount = isSpendable(currencyType) + ? await getBalance( + c.env.DB, + id, + currencyType, + intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) + ) + : 0 + return c.json([{ CurrencyType: currencyType, Platform: ALL_PLATFORMS, Balance: amount }]) + } + ) // Gift-drop storefront. Serves `static/storefronts/sf{id}.json` for the requested // storefront id via the ASSETS binding; 404s when no such catalog exists. - .get('/api/storefronts/v3/giftdropstore/:id', async (c) => { - const id = c.req.param('id') - const res = await c.env.ASSETS.fetch(new URL(`/sf${id}.json`, c.req.url)) - if (!res.ok) return c.notFound() - return c.json(await res.json()) - }) + .get( + '/api/storefronts/v3/giftdropstore/:id', + describeRoute({ + tags: ['Storefront'], + summary: 'Gift-drop storefront catalog', + description: 'Serves the `sf{id}.json` catalog via the ASSETS binding. 404 when none exists.', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + description: 'Storefront id (selects sf{id}.json)', + schema: { type: 'string' }, + }, + ], + responses: { + 200: json(JsonObject, 'The storefront catalog'), + 404: { description: 'No such storefront catalog' }, + }, + }), + async (c) => { + const id = c.req.param('id') + const res = await c.env.ASSETS.fetch(new URL(`/sf${id}.json`, c.req.url)) + if (!res.ok) return c.notFound() + return c.json(await res.json()) + } + ) // Buy a storefront item. [Authorize]. The client posts the storefront/item ids, the // currency and the price it sees; we look the item up in that storefront's catalog, @@ -546,154 +888,196 @@ const app = new Hono({ strict: false }) // // `RequestedPrice` is the price the client rendered; rejecting a mismatch stops a stale // client (or a tampered request) from buying at a price the catalog no longer offers. - .post('/api/storefronts/v2/buyItem', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .post( + '/api/storefronts/v2/buyItem', + describeRoute({ + tags: ['Storefront'], + summary: 'Buy a storefront item', + description: + 'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice` ' + + 'still matches, debits the buyer atomically, grants the item (into the inventory or ' + + 'consumable table), and returns a gift box. A `Gift` block routes the item to another ' + + 'player, but the caller always pays. `Balance` in the response is the CHANGE (negated ' + + 'price), not the new total. Pushes a StorefrontBalanceUpdate socket notification.', + security: AUTHED, + requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'), + responses: { + 200: json(BuyItemResponse, 'The purchase result (gift box + balance change)'), + 400: json(ErrorResponse, 'Invalid body, unavailable currency, or insufficient balance'), + 401: UNAUTHORIZED_RESPONSE, + 404: json(ErrorResponse, 'No such item'), + 409: json(ErrorResponse, 'The price has changed since the client rendered it'), + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) - const body = (await c.req.json().catch(() => null)) as Record | null - if (body === null || typeof body !== 'object' || Array.isArray(body)) { - return c.json({ error: 'Invalid request body' }, 400) - } - const storefrontType = body.StorefrontType - const purchasableItemId = body.PurchasableItemId - const currencyType = body.CurrencyType - const requestedPrice = body.RequestedPrice - if ( - !Number.isInteger(storefrontType) || - !Number.isInteger(purchasableItemId) || - !Number.isInteger(currencyType) || - !Number.isInteger(requestedPrice) - ) { - return c.json( - { - error: 'StorefrontType, PurchasableItemId, CurrencyType and RequestedPrice are required', - }, - 400 + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return c.json({ error: 'Invalid request body' }, 400) + } + const storefrontType = body.StorefrontType + const purchasableItemId = body.PurchasableItemId + const currencyType = body.CurrencyType + const requestedPrice = body.RequestedPrice + if ( + !Number.isInteger(storefrontType) || + !Number.isInteger(purchasableItemId) || + !Number.isInteger(currencyType) || + !Number.isInteger(requestedPrice) + ) { + return c.json( + { + error: + 'StorefrontType, PurchasableItemId, CurrencyType and RequestedPrice are required', + }, + 400 + ) + } + + const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number) + if (item === null) return c.json({ error: 'Item not found' }, 404) + + const price = item.Prices.find((p) => p.CurrencyType === currencyType) + if (price === undefined) { + return c.json({ error: 'Currency type not available for this item' }, 400) + } + if (price.Price !== requestedPrice) { + return c.json({ error: 'Price has changed' }, 409) + } + // The item's currency must be an account balance we can debit (RecCenterTokens et al), + // not a room-scoped or non-spendable currency. + if (!isSpendable(currencyType as number)) { + return c.json({ error: 'Currency type is not spendable' }, 400) + } + + const gift = ( + typeof body.Gift === 'object' && body.Gift !== null ? body.Gift : null + ) as GiftRequest | null + const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id + // A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift + // is attributed to the "Coach" system account (id 1), never a null/0 sender. + const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID + const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3' + + const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) + // Debit the buyer atomically; a false return means they couldn't afford it and + // nothing changed, so no item is granted. + const paid = await spendCurrency( + c.env.DB, + id, + currencyType as number, + price.Price, + startingTokens ) - } + if (!paid) return c.json({ error: 'Insufficient balance' }, 400) - const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number) - if (item === null) return c.json({ error: 'Item not found' }, 404) - - const price = item.Prices.find((p) => p.CurrencyType === currencyType) - if (price === undefined) { - return c.json({ error: 'Currency type not available for this item' }, 400) - } - if (price.Price !== requestedPrice) { - return c.json({ error: 'Price has changed' }, 409) - } - // The item's currency must be an account balance we can debit (RecCenterTokens et al), - // not a room-scoped or non-spendable currency. - if (!isSpendable(currencyType as number)) { - return c.json({ error: 'Currency type is not spendable' }, 400) - } - - const gift = ( - typeof body.Gift === 'object' && body.Gift !== null ? body.Gift : null - ) as GiftRequest | null - const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id - // A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift - // is attributed to the "Coach" system account (id 1), never a null/0 sender. - const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID - const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3' - - const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) - // Debit the buyer atomically; a false return means they couldn't afford it and - // nothing changed, so no item is granted. - const paid = await spendCurrency( - c.env.DB, - id, - currencyType as number, - price.Price, - startingTokens - ) - if (!paid) return c.json({ error: 'Insufficient balance' }, 400) - - // Grant the item to the recipient. A gift-drop carries an avatar item, a consumable, - // or neither (currency/xp drops aren't granted yet); grant whichever it actually has. - if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') { - await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop)) - } - const isConsumable = - typeof item.GiftDrop.ConsumableItemDesc === 'string' && - item.GiftDrop.ConsumableItemDesc !== '' - const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0 - // Capture the granted consumable's row id and the player's pre-existing count so - // the gift box can carry them — gift-consume fires ConsumableMappingAdded from these. - let consumableMappingId = 0 - let consumablePreExisting = 0 - if (isConsumable) { - consumablePreExisting = await countConsumable( + // Grant the item to the recipient. A gift-drop carries an avatar item, a consumable, + // or neither (currency/xp drops aren't granted yet); grant whichever it actually has. + if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') { + await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop)) + } + const isConsumable = + typeof item.GiftDrop.ConsumableItemDesc === 'string' && + item.GiftDrop.ConsumableItemDesc !== '' + const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0 + // Capture the granted consumable's row id and the player's pre-existing count so + // the gift box can carry them — gift-consume fires ConsumableMappingAdded from these. + let consumableMappingId = 0 + let consumablePreExisting = 0 + if (isConsumable) { + consumablePreExisting = await countConsumable( + c.env.DB, + receiverId, + item.GiftDrop.ConsumableItemDesc + ) + consumableMappingId = await grantConsumable( + c.env.DB, + receiverId, + item.GiftDrop.ConsumableItemDesc, + consumableCount + ) + } + const { id: giftId } = await createGift( c.env.DB, receiverId, - item.GiftDrop.ConsumableItemDesc - ) - consumableMappingId = await grantConsumable( - c.env.DB, - receiverId, - item.GiftDrop.ConsumableItemDesc, - consumableCount + toGiftContent( + item.GiftDrop, + message, + consumableCount, + consumableMappingId, + consumablePreExisting + ) ) + + // Push the buyer's new (reduced) balance over the socket so their client updates the + // shown total immediately — the buyer (`id`) is who was debited, in the currency they + // spent. Best-effort; the HTTP response still carries the change either way. + const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens) + await pushBalanceUpdate(c, id, currencyType as number, newBalance) + + // The response mirrors a captured real buyItem: `Balance` is the change applied (the + // negated price), not the resulting balance (the client reads its new total from + // `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms). The Data + // entry is the gift-drop the client received — it carries no FriendlyName or + // consumable count (the count is a getUnlocked concept; each box is one instance). + return c.json({ + BalanceUpdates: [ + { + UpdateResponse: 0, + Data: [ + { + Id: giftId, + FromPlayerId: fromPlayerId, + ConsumableItemDesc: item.GiftDrop.ConsumableItemDesc, + AvatarItemDesc: item.GiftDrop.AvatarItemDesc, + AvatarItemType: item.GiftDrop.AvatarItemType ?? 0, + EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName, + EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid, + CurrencyType: item.GiftDrop.CurrencyType, + Currency: item.GiftDrop.Currency, + Xp: 0, + Level: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: ALL_PLATFORMS, + GiftContext: Number.isInteger(gift?.GiftContext) + ? (gift?.GiftContext as number) + : item.GiftDrop.Context, + GiftRarity: item.GiftDrop.Rarity, + Message: message, + }, + ], + }, + ], + Balance: -price.Price, + CurrencyType: currencyType, + BalanceType: ALL_PLATFORMS, + }) } - const { id: giftId } = await createGift( - c.env.DB, - receiverId, - toGiftContent(item.GiftDrop, message, consumableCount, consumableMappingId, consumablePreExisting) - ) - - // Push the buyer's new (reduced) balance over the socket so their client updates the - // shown total immediately — the buyer (`id`) is who was debited, in the currency they - // spent. Best-effort; the HTTP response still carries the change either way. - const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens) - await pushBalanceUpdate(c, id, currencyType as number, newBalance) - - // The response mirrors a captured real buyItem: `Balance` is the change applied (the - // negated price), not the resulting balance (the client reads its new total from - // `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms). The Data - // entry is the gift-drop the client received — it carries no FriendlyName or - // consumable count (the count is a getUnlocked concept; each box is one instance). - return c.json({ - BalanceUpdates: [ - { - UpdateResponse: 0, - Data: [ - { - Id: giftId, - FromPlayerId: fromPlayerId, - ConsumableItemDesc: item.GiftDrop.ConsumableItemDesc, - AvatarItemDesc: item.GiftDrop.AvatarItemDesc, - AvatarItemType: item.GiftDrop.AvatarItemType ?? 0, - EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName, - EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid, - CurrencyType: item.GiftDrop.CurrencyType, - Currency: item.GiftDrop.Currency, - Xp: 0, - Level: 0, - Platform: -1, - PlatformsToSpawnOn: -1, - BalanceType: ALL_PLATFORMS, - GiftContext: Number.isInteger(gift?.GiftContext) - ? (gift?.GiftContext as number) - : item.GiftDrop.Context, - GiftRarity: item.GiftDrop.Rarity, - Message: message, - }, - ], - }, - ], - Balance: -price.Price, - CurrencyType: currencyType, - BalanceType: ALL_PLATFORMS, - }) - }) + ) // Storefront ad-carousel items. Served from the bundled static JSON — one // placeholder banner with no purchasable items until real promo data exists. - .get('/api/storefronts/v1/adcarouselitems', (c) => c.json(adCarouselItems)) + .get( + '/api/storefronts/v1/adcarouselitems', + listRoute('Storefront ad-carousel items', 'The bundled carousel (one placeholder banner)'), + (c) => c.json(adCarouselItems) + ) // Current weekly challenge. Served from the bundled static JSON until // per-rotation challenge data is wired up. - .get('/api/challenge/v2/getCurrent', (c) => c.json(weeklyChallenge)) + .get( + '/api/challenge/v2/getCurrent', + describeRoute({ + tags: ['Econ'], + summary: 'Current weekly challenge', + description: 'Served from the bundled static challenge until per-rotation data is wired up.', + responses: { 200: json(JsonObject, 'The current weekly challenge') }, + }), + (c) => c.json(weeklyChallenge) + ) // Report progress on a weekly challenge. The client evaluates the challenge's rule // tree locally and posts ChallengeMapId/ChallengeId, that tree in `Config`, and @@ -701,34 +1085,106 @@ const app = new Hono({ strict: false }) // progress DB yet we persist nothing and never mark a challenge complete (so the // gift flow isn't triggered). Echo the identifying fields back with Complete=false // so the client gets a well-formed, non-null body to deserialize. - .post('/api/challenge/v2/updateProgress', async (c) => { - const body = await c.req - .json<{ ChallengeMapId?: string | number; ChallengeId?: string | number; Config?: string }>() - .catch(() => ({}) as Record) - return c.json({ - ChallengeMapId: Number(body.ChallengeMapId) || 0, - ChallengeId: Number(body.ChallengeId) || 0, - Config: typeof body.Config === 'string' ? body.Config : '', - Complete: false, - }) - }) + .post( + '/api/challenge/v2/updateProgress', + describeRoute({ + tags: ['Econ'], + summary: 'Report weekly-challenge progress', + description: + 'Stubbed: with no challenge-progress store we persist nothing and never mark a ' + + 'challenge complete. Echoes the identifying fields back with `Complete: false` so the ' + + 'client gets a well-formed body.', + requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'), + responses: { 200: json(ChallengeProgressResponse, 'Echoed fields, Complete false') }, + }), + async (c) => { + const body = await c.req + .json<{ + ChallengeMapId?: string | number + ChallengeId?: string | number + Config?: string + }>() + .catch(() => ({}) as Record) + return c.json({ + ChallengeMapId: Number(body.ChallengeMapId) || 0, + ChallengeId: Number(body.ChallengeId) || 0, + Config: typeof body.Config === 'string' ? body.Config : '', + Complete: false, + }) + } + ) // Pending game rewards. Returns "[]". - .get('/api/gamerewards/v1/pending', (c) => c.json([])) + .get('/api/gamerewards/v1/pending', listRoute('Pending game rewards', 'Empty for now'), (c) => + c.json([]) + ) // Request a game reward (client posts `rewardType`/`Message`, e.g. // FirstActivityOfDay). Stubbed: with no reward DB yet we grant nothing and return an // empty list of rewards — matching the `pending` shape so the client deserializes it. - .post('/api/gamerewards/v1/request', (c) => c.json([])) - - // The player's room keys. Returns "[]". - .get('/api/roomkeys/v1/mine', (c) => c.json([])) - // Room keys for a given room (client calls this on the econ host). [] with no DB. - .get('/api/roomkeys/v1/room', (c) => c.json([])) - - // Subscription lookup. Returns both fields null with no auth. - .post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) => - c.json({ subscription: null, platformAccountSubscribedPlayerId: null }) + .post( + '/api/gamerewards/v1/request', + listRoute('Request a game reward', 'Stubbed — grants nothing, returns []'), + (c) => c.json([]) ) + // The player's room keys. Returns "[]". + .get('/api/roomkeys/v1/mine', listRoute('The player’s room keys', 'Empty for now'), (c) => + c.json([]) + ) + // Room keys for a given room (client calls this on the econ host). [] with no DB. + .get('/api/roomkeys/v1/room', listRoute('Room keys for a room', 'Empty for now'), (c) => + c.json([]) + ) + + // Subscription lookup. Returns both fields null with no auth. + .post( + '/api/CampusCard/v1/UpdateAndGetSubscription', + describeRoute({ + tags: ['Econ'], + summary: 'Subscription lookup', + description: 'No subscriptions yet — both fields null. No auth.', + responses: { 200: json(SubscriptionResponse, 'Both fields null') }, + }), + (c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null }) + ) + +// The generated spec. Documentation only — no request is validated against it (see +// openapi.ts). `hide: true` keeps this route out of its own output. +app.get( + '/openapi.json', + describeRoute({ hide: true }), + openAPIRouteHandler(app, { + documentation: { + info: { + title: 'recflare econ', + version: '1.0.0', + description: [ + 'Avatar and economy endpoints for recflare, a private-server reimplementation of the', + 'Rec Room backend. The client calls these on the `econ` host; many are also served by', + 'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,', + 'inventory, consumables, saved outfits and gift boxes are D1-backed.', + '', + 'The shapes here are **reverse-engineered from the game client**, which is the only', + 'real consumer. They record observed behaviour, not a designed contract; the handlers', + 'are lenient and parse bodies defensively. Nothing in this spec is enforced at', + 'runtime — treat a field marked required as "the client always sends it", not "the', + 'server rejects it if absent".', + ].join('\n'), + }, + servers: [{ url: 'https://econ.recflare.net', description: 'Production' }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'An `access_token` from the auth worker’s `POST /connect/token`.', + }, + }, + }, + }, + }) +) + export default app diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts new file mode 100644 index 0000000..9ab3c7b --- /dev/null +++ b/apps/econ/src/openapi.ts @@ -0,0 +1,181 @@ +import { resolver } from 'hono-openapi' +import { z } from 'zod' + +import type { OpenAPIV3_1 } from 'openapi-types' + +/** + * OpenAPI schemas for the econ worker. + * + * IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to + * generate the spec and are never wired into `hono-openapi`'s `validator()`. Same + * rationale as the auth/accounts/match workers: a reverse-engineered protocol, lenient + * handlers, no runtime validation. + * + * Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a + * meta'd schema used in a response emits a `$ref` the framework doesn't always hoist + * into `components.schemas`, leaving a dangling reference. Leaving meta off makes every + * schema inline, which renders correctly in any tool. + */ + +/** Emit a zod schema as an `application/json` response body. */ +export function json(schema: z.ZodType, description: string) { + return { description, content: { 'application/json': { schema: resolver(schema) } } } +} + +function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject { + const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema) + return jsonSchema as OpenAPIV3_1.SchemaObject +} + +/** A form-urlencoded / multipart request body (the client posts both). */ +export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject { + const s = toOpenApiSchema(schema) + return { + description, + content: { + 'application/x-www-form-urlencoded': { schema: s }, + 'multipart/form-data': { schema: s }, + }, + } +} + +/** An `application/json` request body. */ +export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject { + return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } } +} + +/** The empty-body 401 the auth-gated routes return. */ +export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' } + +/** Bearer-JWT security requirement, for the auth-gated routes. */ +export const AUTHED = [{ bearerAuth: [] }] + +// ---- Loose shapes ---------------------------------------------------------- +// Several routes serve opaque static catalogs (avatar items, the weekly challenge) or +// empty-list stubs. Modelling every catalog field adds noise without value, so these +// use deliberately loose schemas. + +/** An opaque JSON object (a catalog entry, an avatar blob, …). */ +export const JsonObject = z.record(z.string(), z.unknown()) +/** An opaque JSON array (a static catalog served verbatim). */ +export const JsonArray = z.array(z.unknown()) + +// ---- Response schemas ------------------------------------------------------ + +/** + * The public avatar render subset (`GET /api/avatar/v2/:id`) — the fields needed to + * draw another player's avatar. The stored blob also holds OutfitSelectionsV2 / + * CustomAvatarItems, which this view omits. + */ +export const AvatarV2Dto = z.object({ + OutfitSelections: z.unknown(), + FaceFeatures: z.unknown(), + SkinColor: z.unknown(), + HairColor: z.unknown(), +}) + +/** + * The `{ error, success, value }` envelope both consume routes return. Always HTTP 200, + * even for a missing/already-gone target — the client parses this to finish the action, + * so a bare 200 reads as a failure. + */ +export const ConsumeEnvelope = z.object({ + error: z.string(), + success: z.boolean(), + value: z.null(), +}) + +/** One currency balance entry (`GET /api/storefronts/v4/balance/:currencyType`). */ +export const BalanceEntry = z.object({ + CurrencyType: z.int(), + Platform: z.int().describe('-2 = all platforms (account-wide)'), + Balance: z.int(), +}) + +/** `GET /econ/customAvatarItems/v1/owned` — paginated owned custom items. */ +export const CustomAvatarItemsResponse = z.object({ + Results: JsonArray, + TotalResults: z.int(), +}) + +/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both fields null (no subs yet). */ +export const SubscriptionResponse = z.object({ + subscription: z.null(), + platformAccountSubscribedPlayerId: z.null(), +}) + +/** `POST /api/challenge/v2/updateProgress` — the identifying fields echoed back. */ +export const ChallengeProgressResponse = z.object({ + ChallengeMapId: z.int(), + ChallengeId: z.int(), + Config: z.string(), + Complete: z.boolean().describe('Always false — no challenge-progress store yet'), +}) + +/** + * `POST /api/storefronts/v2/buyItem` — the purchase result. `Balance` is the CHANGE + * applied (the negated price), not the resulting total; the client reads its new total + * from `GET /balance/:type`. `BalanceType` -2 is account-wide. Each `Data` entry is the + * gift-drop the recipient received. + */ +export const BuyItemResponse = z.object({ + BalanceUpdates: z.array( + z.object({ + UpdateResponse: z.int(), + Data: z.array(JsonObject).describe('The gift-drop(s) granted'), + }) + ), + Balance: z.int().describe('The change applied (negated price), not the new total'), + CurrencyType: z.int(), + BalanceType: z.int().describe('-2 = account-wide'), +}) + +/** buyItem error body (`{ error }`), returned on 400/404/409. */ +export const ErrorResponse = z.object({ error: z.string() }) + +// ---- Request schemas ------------------------------------------------------- + +/** `POST /api/storefronts/v2/buyItem` JSON body. */ +export const BuyItemRequest = z.object({ + StorefrontType: z.int().describe('Which storefront catalog (sf{N}.json)'), + PurchasableItemId: z.int(), + CurrencyType: z.int().describe('Must be a spendable account currency'), + RequestedPrice: z.int().describe('The price the client rendered; a mismatch is 409'), + Gift: z + .object({ + ToPlayerId: z.int().optional(), + Anonymous: z.boolean().optional(), + Message: z.string().optional(), + GiftContext: z.int().optional(), + }) + .optional() + .describe('Present when buying for another player; the caller still pays'), +}) + +/** `POST /api/consumables/v1/consume` JSON body. */ +export const ConsumeConsumableRequest = z.object({ + Id: z.int().describe('The consumable row id to spend from'), + DeltaCount: z.int().optional().describe('How many to spend; defaults to 1'), +}) + +/** `POST /api/avatar/v2/gifts/consume` form body (posted with a trailing slash). */ +export const ConsumeGiftRequest = z.object({ + Id: z.string().describe('The gift-box id to open'), + UnlockedLevel: z.string().optional().describe('Consumable-level hint; unused'), +}) + +/** `POST /api/challenge/v2/updateProgress` JSON body. */ +export const ChallengeProgressRequest = z.object({ + ChallengeMapId: z.union([z.string(), z.int()]).optional(), + ChallengeId: z.union([z.string(), z.int()]).optional(), + Config: z.string().optional().describe('The client-evaluated rule tree'), +}) + +/** `POST /api/avatar/v3/saved/set` JSON body — an outfit with a target `Slot`. */ +export const SaveOutfitRequest = z + .object({ Slot: z.int().describe('Which slot to overwrite; a non-integer is 400') }) + .catchall(z.unknown()) + .describe('Plus opaque outfit fields (OutfitSelectionsV2, FaceFeatures, …) stored verbatim') + +/** An opaque JSON body stored verbatim (the avatar blob for `POST /api/avatar/v2/set`). */ +export const OpaqueJsonBody = JsonObject.describe('Stored verbatim and echoed back') diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 42f70f6..d3bbc7e 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -582,7 +582,7 @@ describe('econ endpoints', () => { expect(await res.json()).toBeTruthy() }) - // Item 73 in sf3.json — "Class of 2016", 4500 RecCenterTokens (CurrencyType 2). + // Item 73 in sf3.json — "Bowtie (White)", 450 RecCenterTokens (CurrencyType 2). test('POST /api/storefronts/v2/buyItem 401s without a token', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { method: 'POST', @@ -591,7 +591,7 @@ describe('econ endpoints', () => { StorefrontType: 3, PurchasableItemId: 73, CurrencyType: 2, - RequestedPrice: 4500, + RequestedPrice: 450, }), }) expect(res.status).toBe(401) @@ -606,7 +606,7 @@ describe('econ endpoints', () => { StorefrontType: 3, PurchasableItemId: 73, CurrencyType: 2, - RequestedPrice: 4500, + RequestedPrice: 450, }), }) expect(res.status).toBe(200) @@ -619,25 +619,25 @@ describe('econ endpoints', () => { }> } // `Balance` is the change applied (the negated price), not the resulting total. - expect(body.Balance).toBe(-4500) + expect(body.Balance).toBe(-450) expect(body.CurrencyType).toBe(2) expect(body.BalanceType).toBe(-2) const gift = body.BalanceUpdates[0].Data[0] expect(gift.AvatarItemDesc).not.toBe('') expect(gift.Id).toBeGreaterThan(0) - // The balance endpoint reflects the debit (this is the resulting total, 10000 - 4500). + // The balance endpoint reflects the debit (this is the resulting total, 10000 - 450). const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { headers: await bearer('20'), }) - expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 5500 }]) + expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 9550 }]) // The item is now owned — it leads the v4/items list (owned items prepend the catalog). const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { headers: await bearer('20'), }) const list = (await items.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }> - expect(list[0].FriendlyName).toBe('Class of 2016') + expect(list[0].FriendlyName).toBe('Bowtie (White)') expect(list[0].AvatarItemDesc).toBe(gift.AvatarItemDesc) // And a pending gift box is waiting to be opened. @@ -759,7 +759,7 @@ describe('econ endpoints', () => { StorefrontType: 3, PurchasableItemId: 9999999, CurrencyType: 2, - RequestedPrice: 4500, + RequestedPrice: 450, }), }) expect(res.status).toBe(404) @@ -777,7 +777,7 @@ describe('econ endpoints', () => { StorefrontType: 3, PurchasableItemId: 73, CurrencyType: 2, - RequestedPrice: 4500, + RequestedPrice: 450, }), }) expect(res.status).toBe(400) @@ -786,7 +786,7 @@ describe('econ endpoints', () => { headers: await bearer('23'), }) const list = (await items.json()) as Array<{ FriendlyName: string }> - expect(list.every((i) => i.FriendlyName !== 'Class of 2016')).toBe(true) + expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true) }) test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => { @@ -799,7 +799,7 @@ describe('econ endpoints', () => { StorefrontType: 3, PurchasableItemId: 73, CurrencyType: 2, - RequestedPrice: 4500, + RequestedPrice: 450, }), }) const bought = (await buy.json()) as { @@ -827,7 +827,7 @@ describe('econ endpoints', () => { headers: await bearer('24'), }) const list = (await items.json()) as Array<{ FriendlyName: string }> - expect(list.some((i) => i.FriendlyName === 'Class of 2016')).toBe(true) + expect(list.some((i) => i.FriendlyName === 'Bowtie (White)')).toBe(true) // Opening it again is a harmless no-op — still 200. const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, { @@ -855,8 +855,9 @@ describe('econ endpoints', () => { }), }) expect(buy.status).toBe(200) - const giftId = ((await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> }) - .BalanceUpdates[0].Data[0].Id + const giftId = ( + (await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> } + ).BalanceUpdates[0].Data[0].Id // Opening the box succeeds and fires the ConsumableMappingAdded push (which no-ops // against the test hub stub — this asserts the notify path doesn't throw). @@ -871,7 +872,9 @@ describe('econ endpoints', () => { // The box is gone; the consumable stays owned (granted at purchase). expect( await ( - await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { headers: await bearer('26') }) + await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { + headers: await bearer('26'), + }) ).json() ).toEqual([]) const unlocked = (await ( @@ -891,11 +894,12 @@ describe('econ endpoints', () => { StorefrontType: 3, PurchasableItemId: 73, CurrencyType: 2, - RequestedPrice: 4500, + RequestedPrice: 450, }), }) - const giftId = ((await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> }) - .BalanceUpdates[0].Data[0].Id + const giftId = ( + (await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> } + ).BalanceUpdates[0].Data[0].Id // Account 28 trying to open 27's box is forbidden — and 27 keeps it. const forbidden = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, { @@ -1002,4 +1006,70 @@ describe('econ endpoints', () => { const res = await exports.default.fetch(`${ORIGIN}/nope`) expect(res.status).toBe(404) }) + + test('GET /openapi.json documents every route', async () => { + const res = await exports.default.fetch(`${ORIGIN}/openapi.json`) + expect(res.status).toBe(200) + const spec = (await res.json()) as { + openapi: string + paths: Record> + } + expect(spec.openapi).toMatch(/^3\.1/) + + // The spec route hides itself. + expect(spec.paths['/openapi.json']).toBeUndefined() + + // Every route the worker serves is described. This is the drift guard: adding a + // route without a describeRoute() block fails here rather than silently shipping + // an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the + // `.on(['GET','POST'], …)` cleargroup route contributes both methods. + const documented = new Set( + Object.entries(spec.paths).flatMap(([path, ops]) => + Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`) + ) + ) + expect([...documented].sort()).toEqual([ + 'GET /api/avatar/v1/defaultbaseavataritems', + 'GET /api/avatar/v1/defaultunlocked', + 'GET /api/avatar/v2', + 'GET /api/avatar/v2/gifts', + 'GET /api/avatar/v2/{id}', + 'GET /api/avatar/v3/saved', + 'GET /api/avatar/v4/items', + 'GET /api/challenge/v2/getCurrent', + 'GET /api/checklist/v1/current', + 'GET /api/consumables/v2/getUnlocked', + 'GET /api/equipment/v2/getUnlocked', + 'GET /api/gamerewards/v1/pending', + 'GET /api/itemWishlists/v1/wishlist/me', + 'GET /api/objectives/v1/cleargroup', + 'GET /api/objectives/v1/myprogress', + 'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}', + 'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}/me', + 'GET /api/roomcurrencies/v1/currencies', + 'GET /api/roomcurrencies/v1/getAllBalances', + 'GET /api/roomkeys/v1/mine', + 'GET /api/roomkeys/v1/room', + 'GET /api/storefronts/v1/adcarouselitems', + 'GET /api/storefronts/v3/giftdropstore/{id}', + 'GET /api/storefronts/v4/balance/{currencyType}', + 'GET /econ/customAvatarItems/v1/owned', + 'POST /api/CampusCard/v1/UpdateAndGetSubscription', + 'POST /api/avatar/v2/gifts/consume', + 'POST /api/avatar/v2/set', + 'POST /api/avatar/v3/saved/set', + 'POST /api/challenge/v2/updateProgress', + 'POST /api/consumables/v1/consume', + 'POST /api/gamerewards/v1/request', + 'POST /api/objectives/v1/cleargroup', + 'POST /api/settings/v2/set', + 'POST /api/storefronts/v2/buyItem', + ]) + + // Every operation carries a summary — a path present but undescribed is not + // documentation. + for (const ops of Object.values(spec.paths)) { + for (const op of Object.values(ops)) expect(op.summary).toBeTruthy() + } + }) })