support for 202507 endpoints (#37)

* [auth][api] accept the 20250424.01 client

* [2025] unstable

* 20250718.0

* correct one this time

* stubs

* more stubs

* more stubs

* [lists] add worker

* [ai] route stubs

* [api] player photo setting

* [econ] add roomEconConfig route

* [infra] update worker generators

* [worker] add cards/moderation/platformnotification workers

* [lists] updates to some endpoints

* [clubs] stub out announcement endpoint, for now

* [econ] stub out season endpoints for now

* [chat] apps/chat stub out party endpoint not sure the shape yet

* [api] stub out statsig and lockeditems

* [doc] new services

* [lists] stub the bulk endpoint

* [datacollection] add placeholder service until we can kill it

* [api] set gifting to lvl5

* update lock

* [cdn] enable cache

* [match] matchmake v2

* [lists] stub some lists

* [ai] stubs

* [rooms] new subroom save endpoint

* [econ] add bulk purchase endpoint

* [discovery] update featured creator to 1 for fun

* [api] add photo settings flag

* [chat] fixup chat permissions (sorta)

* [auth] restrictions endpoint

* [rooms] contributed endpoint

* [api] fix outfit endpoint

* [discovery] attempt to fix store

* [chat] privacy endpoints

* [api] cheered images

* [rooms] add xp endpoint (disbaled)

* [rooms] add xp endpoint (disabled)

* update images-db for cheers

* [rooms] add autocomplete endpoint

* [cdn/img] increase cache ttl for statics

* [api] bulk route for images

* [accounts] add banner image

* [api] add misc missing endpoints

* [discovery] remove AI tab

* [platformnotifications] stub some endpoints

* [lists] add some more lists

* [rooms] additional endpoints

* [chat] stub a few privacy endpoints

* [econ] stub some endpoints

* misc db fixes

* [api] tweak shape for images v6

* [rooms] dont show trending RROs
This commit is contained in:
devin
2026-08-18 23:07:24 -04:00
committed by Devin Zuczek
parent 66c09806f9
commit 178d3b5b0e
162 changed files with 114930 additions and 469 deletions
+953 -56
View File
@@ -7,11 +7,13 @@ import {
consumeGift,
createGift,
getGift,
getOutfits,
getPendingGifts,
grantInvention,
levelReward,
levelsReached,
ownsInvention,
setOutfit,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
@@ -29,6 +31,7 @@ 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'
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
import myProgress from '../static/my-progress.json'
import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
@@ -54,16 +57,22 @@ import {
grantConsumable,
} from './consumables-db'
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
import { getInventory, grantItem } from './inventory-db'
import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db'
import {
AUTHED,
AvatarItemV4Dto,
AvatarV2Dto,
BalanceEntry,
BulkPurchaseRequest,
BulkPurchaseResponse,
BuyInventionResponse,
BuyItemRequest,
BuyItemResponse,
ChallengeProgressRequest,
ChallengeProgressResponse,
ChecklistCompleteResponse,
ChecklistEntry,
CompleteChecklistRequest,
ConsumeConsumableRequest,
ConsumeEnvelope,
ConsumeGiftRequest,
@@ -72,12 +81,17 @@ import {
ErrorResponse,
form,
GameRewardRequest,
InfluencerIdsResponse,
json,
JsonArray,
jsonBody,
JsonObject,
MakerAiFreeTrialEligibilityResponse,
OpaqueJsonBody,
OPTIONAL_AUTHED,
ReferralProgressResponse,
RoomEconConfig,
RRPlusSignUpBonus,
SaveOutfitRequest,
SaveOutfitV4Response,
SubscriptionResponse,
@@ -85,11 +99,10 @@ import {
UpdateObjectiveRequest,
UpdateObjectiveResponse,
} from './openapi'
import { getOutfits, setOutfit } from './outfit-db'
import { claimReward } from './reward-db'
import type { Context } from 'hono'
import type { GiftContent, Progression, StoredGift, XpGrant } from '@repo/domain'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type {
BalanceResponsePayload,
PurchaseBalanceModificationPayload,
@@ -99,7 +112,6 @@ import type { ConsumeResult } from './consumables-db'
import type { App } from './context'
import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db'
import type { Outfit } from './outfit-db'
/**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
@@ -474,6 +486,20 @@ interface GiftRequest {
GiftContext?: number
}
/**
* Read a storefront catalog (`sf{type}.json`) from the ASSETS binding. Null when there is
* no such storefront.
*
* Separate from {@link findStoreItem} so a caller resolving SEVERAL items from one
* storefront reads (and parses) it once: sf3 alone is over a thousand items, and a bulk
* purchase carries up to `BULK_PURCHASE_CAP` lines.
*/
async function loadStorefront(c: Context<App>, storefrontType: number): Promise<Storefront | null> {
const res = await c.env.ASSETS.fetch(new URL(`/sf${storefrontType}.json`, c.req.url))
if (!res.ok) return null
return (await res.json()) as Storefront
}
/**
* Look up a store item by (storefront type, purchasable item id), reading the catalog
* from the ASSETS binding (`sf{type}.json`). Returns null when there is no such
@@ -484,9 +510,8 @@ async function findStoreItem(
storefrontType: number,
purchasableItemId: number
): Promise<StoreItem | null> {
const res = await c.env.ASSETS.fetch(new URL(`/sf${storefrontType}.json`, c.req.url))
if (!res.ok) return null
const storefront = (await res.json()) as Storefront
const storefront = await loadStorefront(c, storefrontType)
if (storefront === null) return null
return storefront.StoreItems.find((it) => it.PurchasableItemId === purchasableItemId) ?? null
}
@@ -647,10 +672,8 @@ const ROLL_STOREFRONT_TYPE = 3
/** Every item in the roll catalog, or `[]` if it can't be read (a roll then yields nothing). */
async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
const res = await c.env.ASSETS.fetch(new URL(`/sf${ROLL_STOREFRONT_TYPE}.json`, c.req.url))
if (!res.ok) return []
const storefront = (await res.json()) as Storefront
return storefront.StoreItems
const storefront = await loadStorefront(c, ROLL_STOREFRONT_TYPE)
return storefront?.StoreItems ?? []
}
/**
@@ -747,12 +770,31 @@ async function rollQueryDrop(
* A gift box that was just created, and the drop it ended up holding. The drop is the
* RESOLVED one — what a query drop rolled, not the box that promised it — so a caller
* announcing the gift names the item the player actually won.
*
* `id` is 0 when no box was created (`skipGiftBox`) — the drop was still granted.
*/
interface GrantedGift {
id: number
drop: StoreGiftDrop
}
/** How a drop is handed over: how a query one rolls, plus how it is wrapped. */
interface GrantOptions extends RollOptions {
/**
* How many of the drop to hand over in ONE box — a bulk line's `DuplicateItemCount`.
* Only a consumable stacks, so this multiplies the consumable count and nothing else;
* callers must refuse a count above 1 for anything owned once. Defaults to 1.
*/
copies?: number
/**
* Grant the drop without creating the gift box that renders it — what a bulk purchase's
* `BypassGiftPackages` asks for. Ownership never depended on the box (it is granted here,
* not when the box is opened), so this only skips the "open it" moment; a caller setting
* it is saying its own UI announces the items.
*/
skipGiftBox?: boolean
}
/**
* Pick a random consumable from the roll catalog — the reward the published level table
* hands out for the early levels.
@@ -791,7 +833,7 @@ async function grantGiftDrop(
accountId: number,
drop: StoreGiftDrop,
message: string,
options: RollOptions = {}
options: GrantOptions = {}
): Promise<GrantedGift> {
let giftDrop = drop
if (drop.IsQuery === true) {
@@ -819,7 +861,7 @@ async function grantGiftDrop(
}
const isConsumable =
typeof giftDrop.ConsumableItemDesc === 'string' && giftDrop.ConsumableItemDesc !== ''
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT * (options.copies ?? 1) : 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
@@ -833,6 +875,7 @@ async function grantGiftDrop(
consumableCount
)
}
if (options.skipGiftBox === true) return { id: 0, drop: giftDrop }
const { id } = await createGift(
db,
accountId,
@@ -841,6 +884,282 @@ async function grantGiftDrop(
return { id, drop: giftDrop }
}
/**
* One `BalanceUpdates[].Data` entry: the gift-drop a player RECEIVED, as both purchase
* endpoints report it. `granted.drop` is the resolved drop — the rolled prize for a query
* box, not the box that promised it — or a query purchase answers with every item field
* empty and the client draws an empty box.
*
* It carries no FriendlyName or consumable count (the count is a getUnlocked concept; each
* box is one instance). `giftContext` is the requesting `Gift` block's, when it named one;
* otherwise the drop's own.
*/
function toBalanceUpdateData(
granted: GrantedGift,
fromPlayerId: number,
message: string,
giftContext: number | null
): Record<string, unknown> {
const drop = granted.drop
return {
Id: granted.id,
FromPlayerId: fromPlayerId,
ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemDesc: drop.AvatarItemDesc,
AvatarItemType: drop.AvatarItemType ?? 0,
EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: drop.EquipmentModificationGuid,
CurrencyType: drop.CurrencyType,
Currency: drop.Currency,
Xp: drop.Xp ?? 0,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
GiftContext: giftContext ?? drop.Context,
GiftRarity: drop.Rarity,
Message: message,
}
}
/**
* `Econ.BulkPurchaseCap` — the most copies one bulk purchase may carry. The client reads
* the same 200 out of its game config (apps/api/static/gameconfigs-v1-all.json) and caps
* the bag with it, so this is the server side of a limit the client already knows; a
* request over it is a client that ignored its own config, not a bigger shopping trip.
*/
const BULK_PURCHASE_CAP = 200
/**
* `UpdateResponse` — the outcome of ONE `BalanceUpdates` entry, from the client's own enum.
* This is where a bulk purchase reports per line: the bag answers one entry per REQUESTED
* item, and `AllowPartialSuccess` is what lets some of them come back non-OK while the
* envelope's `Success` stays true. (buyItem's single `UpdateResponse: 0` is this same OK.)
*
* The members this server can produce are the ones a catalog purchase can fail on;
* `TooManyRequests`, `PlayerNotEligible`, `RequestCannotBeRefunded` and `PlayerNotApproved`
* belong to rate limiting, entitlements and refunds, none of which exist here. `AlreadyOwned`
* is deliberately unused too: buyItem lets a player re-buy an item they own (the grant
* upserts), and one purchase path refusing what the other allows would be worse than either.
*/
const UpdateResponse = {
OK: 0,
TooManyRequests: 1,
NotEnoughCredit: 2,
AlreadyOwned: 3,
NoItemAvailable: 4,
CouponNotApplicable: 5,
RequestedPriceDoesNotMatch: 6,
RequestedAmountNotAllowed: 7,
PlayerNotEligible: 8,
RequestCannotBeRefunded: 9,
PlayerNotApproved: 10,
} as const
/** The discriminated item id a bulk-purchase line names its item by. */
interface PurchaseMethodId {
Type: number
NumberId: number | null
Guid: string | null
}
/** One line of a `POST /api/items/bulkpurchase` body. */
interface PurchaseItemRequest {
ItemPurchaseMethodId?: Partial<PurchaseMethodId> | null
RequestedPrice?: number
Gift?: GiftRequest | null
CouponConsumablePlayerMappingId?: number | null
DuplicateItemCount?: number
}
/**
* A line that could not be bought: the `UpdateResponse` its entry carries, and the message
* that fills the envelope's single `Error` when the bag as a whole is refused.
*/
interface BulkLineFailure {
method: PurchaseMethodId
code: number
error: string
}
/** A line that resolved to something buyable, with the catalog's own price. */
interface BulkPurchaseLine {
method: PurchaseMethodId
item: StoreItem
/** The UNIT price from the catalog — `count` copies cost `price * count`. */
price: number
count: number
gift: GiftRequest | null
}
/**
* One `BalanceUpdates[].Data` — what a single requested item turned into. Unlike buyItem's,
* it NAMES the purchase rather than describing the drop: the client already has the catalog
* entry for `PurchasableItemId`, so the only thing it can't reconstruct is the box.
*
* `CustomAvatarItem` is the UGC counterpart of `PurchasableItemId`, and both it and
* `GiftPackage` are null on a line that didn't sell. Nothing here fills `CustomAvatarItem`:
* no catalog we serve sells guid-keyed items.
*/
interface BulkPurchaseData {
GiftPackage: Record<string, unknown> | null
PurchasableItemId: number | null
CustomAvatarItem: null
}
/** `ItemPurchaseMethodId.Type` for a numeric (storefront `PurchasableItemId`) id. */
const PURCHASE_METHOD_NUMBER_ID = 0
/**
* The gift box as `GiftPackage` carries it — the same DTO family as buyItem's
* `BalanceUpdates[].Data` entry, but with the four keys that shape doesn't carry
* (`PlayerId`, `CustomAvatarItemId`, `Signature`, `IsSignatureValid`) and without its
* `Level`. Twenty keys, in the order the client's own member list names them.
*
* `Platform`/`PlatformsToSpawnOn` are the platform MASK (-1, all) — the balance bucket is
* the separate `BalanceType` beside them, unlike the envelope's `Value.Platform`, which IS
* a renamed `BalanceType`. `Signature` is null and `IsSignatureValid` false: a box the
* server minted was never signed for peer-to-peer transfer.
*/
function toGiftPackage(
granted: GrantedGift,
playerId: number,
fromPlayerId: number,
message: string,
giftContext: number | null
): Record<string, unknown> {
const drop = granted.drop
return {
Id: granted.id,
PlayerId: playerId,
FromPlayerId: fromPlayerId,
ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemType: drop.AvatarItemType ?? 0,
AvatarItemDesc: drop.AvatarItemDesc,
CustomAvatarItemId: null,
EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: drop.EquipmentModificationGuid,
CurrencyType: drop.CurrencyType,
Currency: drop.Currency,
Xp: drop.Xp ?? 0,
GiftContext: giftContext ?? drop.Context,
GiftRarity: drop.Rarity,
Message: message,
Signature: null,
IsSignatureValid: false,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
}
}
/**
* Normalise the id a line named its item by. A line that sent no id at all still resolves
* to something, so this never returns null — the checks in {@link resolveBulkLine} are what
* reject it.
*/
function toPurchaseMethodId(raw: Partial<PurchaseMethodId> | null | undefined): PurchaseMethodId {
const id = typeof raw === 'object' && raw !== null ? raw : {}
return {
Type: Number.isInteger(id.Type) ? (id.Type as number) : PURCHASE_METHOD_NUMBER_ID,
NumberId: Number.isInteger(id.NumberId) ? (id.NumberId as number) : null,
Guid: typeof id.Guid === 'string' ? id.Guid : null,
}
}
/**
* Resolve one line against the bag's catalog: what it wants, how many, and at what price.
* Returns the failure — with the `UpdateResponse` its entry will carry — instead when the
* line can't be bought.
*
* Pure — the catalog is passed in — so the whole bag resolves from ONE storefront read.
* The price check is buyItem's, per line: `RequestedPrice` is the UNIT price the client
* rendered, and a mismatch means the catalog moved under a stale client rather than that
* the player agreed to today's price.
*/
function resolveBulkLine(
line: PurchaseItemRequest,
storefront: Storefront | null,
currencyType: number
): BulkPurchaseLine | BulkLineFailure {
const method = toPurchaseMethodId(line.ItemPurchaseMethodId)
// Guid-keyed ids name UGC / custom avatar items, which no catalog here sells. Failing the
// line (rather than the request) is what lets a bag of ordinary items still go through.
if (method.Type !== PURCHASE_METHOD_NUMBER_ID || method.NumberId === null) {
return {
method,
code: UpdateResponse.NoItemAvailable,
error: 'Only numeric storefront item ids can be bought',
}
}
// Nothing issues coupons, so a line claiming one would otherwise be charged full price
// for a discount it thinks it applied.
if (
line.CouponConsumablePlayerMappingId !== null &&
line.CouponConsumablePlayerMappingId !== undefined
) {
return {
method,
code: UpdateResponse.CouponNotApplicable,
error: 'Coupons are not supported',
}
}
const count = line.DuplicateItemCount ?? 1
if (!Number.isInteger(count) || count < 1) {
return {
method,
code: UpdateResponse.RequestedAmountNotAllowed,
error: 'DuplicateItemCount must be a positive integer',
}
}
if (storefront === null) {
return { method, code: UpdateResponse.NoItemAvailable, error: 'No such storefront' }
}
const item = storefront.StoreItems.find((it) => it.PurchasableItemId === method.NumberId)
if (item === undefined) {
return { method, code: UpdateResponse.NoItemAvailable, error: 'Item not found' }
}
// Only a consumable stacks. An avatar item or an equipment skin is owned once, so a
// second copy would grant nothing while charging for it — and the bag answers ONE entry
// (one box) per requested item, which is the same statement from the wire's side.
if (count > 1 && item.GiftDrop.ConsumableItemDesc === '') {
return {
method,
code: UpdateResponse.RequestedAmountNotAllowed,
error: 'This item can only be bought once per line',
}
}
const price = item.Prices.find((p) => p.CurrencyType === currencyType)
if (price === undefined) {
return {
method,
code: UpdateResponse.NoItemAvailable,
error: 'Currency type not available for this item',
}
}
if (!Number.isInteger(line.RequestedPrice)) {
return {
method,
code: UpdateResponse.RequestedPriceDoesNotMatch,
error: 'RequestedPrice is required',
}
}
if (line.RequestedPrice !== price.Price) {
return {
method,
code: UpdateResponse.RequestedPriceDoesNotMatch,
error: 'Price has changed',
}
}
const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null
return { method, item, price: price.Price, count, gift }
}
/** Whether a resolved line is buyable or is already a failure. */
function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is BulkPurchaseLine {
return 'item' in resolved
}
/**
* XP paid for a claimed game reward. One flat amount for every reward type, matching the
* one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the
@@ -1160,6 +1479,22 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
}
}
/**
* The default NUX checklist for a brand-new account. `Objective` is an `ObjectiveType`
* ordinal (from the client's `ProgressionManager`) that the client matches its own
* progress events against — the names below are what those ordinals mean.
*/
const DEFAULT_CHECKLIST = [
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 }, // SaveOutfitSlot
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 }, // VisitACustomRoom
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 }, // AddAFriend
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 }, // GoToRecCenter
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 }, // CheerAPlayer
]
/** The `UpdateResponse` context a checklist reward is reported under. */
const CHECKLIST_REWARD_CONTEXT = 303
/**
* 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
@@ -1201,11 +1536,12 @@ const app = new Hono<App>({ strict: false })
(c) => c.json(defaultAvatarItems)
)
// Default base avatar items — empty stub for now. No auth.
// The base items UGC clothing is built on top of — served from bundled static JSON,
// separate from the `defaultunlocked` catalog. No auth.
.get(
'/api/avatar/v1/defaultbaseavataritems',
listRoute('Default base avatar items', 'Empty stub for now'),
(c) => c.json([])
listRoute('Default base avatar items', 'The bundled base items UGC clothing builds on'),
(c) => c.json(defaultBaseAvatarItems)
)
// The player's avatar items — the items they've bought (from `buyItem`, stored in
@@ -1219,10 +1555,12 @@ const app = new Hono<App>({ strict: false })
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.',
'Both sources are projected into the camelCase v4 DTO — the sibling item endpoints',
'(`defaultunlocked`, `defaultbaseavataritems`) serve their records raw instead.',
].join(' '),
security: AUTHED,
responses: {
200: json(JsonArray, 'Owned items followed by the default catalog'),
200: json(AvatarItemV4Dto.array(), 'Owned items followed by the default catalog'),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -1230,7 +1568,7 @@ const app = new Hono<App>({ strict: false })
const id = await authedId(c)
if (id === null) return unauthorized(c)
const owned = await getInventory(c.env.DB, id)
return c.json([...owned, ...defaultAvatarItems])
return c.json([...owned, ...defaultAvatarItems].map(toAvatarItemV4))
}
)
@@ -1376,11 +1714,76 @@ const app = new Hono<App>({ strict: false })
}
)
// 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.
// NUX checklist — the client fetches this on the econ host during load, on either
// version path. A 404 here can abort the load orchestration before matchmake. We
// serve the default brand-new-account list to everyone: nothing records per-player
// checklist progress yet, so it never shrinks as steps are done.
.on(
'GET',
['/api/checklist/v1/current', '/api/checklist/v2/current'],
describeRoute({
tags: ['Econ'],
summary: 'NUX checklist',
description:
'The new-user checklist, as the default brand-new-account list — nothing records ' +
'per-player progress yet, so the same rows come back however much the player has ' +
'done. `Objective` is an `ObjectiveType` ordinal the client matches its own ' +
'progress events against. v1 and v2 serve the same list.',
security: AUTHED,
responses: {
200: json(ChecklistEntry.array(), 'The checklist rows, in `Order`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(DEFAULT_CHECKLIST)
}
)
// Mark a checklist row done. [Authorize]. Stubbed: there is no objective-progress
// table to record the completion in, and no reward ledger to make the 25-token grant
// once-only — without one, re-posting the same row would mint tokens indefinitely, so
// we grant nothing and report a change of 0. The envelope is still the balance-update
// shape the client parses, so the flow completes instead of erroring.
.on(
'POST',
['/api/checklist/v1/complete', '/api/checklist/v2/complete'],
describeRoute({
tags: ['Econ'],
summary: 'Complete a checklist row (stub)',
description:
'Marks a NUX checklist row done. Stubbed: nothing records the completion (no ' +
'objective-progress table) and nothing is granted — a reward is worth 25 XP and 25 ' +
'tokens, but making that once-only needs a ledger we do not have, and without one ' +
're-posting the same row would mint tokens indefinitely. The response is still the ' +
'balance-update envelope, with `Balance` (the change) 0. v1 and v2 behave alike.',
security: AUTHED,
requestBody: jsonBody(CompleteChecklistRequest, 'Which row was completed — `{ ItemIndex }`'),
responses: {
200: json(ChecklistCompleteResponse, 'The balance-update envelope, granting nothing'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The body names the row (`{ ItemIndex: 1 }`, or `Id` as a fallback) — read only
// once there is somewhere to record it.
return c.json({
BalanceUpdates: [{ UpdateResponse: CHECKLIST_REWARD_CONTEXT, Data: [] }],
Balance: 0,
CurrencyType: CurrencyType.RecCenterTokens,
BalanceType: -2,
})
}
)
// The caller's item wishlist. [Authorize]; empty — nothing stores wishlists yet.
.get(
'/api/checklist/v1/current',
listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true),
'/api/itemWishlists/v1/wishlist/me',
listRoute('The players item wishlist', 'Empty for now', true),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
@@ -1388,10 +1791,39 @@ const app = new Hono<App>({ strict: false })
}
)
// The player's item wishlist. [Authorize]; empty without a DB binding.
// Another player's item wishlist, by account id — what the client reads to show what
// somebody else is hoping for (and to mark items in the store as already wished for).
// Empty like `/me`: nothing stores wishlists, so there is nothing to show for anyone.
//
// Registered AFTER `/me` so that path stays its own route rather than being read as an
// account id — the pattern here is digits-only, so it could not swallow `me`, but the
// order also says which is the special case.
.get(
'/api/itemWishlists/v1/wishlist/me',
listRoute('The players item wishlist', 'Empty for now', true),
'/api/itemWishlists/v1/wishlist/:accountId{[0-9]+}',
describeRoute({
tags: ['Econ'],
summary: 'Another players item wishlist',
description: [
'The wishlist of the account named in the path, as a bare array. Empty for now —',
'nothing on this server stores wishlists, so every players is empty, and an empty',
'list is what the client renders as “nothing wished for” where a 404 would read as a',
'failed load.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'The account whose wishlist to read',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(JsonArray, 'That players wishlist — empty for now'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
@@ -1674,6 +2106,84 @@ const app = new Hono<App>({ strict: false })
c.json([])
)
// The room-economy surface the client asks for on entering a room: the room's own
// inventory/offers/gift-drop shops and the caller's slice of them. Nothing here is
// stored yet, so every one is an empty list — the client reads that as "this room
// sells nothing" and renders no shop, where a 404 stalls the room load instead.
//
// The `/player` and `purchaseCounts` variants are caller-scoped but deliberately
// unauthed, matching the `roomConsumable/.../me` stub above: an empty list is the
// same answer for every caller, so there's nothing to protect until something
// writes here. Gate them when they start returning real data.
.get(
'/econ/roomInventory/room/:roomId',
listRoute('A rooms inventory', 'Empty stub so the client doesnt 404'),
(c) => c.json([])
)
.get(
'/econ/roomInventory/room/:roomId/player',
listRoute('The callers inventory in a room', 'Empty stub'),
(c) => c.json([])
)
.get(
'/econ/roomInventoryItemTags/room/:roomId',
listRoute('A rooms inventory item tags', 'Empty stub'),
(c) => c.json([])
)
.get('/econ/roomOffer/room/:roomId', listRoute('A rooms offers', 'Empty stub'), (c) =>
c.json([])
)
.get(
'/econ/roomOffer/room/:roomId/purchaseCounts',
listRoute('Per-offer purchase counts for a room', 'Empty stub'),
(c) => c.json([])
)
.get(
'/econ/roomGiftDropShops/room/:roomId',
listRoute('A rooms gift-drop shops', 'Empty stub'),
(c) => c.json([])
)
// A room's economy config, asked for alongside the room-economy lists above. The only
// setting is whether the room's shop UI groups its offers into sorting tabs; nothing
// stores per-room config yet, so every room answers false and the client renders one
// flat list. [Authorize] — unlike the empty-list stubs above this is a real answer the
// client acts on, so it takes the same token the rest of the econ surface does.
.get(
'/econ/roomEconConfig/:roomId',
describeRoute({
tags: ['Econ'],
summary: 'A rooms economy config',
description: [
'Whether the rooms shop groups offers into sorting tabs. No per-room config is',
'stored, so this is always false; the `RoomId` is echoed from the path.',
].join(' '),
security: AUTHED,
responses: {
200: json(RoomEconConfig, 'The rooms economy config'),
400: { description: 'Non-numeric roomId (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
if (Number.isNaN(roomId)) return c.body(null, 400)
return c.json({ RoomId: roomId, EnableSortingTabs: false })
}
)
// The UGC items a room sells (the creator-made things on sale inside it). Same empty
// stub as the room-economy routes above and asked for on the same room load: nothing
// stores room UGC purchasables yet, and an empty list reads as "this room sells
// nothing" where a 404 stalls the load.
.get(
'/api/ugcPurchasables/v1/items/room/:roomId',
listRoute('A rooms UGC purchasables', 'Empty stub so the client doesnt 404'),
(c) => c.json([])
)
// 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.
@@ -1923,12 +2433,7 @@ const app = new Hono<App>({ strict: false })
// `granted.drop` is what the roll landed on — the response has to describe THAT, not
// the box, or a query purchase answers with every item field empty and the client
// draws an empty box.
const { id: giftId, drop: granted } = await grantGiftDrop(
c,
receiverId,
item.GiftDrop,
message
)
const granted = await grantGiftDrop(c, receiverId, item.GiftDrop, message)
// Push the spend to the buyer (`id` — the caller is who was charged) so their client
// updates without waiting for a `GET /balance` re-fetch. StorefrontBalancePurchase
@@ -1940,36 +2445,18 @@ const app = new Hono<App>({ strict: false })
// 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 — the rolled item for a query box, the
// bought drop otherwise — and it carries no FriendlyName or consumable count (the
// count is a getUnlocked concept; each box is one instance).
// `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms).
return c.json({
BalanceUpdates: [
{
UpdateResponse: 0,
Data: [
{
Id: giftId,
FromPlayerId: fromPlayerId,
ConsumableItemDesc: granted.ConsumableItemDesc,
AvatarItemDesc: granted.AvatarItemDesc,
AvatarItemType: granted.AvatarItemType ?? 0,
EquipmentPrefabName: granted.EquipmentPrefabName,
EquipmentModificationGuid: granted.EquipmentModificationGuid,
CurrencyType: granted.CurrencyType,
Currency: granted.Currency,
Xp: granted.Xp ?? 0,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
GiftContext: Number.isInteger(gift?.GiftContext)
? (gift?.GiftContext as number)
: granted.Context,
GiftRarity: granted.Rarity,
Message: message,
},
toBalanceUpdateData(
granted,
fromPlayerId,
message,
Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
),
],
},
],
@@ -1980,6 +2467,229 @@ const app = new Hono<App>({ strict: false })
}
)
// Check out a whole shopping bag. [Authorize]. The client posts every line it has in the
// bag — an item id, `DuplicateItemCount` copies, the unit price it rendered and an
// optional Gift — plus the ONE storefront and the ONE currency they all share.
//
// The whole bag is debited in ONE `spendCurrency` call. Charging line by line would let a
// bag half-succeed on a race with another spend, and would push a balance frame per line.
//
// The response is NOT buyItem's envelope. It is `{ Success, Error, error_id, Value }`
// (`error_id` lowercase — the client renames that one member; the other three are
// PascalCase), and `Value` is a BalanceUpdateResponse: the RESULTING `{ Balance,
// CurrencyType, Platform }` — `Platform` there being a renamed `BalanceType`, i.e. the
// bucket, not a store — plus ONE `BalanceUpdates` entry per REQUESTED item.
//
// Per-line reporting is that entry's `UpdateResponse`: a line that didn't sell comes back
// non-OK with a null `GiftPackage`, and `AllowPartialSuccess` is what lets those sit
// beside successful ones while `Success` stays true. Without it, one bad line refuses the
// whole bag — `Success: false`, the reason in `Error`, a null `Value`, nothing charged.
.post(
'/api/items/bulkpurchase',
describeRoute({
tags: ['Storefront'],
summary: 'Buy a bag of storefront items',
description: [
'Resolves every line against the bags storefront catalog (one read for the whole',
'bag), confirms each lines `RequestedPrice` still matches, debits the total in ONE',
'atomic spend, grants what sold, and answers the `{ Success, Error, error_id, Value }`',
'envelope. `Value.Balance` is the RESULTING total (not buyItems change) in the',
'`Platform` bucket named beside it, and `BalanceUpdates` carries one entry per',
'REQUESTED item, each with its own `UpdateResponse`. `AllowPartialSuccess` lets some',
'of those be non-OK while `Success` stays true; without it a single bad line refuses',
'the bag and nothing is charged.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(BulkPurchaseRequest, 'The bag: its lines, storefront and currency'),
responses: {
200: json(BulkPurchaseResponse, 'The bags result, or `Success: false` if nothing sold'),
400: json(BulkPurchaseResponse, 'A request that could not be evaluated at all'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// Every refusal answers the same envelope, so a client that only knows how to parse
// this shape never has to special-case one. A null `Value` is legal here (the client's
// validator only cascades into a non-null one), and it is the honest answer: nothing
// was bought, so there is no balance to report and nothing to render.
const refuse = (error: string, status: 200 | 400 = 200) =>
c.json({ Success: false, Error: error, error_id: null, Value: null }, status)
const body = (await c.req.json().catch(() => null)) as {
PurchaseItemRequests?: PurchaseItemRequest[]
StorefrontType?: number
CurrencyType?: number
BypassGiftPackages?: boolean
AllowPartialSuccess?: boolean
ShoppingBagId?: string | number | null
} | null
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return refuse('Invalid request body', 400)
}
const lines = body.PurchaseItemRequests
if (!Array.isArray(lines) || lines.length === 0) {
return refuse('PurchaseItemRequests must be a non-empty array', 400)
}
const storefrontType = body.StorefrontType
const currencyType = body.CurrencyType
if (!Number.isInteger(storefrontType) || !Number.isInteger(currencyType)) {
return refuse('StorefrontType and CurrencyType are required', 400)
}
// The bag's currency must be an account balance we can debit, exactly as buyItem's.
if (!isSpendable(currencyType as number)) {
return refuse('Currency type is not spendable', 400)
}
const allowPartial = body.AllowPartialSuccess === true
const skipGiftBox = body.BypassGiftPackages === true
// One catalog read for the bag; every line resolves against it in memory.
const storefront = await loadStorefront(c, storefrontType as number)
const resolved = lines.map((line) =>
resolveBulkLine(line, storefront, currencyType as number)
)
const buyable = resolved.filter(isBulkLine)
const copies = buyable.reduce((n, line) => n + line.count, 0)
if (copies > BULK_PURCHASE_CAP) {
return refuse(`A bulk purchase is capped at ${BULK_PURCHASE_CAP} items`, 400)
}
// All-or-nothing: one unbuyable line stops the bag before anything is charged, and the
// client is told why by the first thing that was wrong with it.
const firstFailure = resolved.find((line): line is BulkLineFailure => !isBulkLine(line))
if (!allowPartial && firstFailure !== undefined) return refuse(firstFailure.error)
// Decide what the balance covers BEFORE spending: lines are taken in request order
// while they fit, so a bag that overruns still buys the items the player put in first.
// The read is only for choosing; the single spend below is what actually settles, and
// its `amount >= ?` guard is what makes that safe against a concurrent spend.
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
const balance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
const affordable: BulkPurchaseLine[] = []
let total = 0
for (const line of buyable) {
const cost = line.price * line.count
if (total + cost > balance) continue
total += cost
affordable.push(line)
}
const bought = new Set(affordable)
// Without partial success an unaffordable line fails the whole bag — including the
// lines that would have fitted, since the player asked for all of it or none.
if (!allowPartial && affordable.length !== buyable.length) {
return refuse('Insufficient balance')
}
// Nothing sold at all: there is no purchase to report, so this is a refusal rather
// than a `Success: true` bag full of non-OK entries.
if (affordable.length === 0) {
return refuse(firstFailure?.error ?? 'Insufficient balance')
}
// One atomic debit for the whole bag. A false return means another request spent the
// tokens between the read above and here, so nothing is granted and nothing changed.
if (
total > 0 &&
!(await spendCurrency(c.env.DB, id, currencyType as number, total, startingTokens))
) {
return refuse('Insufficient balance')
}
// A query drop (a loot box) rolls against sf3, the big catalog. Read it ONCE for the
// whole bag and only when a line actually holds one — a bag of ordinary items should
// not pull a thousand-item catalog in to grant them.
const rollCatalog = affordable.some((line) => line.item.GiftDrop.IsQuery === true)
? await loadRollCatalog(c)
: undefined
// Grant what sold, keeping each line's box so the entry built below can carry it.
const packages = new Map<BulkPurchaseLine, Record<string, unknown> | null>()
for (const line of affordable) {
// Same routing as buyItem: a Gift block sends the item (and its box) to another
// player while the caller pays, a named gift shows the sender, and a self-buy or an
// anonymous gift is attributed to the "Coach" system account.
const gift = line.gift
const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
// One box per requested item, holding all `count` copies — the wire has one
// `GiftPackage` per entry, and only a consumable can be asked for more than once
// (`resolveBulkLine` refuses a bigger count on anything owned once).
const granted = await grantGiftDrop(c, receiverId, line.item.GiftDrop, message, {
rollCatalog,
skipGiftBox,
copies: line.count,
})
packages.set(
line,
// Null under `BypassGiftPackages`, which is the flag asking for exactly that —
// the item is granted either way.
skipGiftBox
? null
: toGiftPackage(
granted,
receiverId,
fromPlayerId,
message,
Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
)
)
}
// One entry per REQUESTED item, in request order — the failures included, which is
// where a partial bag says what it left behind.
const updates = resolved.map((line) => {
if (!isBulkLine(line)) {
return {
UpdateResponse: line.code,
Data: {
GiftPackage: null,
PurchasableItemId: line.method.NumberId,
CustomAvatarItem: null,
} satisfies BulkPurchaseData,
}
}
return {
UpdateResponse: bought.has(line) ? UpdateResponse.OK : UpdateResponse.NotEnoughCredit,
Data: {
GiftPackage: packages.get(line) ?? null,
PurchasableItemId: line.method.NumberId,
CustomAvatarItem: null,
} satisfies BulkPurchaseData,
}
})
// One frame for the whole bag, not one per line: it SETS the account-wide bucket to the
// resulting total read back from D1, so it agrees with the `Value.Balance` below and
// with a `GET /balance` re-fetch instead of compounding — see the frame rule above
// pushBalanceUpdate. Nothing moved on a free bag, so nothing is sent and the balance
// read for the affordability check above still stands.
let newBalance = balance
if (total > 0) {
newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
await pushBalancePurchase(c, id, currencyType as number, -total, newBalance)
}
return c.json({
Success: true,
Error: null,
error_id: null,
Value: {
// The RESULTING total, unlike buyItem's change — and the bucket it belongs to.
// `Platform` here is the client's `BalanceType` under a [DataMember] rename. A
// capture from the reference server says 4 (RecNetPurchased) because it kept a
// wallet per store; this server keeps ONE account-wide bucket, and the client SUMS
// its buckets, so naming any other platform invents a second balance beside the
// real one. See the frame rule above pushBalanceUpdate.
Balance: newBalance,
CurrencyType: currencyType,
Platform: ALL_PLATFORMS,
BalanceUpdates: updates,
},
})
}
)
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
//
@@ -2342,6 +3052,34 @@ const app = new Hono<App>({ strict: false })
c.json([])
)
// The Rec Room Plus sign-up bonus: which bonus is running and the token price window
// the free items are drawn from. Fixed numbers, the same for every caller.
//
// Unauthenticated, like the subscription lookup below and for the same reason: the
// client reads this while putting the RR+ page together, nothing in the answer is
// per-account, and a 401 would only be a way for that load to stall. (The `api` copy of
// this path does validate a token, mirroring the reference server.)
.get(
'/api/CampusCard/v1/SignUpBonus',
describeRoute({
tags: ['Econ'],
summary: 'Rec Room Plus sign-up bonus',
description: [
'The bonus a player gets for taking out Rec Room Plus: which bonus is running',
'(`RRPlusSignUpBonusId`) and the token price window the free items are picked from.',
'Fixed values — nothing here is per-account or stored, so no auth is required and',
'every caller gets the same three numbers.',
].join(' '),
responses: { 200: json(RRPlusSignUpBonus, 'The running sign-up bonus') },
}),
(c) =>
c.json({
RRPlusSignUpBonusId: 3,
MinFreeItemsPrice: 6000,
MaxFreeItemsPrice: 10000,
})
)
// Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to
// buy one from, so the `developer` role stands in for a paid subscription: a developer
// reports an active Gold year, everyone else reports none. Nothing is stored — see
@@ -2380,6 +3118,165 @@ const app = new Hono<App>({ strict: false })
}
)
// The subscription seasons running right now (the RR+ seasonal reward tracks). Nothing
// here runs a season, so this is an empty-list stub — the client reads it as "no season
// in progress" and skips the seasonal UI, where a 404 stalls the RR+ page load.
.get(
'/api/subscriptionseasons/v1/seasons/current',
listRoute('Current subscription seasons', 'Empty stub — no RR+ season is running'),
(c) => c.json([])
)
// Whether the caller can start a Maker AI free trial. Always false, mirroring the
// reference server: nothing here runs trials, and false is the answer that leaves the
// client's creation UI in its normal state rather than offering a trial that can't
// start. The body is a BARE JSON `false` — not an envelope, not `{ value: false }`.
//
// Auth-gated (401 on a missing or invalid token) even though the answer is the same for
// everyone, because the reference validates the token before answering and eligibility
// is a per-account question the moment anything does run trials.
.get(
'/api/makerai/checkfreetrialeligibility',
describeRoute({
tags: ['Econ'],
summary: 'Maker AI free-trial eligibility',
description: [
'Whether the caller can start a Maker AI free trial. Always `false` — nothing here',
'runs trials. The body is a bare JSON boolean, not an envelope.',
].join(' '),
security: AUTHED,
responses: {
200: json(MakerAiFreeTrialEligibilityResponse, 'Always `false`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(false)
}
)
// The caller's progress through the refer-a-friend rewards: how many of their referrals
// have been verified, and which rewards they have taken from that track.
//
// Nothing here runs a referral programme, so nobody has referred anybody: the count is 0
// and the reward list is empty. That is a real answer rather than a stub — it is what a
// player who has referred nobody sees — so the client draws an untouched track, which is
// exactly the state this server is in.
//
// The payload is NESTED under `value`, unlike econ's flat balance bodies.
.get(
'/api/incentivizedreferrals/progress',
describeRoute({
tags: ['Econ'],
summary: 'The callers referral-reward progress',
description: [
'How many of the callers referrals have been verified and which referral rewards they',
'have claimed, under a `{ success, value }` envelope. Always 0 and empty — no referral',
'programme runs here — which the client renders as an untouched reward track.',
].join(' '),
security: AUTHED,
responses: {
200: json(ReferralProgressResponse, 'The callers progress — always zero'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
success: true,
value: { ReferralsVerifiedCount: 0, PlayerReferralRewards: [] },
})
}
)
// Everyone in the influencer partner program, by account id — the list the client keeps
// so it can badge an influencer wherever they turn up, rather than asking per player.
//
// Empty: no programme runs here, so there is nobody to list. Note this is the LIST
// counterpart of the single-account check below, and the two answer very differently —
// that one 404s to say "not an influencer", this one is a 200 carrying an empty list,
// because "nobody is" is a complete answer to "who is?".
//
// `take` is accepted and ignored; there is nothing to page through.
.get(
'/api/influencerpartnerprogram/influencers',
describeRoute({
tags: ['Econ'],
summary: 'Every influencer in the partner program',
description: [
'The account ids in the influencer partner program, as `{ InfluencerIds }` — an object',
'around the list, not a bare array. Always empty here: no programme runs on this',
'server. `take` is accepted and ignored, there being nothing to page.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'take',
in: 'query',
required: false,
description: 'How many ids to return. Accepted and ignored.',
schema: { type: 'integer' },
},
],
responses: {
200: json(InfluencerIdsResponse, 'The influencer ids — always empty'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ InfluencerIds: [] })
}
)
// Whether the caller is in the influencer partner program. NOBODY is: this server runs
// no such program, and "not an influencer" is a 404 rather than a body saying so — the
// reference answers 404 with an EMPTY body typed `application/json`, which is what the
// client branches on. A 200 carrying null or `{}` is a different answer to it.
//
// Deliberately built by hand rather than through `c.notFound()`: the worker's not-found
// handler answers its own body, and this has to be empty with that content type.
//
// `accountId` is accepted and ignored — the reference binds it and never reads it, the
// answer being the same for everyone. The token is still validated first, so an
// unauthenticated caller gets 401 rather than the 404.
.get(
'/api/influencerpartnerprogram/influencer',
describeRoute({
tags: ['Econ'],
summary: 'The callers influencer partner program status',
description: [
'Always 404 with an EMPTY body typed `application/json` — this server runs no partner',
'program, and 404 is how the reference says “not an influencer”. `accountId` is',
'accepted and ignored; the answer is the same for every caller. Auth is checked first,',
'so a missing or invalid token is 401, not 404.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'accountId',
in: 'query',
required: false,
description: 'The account being asked about. Accepted and ignored.',
schema: { type: 'integer' },
},
],
responses: {
404: { description: 'Not in the partner program — always. Empty body' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.body('', 404, { 'Content-Type': 'application/json' })
}
)
// 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(