mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
182 lines
7.0 KiB
TypeScript
182 lines
7.0 KiB
TypeScript
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')
|