diff --git a/apps/api/package.json b/apps/api/package.json index a6262bb..400300b 100644 --- a/apps/api/package.json +++ b/apps/api/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/api/src/api.app.ts b/apps/api/src/api.app.ts index 762cf61..5e89927 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' @@ -52,4 +53,48 @@ const app = new Hono({ strict: false }) .route('/', roomRoutes) .route('/', imageRoutes) +// 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 api', + version: '1.0.0', + description: [ + 'The catch-all Game API for recflare, a private-server reimplementation of the Rec', + 'Room backend: everything the client calls that has not been split out into its own', + 'worker yet. Today that is config, the friend graph, inventions, saved photos,', + 'reputation and the assorted sinks the client hits while loading. Relationships,', + 'inventions and images are D1-backed; several endpoints are still stubs, noted per', + 'route.', + '', + 'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,', + 'equipment, consumables and objectives on `econ`) are already served there — the', + 'client calls that host and the copy here is a stub, which each route says.', + '', + 'The shapes 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://api.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/api/src/openapi.ts b/apps/api/src/openapi.ts new file mode 100644 index 0000000..2d64b79 --- /dev/null +++ b/apps/api/src/openapi.ts @@ -0,0 +1,528 @@ +import { resolver } from 'hono-openapi' +import { z } from 'zod' + +import type { OpenAPIV3_1 } from 'openapi-types' + +/** + * OpenAPI schemas for the api 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/econ 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: [] }] + +/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */ +export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject { + return { name, in: 'path', required: true, description, schema: { type: 'integer' } } +} + +/** A string path parameter. */ +export function stringParam(name: string, description: string): OpenAPIV3_1.ParameterObject { + return { name, in: 'path', required: true, description, schema: { type: 'string' } } +} + +/** An optional string query parameter. */ +export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject { + return { name, in: 'query', required: false, description, schema: { type: 'string' } } +} + +/** An optional integer query parameter (`skip` / `take` / `sort` / `filter`). */ +export function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject { + return { name, in: 'query', required: false, description, schema: { type: 'integer' } } +} + +/** The `skip`/`take` pair every paginated feed accepts. */ +export function pageParams(defaultTake: number): OpenAPIV3_1.ParameterObject[] { + return [ + intQuery('skip', 'How many entries to skip (default 0)'), + intQuery('take', `How many entries to return (default ${defaultTake})`), + ] +} + +// ---- Loose shapes ---------------------------------------------------------- +// Several routes serve opaque static config blobs (the game configs, the charades word +// list) or empty-list stubs. Modelling every field adds noise without value, so these +// use deliberately loose schemas. + +/** An opaque JSON object (a static config blob, a stub, …). */ +export const JsonObject = z.record(z.string(), z.unknown()) +/** An opaque JSON array (a static list served verbatim, or an empty-list stub). */ +export const JsonArray = z.array(z.unknown()) + +/** A bare JSON boolean — several routes answer `true`/`false` with no envelope. */ +export const BareBoolean = z.boolean() + +/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */ +export const BareString = z.string() + +/** The `{ error }` body the 400 / 403 branches return. */ +export const ErrorResponse = z.object({ error: z.string() }) + +// ---- Config ---------------------------------------------------------------- + +/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */ +export const AmplitudeConfig = z.object({ + AmplitudeKey: z.string(), + StatSigKey: z.string(), + RudderStackKey: z.string(), + UseRudderStack: z.boolean(), +}) + +/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */ +export const AzureSpeechConfig = z.object({ + Key: z.string(), + Region: z.string(), + Enabled: z.boolean(), +}) + +/** `GET /api/config/v1/backtrace` — the client's crash-reporter budget and filters. */ +export const BacktraceConfig = z.object({ + ReportBudget: z.int(), + FilterType: z.int(), + SampleRate: z.int(), + LogLineCount: z.int(), + CaptureNativeCrashes: z.int(), + AMRThresholdMS: z.int(), + MessageCount: z.int(), + MessageRegex: z.string(), + VersionRegex: z.string(), +}) + +/** + * `GET /api/config/v2` — the big client config blob (a static asset), with + * `ShareBaseUrl` derived from the deploy-time base domain. + */ +export const ApiConfigV2 = JsonObject.describe( + 'The static client config, plus a ShareBaseUrl templated from the deploy domain' +) + +/** `GET /api/versioncheck/v4` — always the "you are up to date" answer. */ +export const VersionCheck = z.object({ + VersionStatus: z.int().describe('0 = current'), + UpdateNotificationStage: z.int(), + IsVersionIslanded: z.boolean(), + IsCrossPlayDisabled: z.boolean(), +}) + +// ---- Social ---------------------------------------------------------------- + +/** + * The per-player relationship projection (`RelationshipResponse`). `PlayerID` is the + * OTHER player; the type and flags are taken from the caller's own side of the row, so + * the two players in a pair see different projections of it. + */ +export const RelationshipDto = z.object({ + PlayerID: z.int().describe('The other player in the pair'), + RelationshipType: z + .int() + .describe('0 = none, 1 = friend request sent, 2 = friend request received, 3 = friend'), + Favorited: z.int().describe('0/1 — the caller‘s own flag'), + Ignored: z.int().describe('0/1 — the caller‘s own flag'), + Muted: z.int().describe('0/1 — the caller‘s own flag'), +}) + +/** The `{ Success, Message }` ack the flag toggles answer with. */ +export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() }) + +// ---- Progression ----------------------------------------------------------- + +/** + * A player's reputation (cheer counters). Nobody has earned cheers yet, so every + * counter is 0 and everyone has their full credit. `SelectedCheer` is an int (0 = none), + * not null, and `IsCheerful` is true — the client reads it to decide whether the player + * may hand out cheers at all. + */ +export const ReputationDto = z.object({ + AccountId: z.int(), + IsCheerful: z.boolean(), + Noteriety: z.int(), + SelectedCheer: z.int().describe('0 = none selected'), + CheerCredit: z.int(), + CheerGeneral: z.int(), + CheerHelpful: z.int(), + CheerCreative: z.int(), + CheerGreatHost: z.int(), + CheerSportsman: z.int(), + SubscriberCount: z.int(), + SubscribedCount: z.int(), +}) + +/** A player's level/XP (`/api/players/v1/progression/:id`). */ +export const ProgressionDto = z.object({ + PlayerId: z.int(), + Level: z.int(), + XP: z.int(), +}) + +/** The `Ids` form body the bulk POST endpoints take. */ +export const BulkIdsRequest = z.object({ + Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'), +}) + +// ---- Inventions ------------------------------------------------------------ + +/** One version of an invention — carries the blob name the client downloads. */ +export const InventionVersionDto = z.object({ + InventionId: z.int(), + ReplicationId: z.string(), + VersionNumber: z.int(), + BlobName: z.string().describe('The `.inv` key in the storage worker‘s bucket'), + BlobHash: z.string().nullable(), + InstantiationCost: z.int(), + LightsCost: z.int(), + ChipsCost: z.int(), + CloudVariablesCost: z.int(), + AICost: z.int(), +}) + +/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */ +export const InventionTagDto = z.object({ + Tag: z.string(), + Type: z.int().describe('0 = custom, 2 = auto'), +}) + +/** A stored invention record (the reference's `RRInvention`). */ +export const InventionDto = z.object({ + InventionId: z.int(), + ReplicationId: z.string(), + CreatorPlayerId: z.int(), + Name: z.string(), + Description: z.string(), + ImageName: z.string(), + CurrentVersionNumber: z.int(), + CurrentVersion: InventionVersionDto, + Accessibility: z.int(), + IsPublished: z.boolean().describe('Unpublished inventions are visible only to their creator'), + IsFeatured: z.boolean(), + ModifiedAt: z.string(), + CreatedAt: z.string(), + FirstPublishedAt: z.string().nullable(), + CreationRoomId: z.int(), + NumPlayersHaveUsedInRoom: z.int(), + NumDownloads: z.int(), + CheerCount: z.int(), + CreatorPermission: z.int(), + GeneralPermission: z.int().describe('What other players may do with it once published'), + IsAGInvention: z.boolean(), + IsCertifiedInvention: z.boolean(), + Price: z.int(), + AllowTrial: z.boolean(), + HideFromPlayer: z.boolean(), + ReferencedInventions: z.array(z.int()), + Tags: z + .array(InventionTagDto) + .optional() + .describe('Unset on save — the real RRInvention carries no Tags field'), +}) + +/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */ +export const InventionSaveResult = z.object({ + Status: z.int().describe('0 = success'), + Invention: InventionDto, + InventionVersion: InventionVersionDto, +}) + +/** The tag filter chips on a browse screen, derived from the tags actually in use. */ +export const TagFilters = z.object({ + PinnedFilters: z.array(z.string()), + PopularFilters: z.array(z.string()), + TrendingFilters: z + .array(z.string()) + .nullable() + .describe('Null — needs recent-activity data we don‘t keep'), +}) + +/** `GET /api/inventions/v1/details` — an invention's detail card is just its tags. */ +export const InventionDetails = z.object({ Tags: z.array(InventionTagDto) }) + +/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */ +export const InventionPersonalDetails = z.object({ + IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'), +}) + +/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */ +export const SetTagsRequest = z.object({ + InventionId: z.int(), + AutoTags: z.array(z.string()).optional().describe('Client-derived tags (Type 2)'), + CustomTags: z.array(z.string()).optional().describe('Creator-submitted tags (Type 0)'), +}) + +/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */ +export const SetTagsResponse = z.object({ + Result: z.int().describe('0 = success'), + Tags: z.array(z.string()).describe('Auto tags first, then custom'), +}) + +/** `POST /api/inventions/v1/updateprice` JSON body. */ +export const UpdatePriceRequest = z.object({ + InventionId: z.int(), + Price: z.int().describe('Must be >= 0'), +}) + +/** `POST /api/inventions/v6/save` JSON body — camelCase, unlike the read shapes. */ +export const SaveInventionRequest = z.object({ + inventionDataFilename: z + .string() + .describe('The blob uploaded through the storage worker; the one required field'), + name: z.string().optional().describe('Defaults to “Untitled”'), + description: z.string().optional(), + imageName: z.string().optional(), + instantiationCost: z.int().optional(), + lightsCost: z.int().optional(), + chipsCost: z.int().optional(), + cloudVariablesCost: z.int().optional(), + aiCost: z.int().optional(), + creationRoomId: z.int().optional(), + referencedInventions: z.array(z.int()).optional(), +}) + +// ---- Avatar / custom avatar items ------------------------------------------ + +/** `POST /api/avatar/v2/gifts/generate` — a generated gift box (always a token gift). */ +export const GeneratedGift = z.object({ + Id: z.int().describe('Always 0 — gifts generated here are not persisted'), + FromPlayerId: z.int(), + ConsumableItemDesc: z.string(), + AvatarItemDesc: z.string(), + FriendlyName: z.string(), + AvatarItemType: z.int(), + EquipmentPrefabName: z.string(), + EquipmentModificationGuid: z.string(), + CurrencyType: z.int(), + Currency: z.int().describe('A random token amount'), + Xp: z.int(), + Level: z.int(), + Platform: z.int(), + PlatformsToSpawnOn: z.int(), + BalanceType: z.int(), + GiftContext: z.int(), + GiftRarity: z.int(), + Message: z.string(), +}) + +/** `POST /api/avatar/v2/gifts/generate` form body. */ +export const GenerateGiftRequest = z.object({ + GiftContext: z.string().optional().describe('Where the gift was earned'), + Message: z.string().optional(), + Xp: z.string().optional(), +}) + +/** A paginated custom-avatar-item page (no storage yet, so always empty). */ +export const CustomAvatarItemsPage = z.object({ + Results: JsonArray, + TotalResults: z.int(), +}) + +/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */ +export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() }) + +// ---- Gameplay -------------------------------------------------------------- + +/** `POST /api/sanitize/v1` JSON body — the text to clean. */ +export const SanitizeRequest = z.object({ Value: z.string() }) + +/** `POST /api/sanitize/v1/isPure` — whether the text is clean (always true here). */ +export const IsPureResponse = z.object({ IsPure: z.boolean() }) + +/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */ +export const KeepsakeConfig = z.object({ + KeepsakeFeatureEnabled: z.boolean(), + KeepsakeRoomLimit: z.int(), + SocialXpBoostEnabled: z.boolean(), +}) + +/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */ +export const PlayerEventsAll = z.object({ + Created: JsonArray, + Responses: JsonArray, +}) + +/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */ +export const PlayerEventsPage = z.object({ + ContinuationToken: z.string().describe('Empty = no next page'), + Events: JsonArray, +}) + +/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both null (no subs yet). */ +export const SubscriptionResponse = z.object({ + subscription: z.null(), + platformAccountSubscribedPlayerId: z.null(), +}) + +// ---- Moderation ------------------------------------------------------------ + +/** + * `GET /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked" + * answer (no ban storage yet). `ReportCategory` is -1 (no category) rather than 0, + * which is a real category; `Message` is null, not an empty string — the client + * distinguishes "no message" from a blank one. + */ +export const ModerationBlockDetails = z.object({ + ReportCategory: z.int().describe('-1 = no category (0 is a real one)'), + Duration: z.int(), + GameSessionId: z.int(), + IsBan: z.boolean(), + IsHostKick: z.boolean(), + IsVoiceModAutoban: z.boolean(), + Message: z.string().nullable(), + PlayerIdReporter: z.int().nullable(), + TimeoutStartedAt: z.string().nullable(), +}) + +/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */ +export const DeviceIdRequest = z.object({ + oldDeviceId: z.string().optional().describe('The id the client thinks we hold'), + newDeviceId: z.string().optional(), + platform: z.string().optional(), +}) + +// ---- Rooms ----------------------------------------------------------------- + +/** `GET /api/quickPlay/v1/getandclear` — a pending quick-play action; all null = none. */ +export const QuickPlayResponse = z.object({ + RoomName: z.string().nullable(), + ActionCode: z.string().nullable(), + TargetPlayerId: z.int().nullable(), +}) + +/** `POST /api/rooms/v1/verifyRole` form body. */ +export const VerifyRoleRequest = z.object({ + roomId: z.string(), + role: z.string().describe('The minimum role level required'), + context: z.string().optional().describe('e.g. MakerPen — accepted and ignored'), +}) + +// ---- Images ---------------------------------------------------------------- + +/** + * A stored image record. Note the room photo feed (`/api/images/v4/room/:roomId`) + * serves this shape raw, while the player lists serve the `ImagesPlayer` projection + * below — deliberately different, see the client-contract notes in CLAUDE.md. + */ +export const SavedImageDto = z.object({ + Id: z.int(), + Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'), + Accessibility: z.int(), + AccessibilityLocked: z.boolean(), + ImageName: z.string().describe('The bucket key the img worker serves it back by'), + Description: z.string().nullable(), + PlayerId: z.int(), + TaggedPlayerIds: z.array(z.int()), + RoomId: z.int().nullable(), + PlayerEventId: z.int().nullable(), + CreatedAt: z.string(), + CheerCount: z.int(), + CommentCount: z.int(), +}) + +/** + * The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`, + * `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed + * MUST serve this: the raw SavedImage renders blank thumbnails. + */ +export const ImagesPlayerDto = z.object({ + SavedImageId: z.int(), + SavedImageType: z.int(), + Accessibility: z.int(), + AccessibilityLocked: z.boolean(), + CheerCount: z.int(), + CommentCount: z.int(), + CreatedAt: z.string(), + Description: z.string().nullable(), + ImageName: z.string(), + PlayerEventId: z.int().nullable(), + PlayerId: z.int(), + RoomId: z.int().nullable(), +}) + +/** One entry in the anonymous slideshow feed, joined to its creator and room. */ +export const SlideshowImageDto = z.object({ + SavedImageId: z.int(), + ImageName: z.string(), + Username: z.string(), + RoomName: z.string().nullable(), + RoomId: z.int().nullable(), + SavedImageType: z.int(), + PlayerEventId: z.int().nullable(), + Accessibility: z.int(), + PlayerIds: z.array(z.int()), +}) + +/** `GET /api/images/v1/slideshow` — the feed plus a short cache hint. */ +export const SlideshowResponse = z.object({ + Images: z.array(SlideshowImageDto), + ValidTill: z.string().describe('ISO timestamp ~2 minutes out; the client refreshes against it'), +}) + +/** `POST /api/images/v4/uploadsaved` multipart body. */ +export const UploadImageRequest = z.object({ + image: z.string().describe('The image file (`file` is accepted too)'), + imgMeta: z + .string() + .optional() + .describe( + 'A JSON `SavedImageMetaDTO`: { playerIds, savedImageType, roomId, playerEventId, accessibility, description }' + ), +}) + +/** `POST /api/images/v4/uploadsaved` — the stored bucket key. */ +export const UploadImageResponse = z.object({ + ImageName: z.string().describe('The bucket key; the img worker serves the object by it'), +}) + +/** `DELETE /api/images/v1/deletesaved` JSON body. */ +export const DeleteImageRequest = z.object({ ImageName: z.string() }) + +/** `POST /api/images/v1/cheer` JSON body. */ +export const CheerImageRequest = z.object({ + SavedImageId: z.int(), + Cheer: z.boolean().describe('True to cheer, false to un-cheer'), +}) + +/** The bare `{ success: true }` ack the image writes answer with. */ +export const SuccessResponse = z.object({ success: z.boolean() }) + +/** One entry of `GET /api/images/v5/cheered/bulk`, one per requested id, in order. */ +export const CheeredEntry = z.object({ + SavedImageId: z.int(), + IsCheered: z.boolean(), +}) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index afb76c7..a3f610d 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import { describeRoute } from 'hono-openapi' import { authedId, unauthorized } from '../http' import { @@ -20,6 +21,34 @@ import { toSaveResult, updateInvention, } from '../inventions-db' +import { + AUTHED, + BareBoolean, + CustomAvatarItemsPage, + ErrorResponse, + form, + GeneratedGift, + GenerateGiftRequest, + idParam, + intQuery, + InventionDetails, + InventionDto, + InventionPersonalDetails, + InventionSaveResult, + InventionVersionDto, + json, + JsonArray, + jsonBody, + pageParams, + SaveInventionRequest, + SetTagsRequest, + SetTagsResponse, + stringQuery, + SuccessValueEnvelope, + TagFilters, + UNAUTHORIZED_RESPONSE, + UpdatePriceRequest, +} from '../openapi' import type { Context } from 'hono' import type { App } from '../context' @@ -52,129 +81,287 @@ async function creatorsInvention( // gift-box consume live in the `econ` worker, which the client calls on the econ host // — not here. Only the gift `generate` action remains on this worker. export const avatarRoutes = new Hono({ strict: false }) - .post('/api/avatar/v2/gifts/generate', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .post( + '/api/avatar/v2/gifts/generate', + describeRoute({ + tags: ['Avatar'], + summary: 'Generate a gift box', + description: + 'Mint the gift box a player earned (levelling up, a room reward). With no ' + + 'EarnableRewards catalog wired up this always falls back to a token gift of a ' + + 'random amount, and the box is not persisted — its `Id` is 0 and it cannot be ' + + 'opened through the `econ` worker’s consume endpoint.', + security: AUTHED, + requestBody: form(GenerateGiftRequest, 'Where the gift was earned'), + responses: { + 200: json(GeneratedGift, 'The generated (unpersisted) gift'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) - const body = await c.req.parseBody().catch(() => ({}) as Record) - const giftContext = - typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0 - const message = typeof body.Message === 'string' ? body.Message : '' - const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0 + const body = await c.req.parseBody().catch(() => ({}) as Record) + const giftContext = + typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0 + const message = typeof body.Message === 'string' ? body.Message : '' + const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0 - // No EarnableRewards binding → always fall back to a token gift. - const tokenAmounts = [10, 25, 50, 100, 250, 500] - const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)] + // No EarnableRewards binding → always fall back to a token gift. + const tokenAmounts = [10, 25, 50, 100, 250, 500] + const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)] - return c.json({ - Id: 0, // TODO: real id once gifts are persisted - FromPlayerId: 1, - ConsumableItemDesc: '', - AvatarItemDesc: '', - FriendlyName: '', - AvatarItemType: 0, - EquipmentPrefabName: '', - EquipmentModificationGuid: '', - CurrencyType: 2, - Currency: currency, - Xp: xp, - Level: 0, - Platform: -1, - PlatformsToSpawnOn: -1, - BalanceType: 0, - GiftContext: giftContext, - GiftRarity: 20, - Message: message, - }) - }) + return c.json({ + Id: 0, // TODO: real id once gifts are persisted + FromPlayerId: 1, + ConsumableItemDesc: '', + AvatarItemDesc: '', + FriendlyName: '', + AvatarItemType: 0, + EquipmentPrefabName: '', + EquipmentModificationGuid: '', + CurrencyType: 2, + Currency: currency, + Xp: xp, + Level: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: 0, + GiftContext: giftContext, + GiftRarity: 20, + Message: message, + }) + } + ) // Custom avatar item gates — real Rec Room client endpoints with no backing // implementation yet; we enable them. Flip to `false` to disable the // corresponding flow. `isCreationAllowedForAccount` wraps its answer in the // success/value envelope; the other two return a bare JSON boolean. - .get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) => - c.json({ success: true, value: null }) + .get( + '/api/customAvatarItems/v1/isCreationAllowedForAccount', + describeRoute({ + tags: ['Avatar'], + summary: 'May this account create custom items?', + description: + 'A feature gate with no backing implementation — we answer yes. Note this one ' + + 'wraps its answer in the `{ success, value }` envelope while the two gates below ' + + 'return a bare boolean.', + responses: { 200: json(SuccessValueEnvelope, 'Allowed') }, + }), + (c) => c.json({ success: true, value: null }) + ) + .get( + '/api/customAvatarItems/v1/isCreationEnabled', + describeRoute({ + tags: ['Avatar'], + summary: 'Is custom-item creation enabled?', + description: 'A server-wide feature gate. Enabled; flip to `false` to disable the flow.', + responses: { 200: json(BareBoolean, 'A bare `true`') }, + }), + (c) => c.json(true) + ) + .get( + '/api/customAvatarItems/v1/isRenderingEnabled', + describeRoute({ + tags: ['Avatar'], + summary: 'Is custom-item rendering enabled?', + description: 'A server-wide feature gate. Enabled; flip to `false` to disable the flow.', + responses: { 200: json(BareBoolean, 'A bare `true`') }, + }), + (c) => c.json(true) ) - .get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true)) - .get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true)) // The featured custom-avatar-item feed. No curated items yet → an empty list. - .get('/api/customAvatarItems/v1/featured', (c) => c.json([])) + .get( + '/api/customAvatarItems/v1/featured', + describeRoute({ + tags: ['Avatar'], + summary: 'Featured custom avatar items', + description: 'The curated feed. Nothing is curated yet, so it is empty.', + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) // The "hot" (trending) custom-avatar-item feed. No items yet → an empty list. - .get('/api/customAvatarItems/v1/hot', (c) => c.json([])) + .get( + '/api/customAvatarItems/v1/hot', + describeRoute({ + tags: ['Avatar'], + summary: 'Trending custom avatar items', + description: 'The “hot” feed. No custom items exist yet, so it is empty.', + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) // Custom avatar items created by a given account. No storage yet → an empty // paginated result (matches the econ `customAvatarItems/v1/owned` shape). - .get('/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}', (c) => - c.json({ Results: [], TotalResults: 0 }) + .get( + '/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}', + describeRoute({ + tags: ['Avatar'], + summary: 'A creator’s custom avatar items', + description: + 'The items an account has authored. Nothing stores custom items yet, so this is an ' + + 'empty page — in the same shape as the `econ` worker’s `customAvatarItems/v1/owned`.', + parameters: [idParam('accountId', 'Creator account id')], + responses: { 200: json(CustomAvatarItemsPage, 'An empty page') }, + }), + (c) => c.json({ Results: [], TotalResults: 0 }) ) // A single invention by id (`?inventionId=…`). Returns the stored RRInvention, // or 404 when there's no such invention. - .get('/api/inventions/v1', async (c) => { - const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10) - if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400) - const invention = await getInventionById(c.env.DB, inventionId) - return invention ? c.json(invention) : c.notFound() - }) + .get( + '/api/inventions/v1', + describeRoute({ + tags: ['Inventions'], + summary: 'One invention by id', + description: 'The stored `RRInvention`. Public — an unpublished invention is served too.', + parameters: [intQuery('inventionId', 'Invention id; required')], + responses: { + 200: json(InventionDto, 'The invention'), + 400: json(ErrorResponse, 'Missing or non-numeric inventionId'), + 404: { description: 'No such invention' }, + }, + }), + async (c) => { + const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10) + if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400) + const invention = await getInventionById(c.env.DB, inventionId) + return invention ? c.json(invention) : c.notFound() + } + ) // The tag filter chips on the invention browse screen. Derived from the tags in // use on published inventions — most popular first, top few pinned. Public. - .get('/api/inventions/v1/tagfilters', async (c) => c.json(await getInventionTagFilters(c.env.DB))) + .get( + '/api/inventions/v1/tagfilters', + describeRoute({ + tags: ['Inventions'], + summary: 'Invention browse filter chips', + description: + 'The filter chips on the invention browse screen, derived from the tags actually in ' + + 'use on published inventions — most popular first, the top few pinned. ' + + '`TrendingFilters` is null: that needs recent-activity data we do not keep, and the ' + + 'client treats null as absent.', + responses: { 200: json(TagFilters, 'The chips in use') }, + }), + async (c) => c.json(await getInventionTagFilters(c.env.DB)) + ) // A batch of inventions by id (`?id=1&id=2`, and each `id` may itself be a // comma-separated list). Unknown ids are dropped rather than 404ing, and an empty // request is an empty list. Auth is optional and only widens what you see: an // unpublished invention comes back only to its creator. Bare array. - .get('/api/inventions/v2/batch', async (c) => { - const ids = c.req - .queries('id') - ?.flatMap((raw) => raw.split(',')) - .map((raw) => Number.parseInt(raw.trim(), 10)) - .filter((id) => !Number.isNaN(id)) - if (ids === undefined || ids.length === 0) return c.json([]) + .get( + '/api/inventions/v2/batch', + describeRoute({ + tags: ['Inventions'], + summary: 'Inventions by id, in bulk', + description: + 'Look up several inventions at once. Unknown ids are dropped rather than 404ing, ' + + 'and an empty request is an empty list. Auth is optional and only widens what you ' + + 'see: an unpublished invention comes back only to its creator.', + parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')], + responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') }, + }), + async (c) => { + const ids = c.req + .queries('id') + ?.flatMap((raw) => raw.split(',')) + .map((raw) => Number.parseInt(raw.trim(), 10)) + .filter((id) => !Number.isNaN(id)) + if (ids === undefined || ids.length === 0) return c.json([]) - const playerId = await authedId(c) - const inventions = await getInventionsByIds(c.env.DB, ids) - return c.json( - inventions.filter( - (i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId) + const playerId = await authedId(c) + const inventions = await getInventionsByIds(c.env.DB, ids) + return c.json( + inventions.filter( + (i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId) + ) ) - ) - }) + } + ) // A room's inventions (`?id=76`) — published inventions created in that room, // newest first. Paginated via skip/take (take defaults to 100). Bare array. - .get('/api/inventions/v1/room', async (c) => { - const roomId = Number.parseInt(c.req.query('id') ?? '', 10) - if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400) - const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 - const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - return c.json(await getInventionsByRoom(c.env.DB, roomId, skip, take)) - }) + .get( + '/api/inventions/v1/room', + describeRoute({ + tags: ['Inventions'], + summary: 'A room’s inventions', + description: 'Published inventions created in that room, newest first.', + parameters: [intQuery('id', 'Room id; required'), ...pageParams(100)], + responses: { + 200: json(InventionDto.array(), 'The room’s inventions'), + 400: json(ErrorResponse, 'Missing or non-numeric id'), + }, + }), + async (c) => { + const roomId = Number.parseInt(c.req.query('id') ?? '', 10) + if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400) + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await getInventionsByRoom(c.env.DB, roomId, skip, take)) + } + ) // The signed-in player's own relationship to an invention (`/personaldetails/2`) // — just whether they're cheering it. We store no cheers (nothing can cheer an // invention yet), so this is always false; it stays a 200 for signed-out callers // too, since the client only reads the flag. - .get('/api/inventions/v1/personaldetails/:inventionId{[0-9]+}', (c) => - c.json({ IsCheering: false }) + .get( + '/api/inventions/v1/personaldetails/:inventionId{[0-9]+}', + describeRoute({ + tags: ['Inventions'], + summary: 'The caller’s own relation to an invention', + description: + 'Just whether the caller is cheering it. We store no cheers, so it is always false ' + + '— and this stays a 200 for signed-out callers too, since the client only reads the ' + + 'flag.', + parameters: [idParam('inventionId', 'Invention id')], + responses: { 200: json(InventionPersonalDetails, 'Always not cheering') }, + }), + (c) => c.json({ IsCheering: false }) ) // A single version of an invention (`?inventionId=…&version=…`) — the bare // RRInventionVersion, which carries the blob name the client downloads. Public. // Only the current version exists (nothing writes version history yet), so any // other version number 404s rather than naming a blob that isn't there. - .get('/api/inventions/v1/version', async (c) => { - const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10) - if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400) - const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10) - if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400) + .get( + '/api/inventions/v1/version', + describeRoute({ + tags: ['Inventions'], + summary: 'One version of an invention', + description: + 'The bare `RRInventionVersion`, which carries the blob name the client downloads. ' + + 'Only the current version exists — nothing writes version history yet — so any ' + + 'other version number 404s rather than naming a blob that is not there.', + parameters: [ + intQuery('inventionId', 'Invention id; required'), + intQuery('version', 'Version number; required'), + ], + responses: { + 200: json(InventionVersionDto, 'The version'), + 400: json(ErrorResponse, 'Missing inventionId or version'), + 404: { description: 'No such invention, or not the current version' }, + }, + }), + async (c) => { + const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10) + if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400) + const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10) + if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400) - const version = await getInventionVersion(c.env.DB, inventionId, versionNumber) - return version === null ? c.notFound() : c.json(version) - }) + const version = await getInventionVersion(c.env.DB, inventionId, versionNumber) + return version === null ? c.notFound() : c.json(version) + } + ) // Edit an invention's metadata. A GET that writes — that's what the client sends // (`?inventionId=1&description=my+description`), with the fields to change as @@ -183,67 +370,135 @@ export const avatarRoutes = new Hono({ strict: false }) // empty `description` clears it, but an empty `name`/`imageName` is ignored // rather than blanking the invention. Publishing and pricing are separate // endpoints. Auth-gated, creator only; answers the save envelope. - .get('/api/inventions/v1/update', async (c) => { - const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10)) - if ('response' in gate) return gate.response + .get( + '/api/inventions/v1/update', + describeRoute({ + tags: ['Inventions'], + summary: 'Edit an invention’s metadata', + description: + 'A GET that writes — that is what the client sends, with the fields to change as ' + + 'query params. Absent params keep their stored value. An empty `description` ' + + 'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' + + 'invention. Publishing and pricing are separate endpoints.', + security: AUTHED, + parameters: [ + intQuery('inventionId', 'Invention id; required'), + stringQuery('name', 'New name; empty is ignored'), + stringQuery('description', 'New description; present-but-empty clears it'), + stringQuery('imageName', 'New thumbnail; empty is ignored'), + stringQuery('allowTrial', '`true`/`1` to allow trials'), + stringQuery('permission', 'A name like `useonly`, or the raw permission number'), + ], + responses: { + 200: json(InventionSaveResult, 'The updated invention, in the save envelope'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(ErrorResponse, 'Not the caller’s invention'), + 404: { description: 'No such invention' }, + }, + }), + async (c) => { + const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10)) + if ('response' in gate) return gate.response - // Query params arrive as strings; only the ones actually present are applied. - const nonEmpty = (name: string): string | undefined => { - const v = c.req.query(name)?.trim() - return v === undefined || v === '' ? undefined : v + // Query params arrive as strings; only the ones actually present are applied. + const nonEmpty = (name: string): string | undefined => { + const v = c.req.query(name)?.trim() + return v === undefined || v === '' ? undefined : v + } + const allowTrial = c.req.query('allowTrial') + const permission = c.req.query('permission') + + const updated = await updateInvention(c.env.DB, gate.invention.InventionId, { + name: nonEmpty('name'), + // Present-but-empty clears the description, so this checks presence. + description: c.req.query('description'), + imageName: nonEmpty('imageName'), + allowTrial: + allowTrial === undefined + ? undefined + : allowTrial.toLowerCase() === 'true' || allowTrial === '1', + generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission), + }) + return updated === null ? c.notFound() : c.json(toSaveResult(updated)) } - const allowTrial = c.req.query('allowTrial') - const permission = c.req.query('permission') - - const updated = await updateInvention(c.env.DB, gate.invention.InventionId, { - name: nonEmpty('name'), - // Present-but-empty clears the description, so this checks presence. - description: c.req.query('description'), - imageName: nonEmpty('imageName'), - allowTrial: - allowTrial === undefined - ? undefined - : allowTrial.toLowerCase() === 'true' || allowTrial === '1', - generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission), - }) - return updated === null ? c.notFound() : c.json(toSaveResult(updated)) - }) + ) // Publish an invention — this is what puts it into search and the feeds. Sets the // permission other players get (`permissionLevel`, defaulting to UseOnly) and its // `price`. Auth-gated, creator only; answers the save envelope. - .get('/api/inventions/v3/publish', async (c) => { - const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10)) - if ('response' in gate) return gate.response + .get( + '/api/inventions/v3/publish', + describeRoute({ + tags: ['Inventions'], + summary: 'Publish an invention', + description: + 'What puts an invention into search and the feeds. Sets the permission other ' + + 'players get (defaulting to UseOnly) and its price. Another GET that writes.', + security: AUTHED, + parameters: [ + intQuery('inventionId', 'Invention id; required'), + stringQuery('permissionLevel', 'A name like `useonly`, or the raw number'), + intQuery('price', 'Price in tokens; negative is ignored'), + ], + responses: { + 200: json(InventionSaveResult, 'The published invention, in the save envelope'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(ErrorResponse, 'Not the caller’s invention'), + 404: { description: 'No such invention' }, + }, + }), + async (c) => { + const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10)) + if ('response' in gate) return gate.response - const permissionLevel = c.req.query('permissionLevel') - const price = Number.parseInt(c.req.query('price') ?? '', 10) + const permissionLevel = c.req.query('permissionLevel') + const price = Number.parseInt(c.req.query('price') ?? '', 10) - const published = await publishInvention( - c.env.DB, - gate.invention.InventionId, - permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel), - Number.isNaN(price) || price < 0 ? undefined : price - ) - return published === null ? c.notFound() : c.json(toSaveResult(published)) - }) + const published = await publishInvention( + c.env.DB, + gate.invention.InventionId, + permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel), + Number.isNaN(price) || price < 0 ? undefined : price + ) + return published === null ? c.notFound() : c.json(toSaveResult(published)) + } + ) // Set an invention's price. Unlike update/publish this one POSTs a JSON body. // Auth-gated, creator only; answers the save envelope. - .post('/api/inventions/v1/updateprice', async (c) => { - const body = (await c.req.json().catch(() => null)) as Record | null - if (body === null) return c.json({ error: 'Invalid request body' }, 400) + .post( + '/api/inventions/v1/updateprice', + describeRoute({ + tags: ['Inventions'], + summary: 'Set an invention’s price', + description: + 'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' + + 'is rejected.', + security: AUTHED, + requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'), + responses: { + 200: json(InventionSaveResult, 'The repriced invention, in the save envelope'), + 400: json(ErrorResponse, 'Unparseable body, or a price below 0'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(ErrorResponse, 'Not the caller’s invention'), + 404: { description: 'No such invention' }, + }, + }), + async (c) => { + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null) return c.json({ error: 'Invalid request body' }, 400) - const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN - const gate = await creatorsInvention(c, inventionId) - if ('response' in gate) return gate.response + const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN + const gate = await creatorsInvention(c, inventionId) + if ('response' in gate) return gate.response - const price = typeof body.Price === 'number' ? body.Price : Number.NaN - if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400) + const price = typeof body.Price === 'number' ? body.Price : Number.NaN + if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400) - const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price) - return updated === null ? c.notFound() : c.json(toSaveResult(updated)) - }) + const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price) + return updated === null ? c.notFound() : c.json(toSaveResult(updated)) + } + ) // Replace an invention's tags. `CustomTags` are the creator's own (Type 0), // `AutoTags` the ones the client derives from the invention (Type 2); both lists @@ -251,69 +506,162 @@ export const avatarRoutes = new Hono({ strict: false }) // invention. Answers `{ Result, Tags }` — `Result` 0 is success, and `Tags` is the // flat list of tag *names* (auto first, then custom); the typed `{ Tag, Type }` // objects are what `v1/details` serves. - .post('/api/inventions/v1/settags', async (c) => { - const body = (await c.req.json().catch(() => null)) as Record | null - if (body === null) return c.json({ error: 'Invalid request body' }, 400) + .post( + '/api/inventions/v1/settags', + describeRoute({ + tags: ['Inventions'], + summary: 'Replace an invention’s tags', + description: + '`CustomTags` are the creator’s own (Type 0), `AutoTags` the ones the client ' + + 'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' + + 'only.\n\n' + + 'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' + + 'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.', + security: AUTHED, + requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'), + responses: { + 200: json(SetTagsResponse, 'The resulting tag names'), + 400: json(ErrorResponse, 'Unparseable body'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(ErrorResponse, 'Not the caller’s invention'), + 404: { description: 'No such invention' }, + }, + }), + async (c) => { + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null) return c.json({ error: 'Invalid request body' }, 400) - const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN - const gate = await creatorsInvention(c, inventionId) - if ('response' in gate) return gate.response + const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN + const gate = await creatorsInvention(c, inventionId) + if ('response' in gate) return gate.response - const strings = (v: unknown): string[] => - Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : [] + const strings = (v: unknown): string[] => + Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : [] - const tags = await setInventionTags( - c.env.DB, - gate.invention.InventionId, - strings(body.AutoTags), - strings(body.CustomTags) - ) - return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) }) - }) + const tags = await setInventionTags( + c.env.DB, + gate.invention.InventionId, + strings(body.AutoTags), + strings(body.CustomTags) + ) + return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) }) + } + ) // An invention's detail card (`?inventionId=…`) — just its tags, as `{ Tags }`. // Untagged inventions report an empty list. 404s on unknown ids. - .get('/api/inventions/v1/details', async (c) => { - const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10) - if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400) - const tags = await getInventionTags(c.env.DB, inventionId) - return tags === null ? c.notFound() : c.json({ Tags: tags }) - }) + .get( + '/api/inventions/v1/details', + describeRoute({ + tags: ['Inventions'], + summary: 'An invention’s detail card', + description: + 'Which in practice is just its tags, as typed `{ Tag, Type }` objects. An untagged ' + + 'invention reports an empty list.', + parameters: [intQuery('inventionId', 'Invention id; required')], + responses: { + 200: json(InventionDetails, 'The invention’s tags'), + 400: json(ErrorResponse, 'Missing or non-numeric inventionId'), + 404: { description: 'No such invention' }, + }, + }), + async (c) => { + const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10) + if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400) + const tags = await getInventionTags(c.env.DB, inventionId) + return tags === null ? c.notFound() : c.json({ Tags: tags }) + } + ) // The "top today" invention feed — published inventions ranked by engagement // (lifetime, not per-day: we keep no daily counters). Paginated via skip/take // (take defaults to 50, as the client asks for). Bare array. - .get('/api/inventions/v1/toptoday', async (c) => { - const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 - const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50 - return c.json(await getTopInventions(c.env.DB, skip, take)) - }) + .get( + '/api/inventions/v1/toptoday', + describeRoute({ + tags: ['Inventions'], + summary: 'The “top today” feed', + description: + 'Published inventions ranked by engagement — lifetime, not per-day: we keep no ' + + 'daily counters, so “today” is a label, not a window.', + parameters: pageParams(50), + responses: { 200: json(InventionDto.array(), 'The top inventions') }, + }), + async (c) => { + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50 + return c.json(await getTopInventions(c.env.DB, skip, take)) + } + ) // The featured invention feed — curated (`IsFeatured`) inventions, falling back // to the top feed while nothing is curated. Bare array, like toptoday. - .get('/api/inventions/v1/featured', async (c) => { - const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 - const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50 - return c.json(await getFeaturedInventions(c.env.DB, skip, take)) - }) + .get( + '/api/inventions/v1/featured', + describeRoute({ + tags: ['Inventions'], + summary: 'The featured feed', + description: + 'Curated (`IsFeatured`) inventions, falling back to the top feed while nothing is ' + + 'curated — so this is never empty just because no one has picked favourites.', + parameters: pageParams(50), + responses: { 200: json(InventionDto.array(), 'The featured inventions') }, + }), + async (c) => { + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50 + return c.json(await getFeaturedInventions(c.env.DB, skip, take)) + } + ) // Invention search/browse: published inventions matching `value` (matched against // name + description; absent → browse everything published), newest first. // Paginated via skip/take (take defaults to 100). Returns a bare array. - .get('/api/inventions/v2/search', async (c) => { - const value = c.req.query('value') ?? '' - const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 - const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - return c.json(await searchInventions(c.env.DB, value, skip, take)) - }) + .get( + '/api/inventions/v2/search', + describeRoute({ + tags: ['Inventions'], + summary: 'Search / browse inventions', + description: + 'Published inventions matching `value` (matched against name and description), ' + + 'newest first. An absent `value` browses everything published — that is the ' + + 'browse screen’s initial request.', + parameters: [ + stringQuery('value', 'Search text; absent browses everything'), + ...pageParams(100), + ], + responses: { 200: json(InventionDto.array(), 'The matching inventions') }, + }), + async (c) => { + const value = c.req.query('value') ?? '' + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await searchInventions(c.env.DB, value, skip, take)) + } + ) // The signed-in player's saved inventions ("my inventions"), newest first. // Auth-gated; returns a bare array (empty when the player has saved none). - .get('/api/inventions/v2/mine', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - return c.json(await getInventionsByCreator(c.env.DB, id)) - }) + .get( + '/api/inventions/v2/mine', + describeRoute({ + tags: ['Inventions'], + summary: 'The caller’s own inventions', + description: + '“My inventions”, newest first — including unpublished ones, which nobody else can ' + + 'see. Not paginated.', + security: AUTHED, + responses: { + 200: json(InventionDto.array(), 'The caller’s inventions'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json(await getInventionsByCreator(c.env.DB, id)) + } + ) // Save an invention's metadata. The data file itself is uploaded separately // through the `storage` worker and referenced here by `inventionDataFilename` — @@ -321,36 +669,57 @@ export const avatarRoutes = new Hono({ strict: false }) // omitted name/description is defaulted rather than rejected. Auth-gated; returns // the `{ Status, Invention, InventionVersion }` envelope the client expects (the // invention carries its assigned inventionId). - .post('/api/inventions/v6/save', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .post( + '/api/inventions/v6/save', + describeRoute({ + tags: ['Inventions'], + summary: 'Save a new invention', + description: + 'Records an invention’s metadata. The data file itself is uploaded separately ' + + 'through the `storage` worker and referenced here by `inventionDataFilename` — the ' + + 'one required field, since an invention with no data blob is unusable. An omitted ' + + 'name/description is defaulted rather than rejected.\n\n' + + 'A freshly saved invention is private: it shows up only in the creator’s own list ' + + 'until they call `v3/publish`.', + security: AUTHED, + requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'), + responses: { + 200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'), + 400: json(ErrorResponse, 'Unparseable body, or no inventionDataFilename'), + 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) return c.json({ error: 'Invalid request body' }, 400) + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null) return c.json({ error: 'Invalid request body' }, 400) - const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) - const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined) + const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) + const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined) - const inventionDataFilename = str(body.inventionDataFilename)?.trim() - if (!inventionDataFilename) { - return c.json({ error: 'inventionDataFilename is required' }, 400) + const inventionDataFilename = str(body.inventionDataFilename)?.trim() + if (!inventionDataFilename) { + return c.json({ error: 'inventionDataFilename is required' }, 400) + } + + const invention = await createInvention(c.env.DB, { + creatorPlayerId: id, + inventionDataFilename, + name: str(body.name), + description: str(body.description), + imageName: str(body.imageName), + instantiationCost: num(body.instantiationCost), + lightsCost: num(body.lightsCost), + chipsCost: num(body.chipsCost), + cloudVariablesCost: num(body.cloudVariablesCost), + aiCost: num(body.aiCost), + creationRoomId: num(body.creationRoomId), + referencedInventions: Array.isArray(body.referencedInventions) + ? body.referencedInventions.filter((v): v is number => typeof v === 'number') + : undefined, + }) + return c.json(toSaveResult(invention)) } - - const invention = await createInvention(c.env.DB, { - creatorPlayerId: id, - inventionDataFilename, - name: str(body.name), - description: str(body.description), - imageName: str(body.imageName), - instantiationCost: num(body.instantiationCost), - lightsCost: num(body.lightsCost), - chipsCost: num(body.chipsCost), - cloudVariablesCost: num(body.cloudVariablesCost), - aiCost: num(body.aiCost), - creationRoomId: num(body.creationRoomId), - referencedInventions: Array.isArray(body.referencedInventions) - ? body.referencedInventions.filter((v): v is number => typeof v === 'number') - : undefined, - }) - return c.json(toSaveResult(invention)) - }) + ) diff --git a/apps/api/src/routes/config.ts b/apps/api/src/routes/config.ts index dc84227..6ad61a9 100644 --- a/apps/api/src/routes/config.ts +++ b/apps/api/src/routes/config.ts @@ -1,56 +1,136 @@ import { Hono } from 'hono' +import { describeRoute } from 'hono-openapi' import apiConfigV2 from '../../static/api-config-v2.json' import gameConfigsV1All from '../../static/gameconfigs-v1-all.json' +import { + AmplitudeConfig, + ApiConfigV2, + AzureSpeechConfig, + BacktraceConfig, + json, + JsonObject, + VersionCheck, +} from '../openapi' import type { App } from '../context' // ---- Config / version ------------------------------------------------------ export const configRoutes = new Hono({ strict: false }) - .get('/api/config/v1/amplitude', (c) => - c.json({ - AmplitudeKey: 'a', - StatSigKey: 'a', - RudderStackKey: 'a', - UseRudderStack: false, - }) + .get( + '/api/config/v1/amplitude', + describeRoute({ + tags: ['Config'], + summary: 'Analytics keys', + description: + 'The Amplitude / StatSig / RudderStack keys the client initialises its analytics ' + + 'with. This server collects nothing, so the keys are placeholders and RudderStack ' + + 'is off — but the client needs the object to finish loading.', + responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') }, + }), + (c) => + c.json({ + AmplitudeKey: 'a', + StatSigKey: 'a', + RudderStackKey: 'a', + UseRudderStack: false, + }) ) - .get('/api/config/v1/azurespeech', (c) => - c.json({ - Key: 'dce8de5b297747d9b5bddcc7f19e8c5b', - Region: 'eastus', - Enabled: false, - }) + .get( + '/api/config/v1/azurespeech', + describeRoute({ + tags: ['Config'], + summary: 'Speech-to-text config', + description: + 'Azure Speech credentials for the client’s voice transcription. `Enabled` is false ' + + 'here, so the key and region are never used.', + responses: { 200: json(AzureSpeechConfig, 'Speech config, disabled') }, + }), + (c) => + c.json({ + Key: 'dce8de5b297747d9b5bddcc7f19e8c5b', + Region: 'eastus', + Enabled: false, + }) ) - .get('/api/config/v1/backtrace', (c) => - c.json({ - ReportBudget: 125, - FilterType: 0, - SampleRate: 1, - LogLineCount: 50, - CaptureNativeCrashes: 1, - AMRThresholdMS: 0, - MessageCount: 1000, - MessageRegex: - "^.*$", - VersionRegex: '.*', - }) + .get( + '/api/config/v1/backtrace', + describeRoute({ + tags: ['Config'], + summary: 'Crash reporter config', + description: + 'Budget, sampling and log-capture settings for the client’s Backtrace crash ' + + 'reporter. Nothing on this server receives the reports.', + responses: { 200: json(BacktraceConfig, 'Crash reporter settings') }, + }), + (c) => + c.json({ + ReportBudget: 125, + FilterType: 0, + SampleRate: 1, + LogLineCount: 50, + CaptureNativeCrashes: 1, + AMRThresholdMS: 0, + MessageCount: 1000, + MessageRegex: '^.*$', + VersionRegex: '.*', + }) ) // ShareBaseUrl is derived from the deploy-time base domain; the rest of the // config is static. - .get('/api/config/v2', (c) => - c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` }) + .get( + '/api/config/v2', + describeRoute({ + tags: ['Config'], + summary: 'The main client config blob', + description: + 'The large feature-switch / endpoint config the client reads at startup. Served ' + + 'from a static asset, except `ShareBaseUrl`, which is templated from the ' + + 'deploy-time base domain so share links point at this deployment.', + responses: { 200: json(ApiConfigV2, 'The client config') }, + }), + (c) => c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` }) ) - .get('/api/versioncheck/v4', (c) => - c.json({ - VersionStatus: 0, - UpdateNotificationStage: 0, - IsVersionIslanded: false, - IsCrossPlayDisabled: false, - }) + .get( + '/api/versioncheck/v4', + describeRoute({ + tags: ['Config'], + summary: 'Client version check', + description: + 'Whether the client build is current. Always the “up to date, nothing islanded” ' + + 'answer — this server does not gate on client version.', + responses: { 200: json(VersionCheck, 'Always current') }, + }), + (c) => + c.json({ + VersionStatus: 0, + UpdateNotificationStage: 0, + IsVersionIslanded: false, + IsCrossPlayDisabled: false, + }) + ) + .get( + '/api/gameconfigs/v1/all', + describeRoute({ + tags: ['Config'], + summary: 'Per-game configuration', + description: 'An opaque static catalog of per-game settings, served verbatim.', + responses: { 200: json(JsonObject, 'The game config catalog') }, + }), + (c) => c.json(gameConfigsV1All) ) - .get('/api/gameconfigs/v1/all', (c) => c.json(gameConfigsV1All)) // Voice chat config. The client fetches it to set up voice. // No reference shape, so return an empty object until the client needs fields. - .get('/voice/config', (c) => c.json({})) + .get( + '/voice/config', + describeRoute({ + tags: ['Config'], + summary: 'Voice chat config', + description: + 'Fetched by the client while setting up voice. We have no reference shape for it, ' + + 'so it stays an empty object until the client is observed needing a field.', + responses: { 200: json(JsonObject, 'An empty object') }, + }), + (c) => c.json({}) + ) diff --git a/apps/api/src/routes/gameplay.ts b/apps/api/src/routes/gameplay.ts index 7a09e3d..b11f4be 100644 --- a/apps/api/src/routes/gameplay.ts +++ b/apps/api/src/routes/gameplay.ts @@ -1,6 +1,24 @@ import { Hono } from 'hono' +import { describeRoute } from 'hono-openapi' import charadesWords from '../../static/charades.json' +import { + BareString, + idParam, + intQuery, + IsPureResponse, + json, + JsonArray, + jsonBody, + JsonObject, + KeepsakeConfig, + PlayerEventsAll, + PlayerEventsPage, + SanitizeRequest, + stringParam, + SubscriptionResponse, + TagFilters, +} from '../openapi' import type { App } from '../context' @@ -9,54 +27,207 @@ import type { App } from '../context' export const gameplayRoutes = new Hono({ strict: false }) // Text sanitization (display names, room names, chat). `v1` echoes the input // value back; `isPure` reports the text is clean. - .post('/api/sanitize/v1', async (c) => { - const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown }) - return c.json(typeof body.Value === 'string' ? body.Value : '') - }) - .post('/api/sanitize/v1/isPure', (c) => c.json({ IsPure: true })) + .post( + '/api/sanitize/v1', + describeRoute({ + tags: ['Gameplay'], + summary: 'Sanitize a string', + description: + 'Runs display names, room names and chat through the profanity filter. There is ' + + 'no filter here — the input `Value` is echoed back verbatim as a bare JSON string ' + + '(an empty string if the body has no `Value`).', + requestBody: jsonBody(SanitizeRequest, 'The text to clean'), + responses: { 200: json(BareString, 'The input text, unchanged (a bare JSON string)') }, + }), + async (c) => { + const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown }) + return c.json(typeof body.Value === 'string' ? body.Value : '') + } + ) + .post( + '/api/sanitize/v1/isPure', + describeRoute({ + tags: ['Gameplay'], + summary: 'Whether a string is clean', + description: 'The yes/no form of the filter. Always `true` — nothing is filtered here.', + requestBody: jsonBody(SanitizeRequest, 'The text to check'), + responses: { 200: json(IsPureResponse, 'Always pure') }, + }), + (c) => c.json({ IsPure: true }) + ) // ---- Activities ----------------------------------------------------------- // Word bank for the Charades activity. The client requests the list by // activity name (`.../words/Charades`); other activities have no data yet. - .get('/api/activities/charades/v1/words/:activity', (c) => c.json(charadesWords)) + .get( + '/api/activities/charades/v1/words/:activity', + describeRoute({ + tags: ['Gameplay'], + summary: 'An activity’s word bank', + description: + 'The words the Charades activity draws from. The client asks by activity name ' + + '(`.../words/Charades`); the name is not matched on, so every activity gets the ' + + 'charades list — no other activity has data yet.', + parameters: [stringParam('activity', 'Activity name, e.g. `Charades`. Not matched on.')], + responses: { 200: json(JsonArray, 'The word list') }, + }), + (c) => c.json(charadesWords) + ) // Keepsakes (room mementos). Stubbed empty. - .get('/api/keepsakes/globalconfig', (c) => - c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false }) + .get( + '/api/keepsakes/globalconfig', + describeRoute({ + tags: ['Gameplay'], + summary: 'Keepsake feature switches', + description: + 'Whether keepsakes (room mementos) are on and how many a room may hold. The ' + + 'feature reports as enabled, but nothing stores keepsakes yet.', + responses: { 200: json(KeepsakeConfig, 'The keepsake config') }, + }), + (c) => + c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false }) + ) + .get( + '/api/keepsakes/rooms/:roomId', + describeRoute({ + tags: ['Gameplay'], + summary: 'A room’s keepsakes', + description: + 'No keepsake storage yet. Answers 204 with no body rather than an empty list — ' + + 'that is what the reference does, and the client treats a body here as data.', + parameters: [idParam('roomId', 'Room id')], + responses: { 204: { description: 'No keepsakes (empty body)' } }, + }), + (c) => c.body(null, 204) + ) + .get( + '/api/keepsakes/categories', + describeRoute({ + tags: ['Gameplay'], + summary: 'Keepsake categories', + description: 'No keepsake catalog yet, so this is an empty list.', + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) ) - .get('/api/keepsakes/rooms/:roomId', (c) => c.body(null, 204)) - .get('/api/keepsakes/categories', (c) => c.json([])) // ---- Objectives / events / rewards --------------------------------------- // Objectives live on the `econ` host (`updateobjective` / `myprogress`), which is // where the client calls them — they are not served here. - .get('/api/communityboard/v2/current', (c) => c.json({})) // TODO: hydrate from JSON/communityboard.json - .get('/api/playerevents/v1/all', (c) => c.json({ Created: [], Responses: [] })) + .get( + '/api/communityboard/v2/current', + describeRoute({ + tags: ['Gameplay'], + summary: 'The current community board', + description: + 'The rotating community board on the home screen. Not hydrated yet, so it is an ' + + 'empty object.', + responses: { 200: json(JsonObject, 'An empty object') }, + }), + (c) => c.json({}) + ) // TODO: hydrate from JSON/communityboard.json + .get( + '/api/playerevents/v1/all', + describeRoute({ + tags: ['Gameplay'], + summary: 'The caller’s player events', + description: + 'Events the player created and events they have RSVP’d to. No player-event ' + + 'storage yet, so both lists are empty.', + responses: { 200: json(PlayerEventsAll, 'Two empty lists') }, + }), + (c) => c.json({ Created: [], Responses: [] }) + ) // The tag filter chips on the player-events browse screen. Derived from the tags in // use across events — we store no events, so there are no chips to offer. // `TrendingFilters` is null even in the reference (it needs recent-activity data). - .get('/api/playerevents/v1/tagfilters', (c) => - c.json({ PinnedFilters: [], PopularFilters: [], TrendingFilters: null }) + .get( + '/api/playerevents/v1/tagfilters', + describeRoute({ + tags: ['Gameplay'], + summary: 'Player-event filter chips', + description: + 'The filter chips on the player-events browse screen, derived from the tags in use ' + + 'across events. We store no events, so there are no chips to offer. ' + + '`TrendingFilters` is null even in the reference — it needs recent-activity data.', + responses: { 200: json(TagFilters, 'Empty chip lists') }, + }), + (c) => c.json({ PinnedFilters: [], PopularFilters: [], TrendingFilters: null }) ) // Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's // page. A bare array: the client deserializes this one as a list, and chokes on the // `{ ContinuationToken, Events }` envelope the single-club form uses. No // player-event storage yet, so the feed is empty. - .get('/api/playerevents/v1/clubs', (c) => c.json([])) + .get( + '/api/playerevents/v1/clubs', + describeRoute({ + tags: ['Gameplay'], + summary: 'Player events across several clubs', + description: + 'The events shelf for a set of clubs (`?id=1&id=2`). This form returns a BARE ' + + 'ARRAY — the client deserializes it as a list and chokes on the paged envelope the ' + + 'single-club form below uses. Do not unify the two. No player-event storage yet, ' + + 'so the feed is empty.', + parameters: [intQuery('id', 'Repeatable club id')], + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) // The same feed for a single club (`/club/1`) — the form the reference serves, // which *does* wrap the events with a paging cursor (empty = no next page). - .get('/api/playerevents/v1/club/:clubId{[0-9]+}', (c) => - c.json({ ContinuationToken: '', Events: [] }) + .get( + '/api/playerevents/v1/club/:clubId{[0-9]+}', + describeRoute({ + tags: ['Gameplay'], + summary: 'Player events for one club', + description: + 'The same feed for a single club — and this form DOES wrap the events with a ' + + 'paging cursor, matching the reference. An empty `ContinuationToken` means no next ' + + 'page.', + parameters: [idParam('clubId', 'Club id')], + responses: { 200: json(PlayerEventsPage, 'An empty page') }, + }), + (c) => c.json({ ContinuationToken: '', Events: [] }) ) - .get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json + .get( + '/api/announcement/v1/get', + describeRoute({ + tags: ['Gameplay'], + summary: 'Announcements', + description: 'The announcement banners on the home screen. Not hydrated yet.', + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) // TODO: hydrate from JSON/announcements.json // GameSight attribution/analytics event sink. Accept and ack without persisting. - .post('/api/gamesight/event', (c) => c.body(null, 200)) + .post( + '/api/gamesight/event', + describeRoute({ + tags: ['Gameplay'], + summary: 'Analytics event sink', + description: + 'The client’s GameSight attribution/analytics events. Accepted and dropped — ' + + 'nothing is persisted. Answers 200 with an empty body.', + responses: { 200: { description: 'Accepted (empty body)' } }, + }), + (c) => c.body(null, 200) + ) // ---- Subscription --------------------------------------------------------- - .post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) => - c.json({ subscription: null, platformAccountSubscribedPlayerId: null }) + .post( + '/api/CampusCard/v1/UpdateAndGetSubscription', + describeRoute({ + tags: ['Gameplay'], + summary: 'The caller’s subscription', + description: + 'Rec Room Plus subscription state. There are no subscriptions on this server, so ' + + 'both fields are null. Also served by the `econ` worker on its own host.', + responses: { 200: json(SubscriptionResponse, 'No subscription') }, + }), + (c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null }) ) diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index 75b0dc6..c7362b6 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import { describeRoute } from 'hono-openapi' import { authedId, unauthorized } from '../http' import { @@ -14,6 +15,28 @@ import { setImageCheer, toImagesPlayer, } from '../images-db' +import { + AUTHED, + CheeredEntry, + CheerImageRequest, + DeleteImageRequest, + ErrorResponse, + form, + idParam, + ImagesPlayerDto, + intQuery, + json, + JsonArray, + jsonBody, + pageParams, + SavedImageDto, + SlideshowResponse, + stringQuery, + SuccessResponse, + UNAUTHORIZED_RESPONSE, + UploadImageRequest, + UploadImageResponse, +} from '../openapi' import type { App } from '../context' @@ -29,198 +52,374 @@ const typeFolder: Record = { // ---- Images ---------------------------------------------------------------- export const imageRoutes = new Hono({ strict: false }) - .get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json - .post('/api/images/v4/uploadsaved', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .get( + '/api/images/v2/named', + describeRoute({ + tags: ['Images'], + summary: 'Named images', + description: + 'The named-image catalog (UI art the client looks up by name). Not hydrated yet.', + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) // TODO: hydrate from JSON/namedimages.json + .post( + '/api/images/v4/uploadsaved', + describeRoute({ + tags: ['Images'], + summary: 'Upload a saved image', + description: + 'Stores a photo in the shared image bucket under a random key, foldered by image ' + + 'type and upload date (e.g. `sharecamera/2026-06-15/…`) so the bucket stays ' + + 'browsable. The returned `ImageName` is that key — the `img` worker serves the ' + + 'object back by it, slashes and all.\n\n' + + 'The `imgMeta` multipart field is a JSON `SavedImageMetaDTO` describing the upload; ' + + 'malformed JSON is tolerated and the image is still stored, just untyped. A ' + + '`savedImageType` of 4 (profile thumbnail) additionally becomes the account’s ' + + 'avatar, persisted on the account row.', + security: AUTHED, + requestBody: form(UploadImageRequest, 'The image file plus its metadata'), + responses: { + 200: json(UploadImageResponse, 'The stored bucket key'), + 400: json(ErrorResponse, 'No file in the request'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) - const body = await c.req.parseBody().catch(() => ({}) as Record) - // The client posts the file as `image`; accept `file` too for safety. - const candidate = body.image ?? body.file - if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400) - const file = candidate + const body = await c.req.parseBody().catch(() => ({}) as Record) + // The client posts the file as `image`; accept `file` too for safety. + const candidate = body.image ?? body.file + if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400) + const file = candidate - // `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`), - // posted as a multipart field. It carries the metadata we record on the image - // (savedImageType, roomId, accessibility, description, taggedPlayerIds, …). - let meta: Record = {} - if (typeof body.imgMeta === 'string') { - try { - const parsed = JSON.parse(body.imgMeta) - if (parsed && typeof parsed === 'object') meta = parsed as Record - } catch { - // Malformed imgMeta — treat as an untyped upload (still stored). + // `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`), + // posted as a multipart field. It carries the metadata we record on the image + // (savedImageType, roomId, accessibility, description, taggedPlayerIds, …). + let meta: Record = {} + if (typeof body.imgMeta === 'string') { + try { + const parsed = JSON.parse(body.imgMeta) + if (parsed && typeof parsed === 'object') meta = parsed as Record + } catch { + // Malformed imgMeta — treat as an untyped upload (still stored). + } } + // imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}. + const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined) + const savedImageType = num(meta.savedImageType) ?? SavedImageType.None + // roomId / playerEventId use 0 or -1 as "none" — store null in that case. + const roomId = num(meta.roomId) + const playerEventId = num(meta.playerEventId) + + const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'] + const dot = file.name.lastIndexOf('.') + const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : '' + const extension = valid.includes(ext) ? ext : '.jpg' + + // Store the upload in the shared image bucket under a random key, foldered by + // the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so + // the bucket stays browsable over time. The `img` worker serves it back by that + // key (slashes and all), which is the returned ImageName. + const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/' + const datePrefix = new Date().toISOString().slice(0, 10) + '/' + const name = typePrefix + datePrefix + crypto.randomUUID() + extension + await c.env.IMAGES.put(name, await file.arrayBuffer(), { + httpMetadata: { contentType: file.type || 'image/jpeg' }, + }) + + // A profile thumbnail becomes the account's avatar — persist it on the + // account row (a JSON blob in the shared accounts table) so it sticks. + if (savedImageType === SavedImageType.ProfileThumbnail) { + await c.env.DB.prepare( + "UPDATE account SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1" + ) + .bind(id, name) + .run() + } + + // Record the image metadata (the `image` table the img worker owns), pulling + // the fields the client provided in imgMeta. + await createImage(c.env.DB, { + imageName: name, + playerId: id, + type: savedImageType, + accessibility: num(meta.accessibility), + roomId: roomId !== undefined && roomId > 0 ? roomId : null, + description: typeof meta.description === 'string' ? meta.description : null, + taggedPlayerIds: Array.isArray(meta.playerIds) + ? meta.playerIds.filter((v): v is number => typeof v === 'number') + : undefined, + playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null, + }) + + return c.json({ ImageName: name }) } - // imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}. - const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined) - const savedImageType = num(meta.savedImageType) ?? SavedImageType.None - // roomId / playerEventId use 0 or -1 as "none" — store null in that case. - const roomId = num(meta.roomId) - const playerEventId = num(meta.playerEventId) - - const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'] - const dot = file.name.lastIndexOf('.') - const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : '' - const extension = valid.includes(ext) ? ext : '.jpg' - - // Store the upload in the shared image bucket under a random key, foldered by - // the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so - // the bucket stays browsable over time. The `img` worker serves it back by that - // key (slashes and all), which is the returned ImageName. - const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/' - const datePrefix = new Date().toISOString().slice(0, 10) + '/' - const name = typePrefix + datePrefix + crypto.randomUUID() + extension - await c.env.IMAGES.put(name, await file.arrayBuffer(), { - httpMetadata: { contentType: file.type || 'image/jpeg' }, - }) - - // A profile thumbnail becomes the account's avatar — persist it on the - // account row (a JSON blob in the shared accounts table) so it sticks. - if (savedImageType === SavedImageType.ProfileThumbnail) { - await c.env.DB.prepare( - "UPDATE account SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1" - ) - .bind(id, name) - .run() - } - - // Record the image metadata (the `image` table the img worker owns), pulling - // the fields the client provided in imgMeta. - await createImage(c.env.DB, { - imageName: name, - playerId: id, - type: savedImageType, - accessibility: num(meta.accessibility), - roomId: roomId !== undefined && roomId > 0 ? roomId : null, - description: typeof meta.description === 'string' ? meta.description : null, - taggedPlayerIds: Array.isArray(meta.playerIds) - ? meta.playerIds.filter((v): v is number => typeof v === 'number') - : undefined, - playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null, - }) - - return c.json({ ImageName: name }) - }) + ) // Delete one of the caller's saved images ({ ImageName }). Auth-gated. Looks the // image up by name, refuses unless the caller took it (PlayerId), then removes the // metadata row (and its cheers) and the object from R2. 404 for an unknown image, // 403 for someone else's. - .delete('/api/images/v1/deletesaved', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .delete( + '/api/images/v1/deletesaved', + describeRoute({ + tags: ['Images'], + summary: 'Delete one of the caller’s photos', + description: + 'Looks the image up by name and refuses unless the caller took it, then removes ' + + 'the metadata row (and its cheers) and the object from the bucket. The metadata ' + + 'goes first; the R2 delete is idempotent, so a missing object is fine.', + security: AUTHED, + requestBody: jsonBody(DeleteImageRequest, 'The image to delete'), + responses: { + 200: json(SuccessResponse, 'Deleted'), + 400: json(ErrorResponse, 'No ImageName given'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(ErrorResponse, 'Not the caller’s image'), + 404: { description: 'No image by that name' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) - const body = (await c.req.json().catch(() => null)) as { ImageName?: unknown } | null - const imageName = typeof body?.ImageName === 'string' ? body.ImageName : '' - if (imageName === '') return c.json({ error: 'ImageName is required' }, 400) + const body = (await c.req.json().catch(() => null)) as { ImageName?: unknown } | null + const imageName = typeof body?.ImageName === 'string' ? body.ImageName : '' + if (imageName === '') return c.json({ error: 'ImageName is required' }, 400) - const image = await getImageByName(c.env.DB, imageName) - if (!image) return c.notFound() - if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403) + const image = await getImageByName(c.env.DB, imageName) + if (!image) return c.notFound() + if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403) - // Drop the metadata (and cheers) first, then the object. An R2 delete is - // idempotent, so a missing object is fine. - await deleteImage(c.env.DB, image) - await c.env.IMAGES.delete(imageName) + // Drop the metadata (and cheers) first, then the object. An R2 delete is + // idempotent, so a missing object is fine. + await deleteImage(c.env.DB, image) + await c.env.IMAGES.delete(imageName) - return c.json({ success: true }) - }) + return c.json({ success: true }) + } + ) // A room's photo feed — the public images taken in that room. `sort` orders the // feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType // (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array. - .get('/api/images/v4/room/:roomId{[0-9]+}', async (c) => { - const roomId = Number.parseInt(c.req.param('roomId'), 10) - const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0 - const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0 - const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 - const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take)) - }) + .get( + '/api/images/v4/room/:roomId{[0-9]+}', + describeRoute({ + tags: ['Images'], + summary: 'A room’s photo feed', + description: + 'The public images taken in that room.\n\n' + + 'This feed serves the RAW `SavedImage` record — unlike the player photo lists ' + + 'below, which must serve the `ImagesPlayer` projection. The inconsistency is real ' + + 'and load-bearing: both render correctly as they are, and unifying them breaks one ' + + 'of them.', + parameters: [ + idParam('roomId', 'Room id'), + intQuery('sort', '1 = most cheered; anything else = newest first'), + intQuery('filter', 'Narrow by SavedImageType; 0 = all'), + ...pageParams(100), + ], + responses: { 200: json(SavedImageDto.array(), 'The room’s photos') }, + }), + async (c) => { + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0 + const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0 + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take)) + } + ) // A player's photos — the public images that player has taken, newest first. // Paginated via skip/take (take defaults to 100). Returns a bare array of the // client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type). - .get('/api/images/v4/player/:playerId{[0-9]+}', async (c) => { - const playerId = Number.parseInt(c.req.param('playerId'), 10) - const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 - const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take) - return c.json(images.map(toImagesPlayer)) - }) + .get( + '/api/images/v4/player/:playerId{[0-9]+}', + describeRoute({ + tags: ['Images'], + summary: 'A player’s photos', + description: + 'The public images that player has taken, newest first. Serves the client’s ' + + '`ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) ' + + '— the raw `SavedImage` renders blank thumbnails here.', + parameters: [idParam('playerId', 'Account id'), ...pageParams(100)], + responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') }, + }), + async (c) => { + const playerId = Number.parseInt(c.req.param('playerId'), 10) + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take) + return c.json(images.map(toImagesPlayer)) + } + ) // A player's photos with a sort option. `sort` orders the list (1 = most // cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array. - .get('/api/images/v5/player/:playerId{[0-9]+}', async (c) => { - const playerId = Number.parseInt(c.req.param('playerId'), 10) - const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0 - const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 - const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take) - return c.json(images.map(toImagesPlayer)) - }) + .get( + '/api/images/v5/player/:playerId{[0-9]+}', + describeRoute({ + tags: ['Images'], + summary: 'A player’s photos, sortable', + description: 'v4 plus a `sort` option. Same `ImagesPlayer` projection — see the note on v4.', + parameters: [ + idParam('playerId', 'Account id'), + intQuery('sort', '1 = most cheered; anything else = newest first'), + ...pageParams(100), + ], + responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') }, + }), + async (c) => { + const playerId = Number.parseInt(c.req.param('playerId'), 10) + const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0 + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take) + return c.json(images.map(toImagesPlayer)) + } + ) // A player's photo feed — the public images they took plus ones they're tagged // in, newest first. Paginated via skip/take (take defaults to 100). Bare array of // the same ImagesPlayer projection the player photo lists use. - .get('/api/images/v3/feed/player/:playerId{[0-9]+}', async (c) => { - const playerId = Number.parseInt(c.req.param('playerId'), 10) - const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 - const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 - const images = await getPlayerFeed(c.env.DB, playerId, skip, take) - return c.json(images.map(toImagesPlayer)) - }) + .get( + '/api/images/v3/feed/player/:playerId{[0-9]+}', + describeRoute({ + tags: ['Images'], + summary: 'A player’s photo feed', + description: + 'The public images they took PLUS the ones they are tagged in, newest first — the ' + + 'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.', + parameters: [idParam('playerId', 'Account id'), ...pageParams(100)], + responses: { 200: json(ImagesPlayerDto.array(), 'The player’s feed') }, + }), + async (c) => { + const playerId = Number.parseInt(c.req.param('playerId'), 10) + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + const images = await getPlayerFeed(c.env.DB, playerId, skip, take) + return c.json(images.map(toImagesPlayer)) + } + ) // Global slideshow feed — the most recent publicly-listable ShareCamera photos // (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its // creator's username and room name. Public (no auth): it only surfaces already-public // images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`, // where ValidTill is a short (2-minute) cache hint the client refreshes against. - .get('/api/images/v1/slideshow', async (c) => { - const Images = await getSlideshowImages(c.env.DB) - const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString() - return c.json({ Images, ValidTill }) - }) + .get( + '/api/images/v1/slideshow', + describeRoute({ + tags: ['Images'], + summary: 'The global slideshow feed', + description: + 'The most recent publicly-listable ShareCamera photos across all rooms, newest ' + + 'first, each joined to its creator’s username and room name.\n\n' + + 'Deliberately public — it surfaces only already-public images and backs the ' + + 'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' + + 'client refreshes against.', + responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') }, + }), + async (c) => { + const Images = await getSlideshowImages(c.env.DB) + const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString() + return c.json({ Images, ValidTill }) + } + ) // Image metadata by filename. Returns the stored SavedImage record, or 404 when // there's no metadata row for that name. - .get('/api/images/v6', async (c) => { - const name = c.req.query('name') ?? '' - if (name === '') return c.json({ error: 'name is required' }, 400) - const image = await getImageByName(c.env.DB, name) - return image ? c.json(image) : c.notFound() - }) + .get( + '/api/images/v6', + describeRoute({ + tags: ['Images'], + summary: 'Image metadata by filename', + description: + 'The stored `SavedImage` record for a bucket key. 404s when the object exists but ' + + 'has no metadata row.', + parameters: [stringQuery('name', 'The image name (bucket key); required')], + responses: { + 200: json(SavedImageDto, 'The image record'), + 400: json(ErrorResponse, 'No name given'), + 404: { description: 'No metadata for that name' }, + }, + }), + async (c) => { + const name = c.req.query('name') ?? '' + if (name === '') return c.json({ error: 'name is required' }, 400) + const image = await getImageByName(c.env.DB, name) + return image ? c.json(image) : c.notFound() + } + ) // Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Persists the // caller's cheer to `image_interaction` and resyncs the image's CheerCount. - .post('/api/images/v1/cheer', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const body = (await c.req.json().catch(() => null)) as { - SavedImageId?: number - Cheer?: boolean - } | null - if (body && typeof body.SavedImageId === 'number') { - await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true) + .post( + '/api/images/v1/cheer', + describeRoute({ + tags: ['Images'], + summary: 'Cheer or un-cheer a photo', + description: + 'Persists the caller’s cheer and resyncs the image’s `CheerCount`. A body naming no ' + + '`SavedImageId` is accepted and ignored — the ack is the same either way.', + security: AUTHED, + requestBody: jsonBody(CheerImageRequest, 'The image and the new cheer state'), + responses: { + 200: json(SuccessResponse, 'Recorded'), + 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 { + SavedImageId?: number + Cheer?: boolean + } | null + if (body && typeof body.SavedImageId === 'number') { + await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true) + } + return c.json({ success: true }) } - return c.json({ success: true }) - }) + ) // Whether the caller has cheered each of the given saved-image ids (`?id=55&id=54`, // and each `id` may itself be a comma-separated list). Auth-gated. Returns one // `{ SavedImageId, IsCheered }` per requested id, in order. - .get('/api/images/v5/cheered/bulk', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const ids = - c.req - .queries('id') - ?.flatMap((raw) => raw.split(',')) - .map((raw) => Number.parseInt(raw.trim(), 10)) - .filter((imageId) => !Number.isNaN(imageId)) ?? [] - const cheered = await getCheeredImageIds(c.env.DB, id, ids) - return c.json( - ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) })) - ) - }) + .get( + '/api/images/v5/cheered/bulk', + describeRoute({ + tags: ['Images'], + summary: 'Which photos the caller has cheered', + description: + 'One `{ SavedImageId, IsCheered }` per requested id, in request order — the client ' + + 'fills in the cheer buttons on a photo grid from this.', + security: AUTHED, + parameters: [ + intQuery('id', 'Repeatable; each value may be a comma-separated list of image ids'), + ], + responses: { + 200: json(CheeredEntry.array(), 'One entry per requested id, in order'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const ids = + c.req + .queries('id') + ?.flatMap((raw) => raw.split(',')) + .map((raw) => Number.parseInt(raw.trim(), 10)) + .filter((imageId) => !Number.isNaN(imageId)) ?? [] + const cheered = await getCheeredImageIds(c.env.DB, id, ids) + return c.json( + ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) })) + ) + } + ) diff --git a/apps/api/src/routes/inventory.ts b/apps/api/src/routes/inventory.ts index e24fddc..2c21544 100644 --- a/apps/api/src/routes/inventory.ts +++ b/apps/api/src/routes/inventory.ts @@ -1,14 +1,46 @@ import { Hono } from 'hono' +import { describeRoute } from 'hono-openapi' import { authedId, unauthorized } from '../http' +import { AUTHED, json, JsonArray, UNAUTHORIZED_RESPONSE } from '../openapi' import type { App } from '../context' // ---- Inventory ------------------------------------------------------------- +// The equipment/consumables the client actually reads are served by the `econ` worker, +// on the econ host. These are the same paths on this host, kept as stubs because some +// client builds probe them here first. export const inventoryRoutes = new Hono({ strict: false }) - .get('/api/equipment/v2/getUnlocked', (c) => c.json([])) - .get('/api/consumables/v2/getUnlocked', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - return c.json([]) // TODO: query ConsumableItems - }) + .get( + '/api/equipment/v2/getUnlocked', + describeRoute({ + tags: ['Inventory'], + summary: 'Unlocked equipment', + description: + 'A stub on this host — the real inventory lives in the `econ` worker, which serves ' + + 'this same path with the player’s equipment. Always an empty list here, and ' + + 'unlike the econ route it does not require a token.', + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) + .get( + '/api/consumables/v2/getUnlocked', + describeRoute({ + tags: ['Inventory'], + summary: 'Unlocked consumables', + description: + 'A stub on this host — the real consumables live in the `econ` worker. Auth-gated ' + + 'even so, then always an empty list.', + security: AUTHED, + responses: { + 200: json(JsonArray, 'An empty list'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json([]) // TODO: query ConsumableItems + } + ) diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 0fec8fa..1756c80 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -1,4 +1,14 @@ import { Hono } from 'hono' +import { describeRoute } from 'hono-openapi' + +import { + BareBoolean, + DeviceIdRequest, + form, + json, + JsonArray, + ModerationBlockDetails, +} from '../openapi' import type { App } from '../context' @@ -8,21 +18,56 @@ export const moderationRoutes = new Hono({ strict: false }) // ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is // -1 (no category) rather than 0, which is a real category; `Message` is null, not // an empty string — the client distinguishes "no message" from a blank one. - .get('/api/PlayerReporting/v1/moderationBlockDetails', (c) => - c.json({ - ReportCategory: -1, - Duration: 0, - GameSessionId: 0, - IsBan: false, - IsHostKick: false, - IsVoiceModAutoban: false, - Message: null, - PlayerIdReporter: null, - TimeoutStartedAt: null, - }) + .get( + '/api/PlayerReporting/v1/moderationBlockDetails', + describeRoute({ + tags: ['Moderation'], + summary: 'Whether the caller is blocked', + description: + 'Ban / timeout / host-kick state for the caller. There is no ban storage yet, so ' + + 'this is always the “not blocked” answer. Two details matter to the client: ' + + '`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' + + '`Message` is null rather than an empty string — the client distinguishes “no ' + + 'message” from a blank one.', + responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') }, + }), + (c) => + c.json({ + ReportCategory: -1, + Duration: 0, + GameSessionId: 0, + IsBan: false, + IsHostKick: false, + IsVoiceModAutoban: false, + Message: null, + PlayerIdReporter: null, + TimeoutStartedAt: null, + }) + ) + .get( + '/api/PlayerReporting/v1/voteToKickReasons', + describeRoute({ + tags: ['Moderation'], + summary: 'Vote-to-kick reasons', + description: + 'The reasons offered when starting a vote-to-kick. Not hydrated yet, so the list ' + + 'is empty.', + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) // TODO: hydrate from JSON/vtkreasons.json + .post( + '/api/PlayerReporting/v1/hile', + describeRoute({ + tags: ['Moderation'], + summary: 'Report submission sink', + description: + 'A player report. Nothing stores reports, so this accepts whatever it is sent and ' + + 'answers a bare `false`.', + responses: { 200: json(BareBoolean, 'A bare JSON `false`') }, + }), + (c) => c.json(false) ) - .get('/api/PlayerReporting/v1/voteToKickReasons', (c) => c.json([])) // TODO: hydrate from JSON/vtkreasons.json - .post('/api/PlayerReporting/v1/hile', (c) => c.json(false)) // The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`, // `platform`), rotating from the id it thinks we hold to the current one. Carries no @@ -34,4 +79,25 @@ export const moderationRoutes = new Hono({ strict: false }) // https://github.com/djdevin/recnet-plugin we disable the device ID check to enable // account creation. Nothing in the logs, client just hangs, who knows what it is // waiting for. - .post('/api/PlayerReporting/v1/deviceId', (c) => c.json([])); + .post( + '/api/PlayerReporting/v1/deviceId', + describeRoute({ + tags: ['Moderation'], + summary: 'Device id rotation (known broken)', + description: + 'The client reporting its device id, rotating from the one it thinks we hold to ' + + 'the current one. It carries no bearer token and fires *before* account creation, ' + + 'so there is no caller to attribute the id to and nothing to store it against — ' + + 'we accept it and drop it.\n\n' + + '**Known broken.** No response shape found so far keeps the client happy: it ' + + 'hangs during account creation with nothing in the logs. The real service answers ' + + 'a `{ success, error }` envelope; we currently answer an empty array, which does ' + + 'not help either. The workaround is to disable the device-id check client-side ' + + '(see [recnet-plugin](https://github.com/djdevin/recnet-plugin)).', + requestBody: form(DeviceIdRequest, 'The id rotation'), + responses: { + 200: json(JsonArray, 'An empty array — see the note above; this is not the real shape'), + }, + }), + (c) => c.json([]) + ) diff --git a/apps/api/src/routes/progression.ts b/apps/api/src/routes/progression.ts index b64a93e..25c9bc6 100644 --- a/apps/api/src/routes/progression.ts +++ b/apps/api/src/routes/progression.ts @@ -1,6 +1,17 @@ import { Hono } from 'hono' +import { describeRoute } from 'hono-openapi' import { parseFormIds, queryIds } from '../http' +import { + BulkIdsRequest, + form, + idParam, + intQuery, + json, + JsonArray, + ProgressionDto, + ReputationDto, +} from '../openapi' import type { App } from '../context' @@ -27,39 +38,148 @@ function defaultReputation(id: number) { } } +/** + * The repeated `id` query param the 2023 client uses on the bulk GET forms — each value + * may itself be a comma-separated list, so `?id=1,2&id=3` is three ids. + */ +const BULK_ID_QUERY = [ + intQuery('id', 'Repeatable; each value may be a comma-separated list of account ids'), +] + +/** The `Ids` form body the bulk POST forms take. */ +const BULK_ID_BODY = form(BulkIdsRequest, 'The account ids to look up') + // ---- Reputation / progression ---------------------------------------------- export const progressionRoutes = new Hono({ strict: false }) - .get('/api/playerReputation/v1/:id', (c) => - c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10))) + .get( + '/api/playerReputation/v1/:id', + describeRoute({ + tags: ['Progression'], + summary: 'A player’s reputation', + description: + 'The cheer counters shown on a player’s profile. No cheers are stored yet, so ' + + 'every player gets the same all-zero record with full cheer credit.', + parameters: [idParam('id', 'Account id')], + responses: { 200: json(ReputationDto, 'The player’s reputation') }, + }), + (c) => c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10))) + ) + .get( + '/api/players/v1/progression/:id', + describeRoute({ + tags: ['Progression'], + summary: 'A player’s level and XP', + description: 'Nothing awards XP yet, so everyone is level 1 with 0 XP.', + parameters: [idParam('id', 'Account id')], + responses: { 200: json(ProgressionDto, 'The player’s progression') }, + }), + (c) => { + const id = Number.parseInt(c.req.param('id'), 10) + return c.json({ PlayerId: id, Level: 1, XP: 0 }) + } + ) + .post( + '/api/playerReputation/v1/bulk', + describeRoute({ + tags: ['Progression'], + summary: 'Reputations in bulk (v1)', + description: + 'The older bulk form, superseded by v2. It answers an empty list rather than ' + + 'synthesizing defaults — the client only uses v2.', + requestBody: BULK_ID_BODY, + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) ) - .get('/api/players/v1/progression/:id', (c) => { - const id = Number.parseInt(c.req.param('id'), 10) - return c.json({ PlayerId: id, Level: 1, XP: 0 }) - }) - .post('/api/playerReputation/v1/bulk', (c) => c.json([])) // TODO: hydrate from JSON/bulkprogression.json // Synthesize a default reputation per requested id (the intended behavior; // the DB-less fallback reads a static JSON file instead). - .post('/api/playerReputation/v2/bulk', async (c) => { - const ids = await parseFormIds(c) - return c.json(ids.map(defaultReputation)) - }) + .post( + '/api/playerReputation/v2/bulk', + describeRoute({ + tags: ['Progression'], + summary: 'Reputations in bulk', + description: + 'One default reputation per requested id, in request order. Ids that name no ' + + 'account still get a record — the client renders a profile card from it.', + requestBody: BULK_ID_BODY, + responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') }, + }), + async (c) => { + const ids = await parseFormIds(c) + return c.json(ids.map(defaultReputation)) + } + ) // The 2023 client calls this as a GET with repeated `id` query params. - .get('/api/playerReputation/v2/bulk', (c) => c.json(queryIds(c).map(defaultReputation))) - .post('/api/players/v1/progression/bulk', async (c) => { - await parseFormIds(c) // TODO: query PlayerProgressions for these ids - return c.json([]) - }) + .get( + '/api/playerReputation/v2/bulk', + describeRoute({ + tags: ['Progression'], + summary: 'Reputations in bulk (GET form)', + description: + 'What the 2023 client sends: the same bulk lookup with the ids as repeated query ' + + 'params instead of a form body.', + parameters: BULK_ID_QUERY, + responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') }, + }), + (c) => c.json(queryIds(c).map(defaultReputation)) + ) + .post( + '/api/players/v1/progression/bulk', + describeRoute({ + tags: ['Progression'], + summary: 'Progressions in bulk (v1)', + description: 'No progression is stored yet, so this is an empty list.', + requestBody: BULK_ID_BODY, + responses: { 200: json(JsonArray, 'An empty list') }, + }), + async (c) => { + await parseFormIds(c) // TODO: query PlayerProgressions for these ids + return c.json([]) + } + ) // v2 is identical to v1 — same form-id parse + PlayerProgressions query. - .post('/api/players/v2/progression/bulk', async (c) => { - await parseFormIds(c) // TODO: query PlayerProgressions for these ids - return c.json([]) - }) + .post( + '/api/players/v2/progression/bulk', + describeRoute({ + tags: ['Progression'], + summary: 'Progressions in bulk (v2)', + description: 'Identical to v1 — same ids in, same empty list out.', + requestBody: BULK_ID_BODY, + responses: { 200: json(JsonArray, 'An empty list') }, + }), + async (c) => { + await parseFormIds(c) // TODO: query PlayerProgressions for these ids + return c.json([]) + } + ) // The 2023 client calls this as a GET with repeated `id` query params. // Return a default progression per requested id. - .get('/api/players/v2/progression/bulk', (c) => - c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 }))) + .get( + '/api/players/v2/progression/bulk', + describeRoute({ + tags: ['Progression'], + summary: 'Progressions in bulk (GET form)', + description: + 'What the 2023 client sends. Unlike the POST forms this one does answer — a ' + + 'default level-1 progression per requested id, in request order.', + parameters: BULK_ID_QUERY, + responses: { 200: json(ProgressionDto.array(), 'One progression per requested id') }, + }), + (c) => c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 }))) + ) + .post( + '/api/v1/progression/bulk', + describeRoute({ + tags: ['Progression'], + summary: 'Progressions in bulk (unversioned path)', + description: + 'An older unversioned path some client builds still call. Same empty answer as ' + + 'the versioned POST forms.', + requestBody: BULK_ID_BODY, + responses: { 200: json(JsonArray, 'An empty list') }, + }), + async (c) => { + await parseFormIds(c) // TODO: query PlayerProgressions for these ids + return c.json([]) + } ) - .post('/api/v1/progression/bulk', async (c) => { - await parseFormIds(c) // TODO: query PlayerProgressions for these ids - return c.json([]) - }) diff --git a/apps/api/src/routes/rooms.ts b/apps/api/src/routes/rooms.ts index 439ca73..08284a7 100644 --- a/apps/api/src/routes/rooms.ts +++ b/apps/api/src/routes/rooms.ts @@ -1,42 +1,101 @@ import { Hono } from 'hono' +import { describeRoute } from 'hono-openapi' import { getRoomById } from '@repo/domain' import { authedId } from '../http' +import { + AUTHED, + BareBoolean, + form, + json, + JsonArray, + QuickPlayResponse, + TagFilters, + VerifyRoleRequest, +} from '../openapi' import type { App } from '../context' // ---- Room keys / quick play / rooms ---------------------------------------- export const roomRoutes = new Hono({ strict: false }) - .get('/api/roomkeys/v1/mine', (c) => c.json([])) - .get('/api/roomkeys/v1/room', (c) => c.json([])) - .get('/api/quickPlay/v1/getandclear', (c) => - c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null }) + .get( + '/api/roomkeys/v1/mine', + describeRoute({ + tags: ['Rooms'], + summary: 'The caller’s room keys', + description: 'Nothing issues room keys yet, so this is an empty list.', + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) + .get( + '/api/roomkeys/v1/room', + describeRoute({ + tags: ['Rooms'], + summary: 'A room’s keys', + description: 'Nothing issues room keys yet, so this is an empty list.', + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) + .get( + '/api/quickPlay/v1/getandclear', + describeRoute({ + tags: ['Rooms'], + summary: 'Take the pending quick-play action', + description: + 'A read-and-clear of whatever quick-play action is queued for the caller (joining ' + + 'a friend, an invite deep link). Nothing queues one yet, so all three fields are ' + + 'null — which the client reads as “nothing to do”.', + responses: { 200: json(QuickPlayResponse, 'All null — no pending action') }, + }), + (c) => c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null }) ) // Room search filters. The client deserializes this into an object (not an // array) — shape from the 2025 reference. - .get('/api/rooms/v1/filters', (c) => - c.json({ - PinnedFilters: [ - 'recroomoriginal', - 'community', - 'featured', - 'quest', - 'pvp', - 'hangout', - 'game', - 'art', - 'store', - 'tutorial', - 'fandom', - 'performance', - 'action', - 'horror', - ], - PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'], - TrendingFilters: ['roleplay', 'nomp', 'rp', 'casual', 'fun', 'action', 'military', 'sports'], - }) + .get( + '/api/rooms/v1/filters', + describeRoute({ + tags: ['Rooms'], + summary: 'Room browse filter chips', + description: + 'The filter chips on the room browse screen. Static, taken from the 2025 ' + + 'reference. The client deserializes this as an OBJECT, not an array — and unlike ' + + 'the invention/event filters, `TrendingFilters` here is a real list.', + responses: { 200: json(TagFilters, 'The filter chips') }, + }), + (c) => + c.json({ + PinnedFilters: [ + 'recroomoriginal', + 'community', + 'featured', + 'quest', + 'pvp', + 'hangout', + 'game', + 'art', + 'store', + 'tutorial', + 'fandom', + 'performance', + 'action', + 'horror', + ], + PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'], + TrendingFilters: [ + 'roleplay', + 'nomp', + 'rp', + 'casual', + 'fun', + 'action', + 'military', + 'sports', + ], + }) ) // Verify the caller holds at least `role` in a room. Params come from the form @@ -44,29 +103,49 @@ export const roomRoutes = new Hono({ strict: false }) // room creator always passes; otherwise the caller needs a Roles entry with // `Role >= role`. Any failure (no token, unknown room, insufficient role) is // `false`. The `context` field (e.g. MakerPen) is accepted and ignored. - .post('/api/rooms/v1/verifyRole', async (c) => { - const body = (await c.req.parseBody().catch(() => ({}))) as Record - const param = (name: string): string => { - const form = body[name] - if (typeof form === 'string' && form !== '') return form - return c.req.query(name) ?? '' + .post( + '/api/rooms/v1/verifyRole', + describeRoute({ + tags: ['Rooms'], + summary: 'Verify the caller’s role in a room', + description: + 'Whether the caller holds at least `role` in the room — the gate the client checks ' + + 'before letting someone into the Maker Pen. The room’s creator always passes; ' + + 'anyone else needs a `Roles` entry at that level or higher.\n\n' + + 'Answers a bare `true`/`false`, and every failure is `false` rather than an error ' + + 'status: no token, an unknown room, and an insufficient role are indistinguishable ' + + 'to the client. Params are read from the form body, falling back to the query ' + + 'string. Room data is read from the shared rooms database (owned by the `rooms` ' + + 'worker).', + security: AUTHED, + requestBody: form(VerifyRoleRequest, 'The room and the role level to check'), + responses: { 200: json(BareBoolean, 'Whether the caller holds the role') }, + }), + async (c) => { + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const param = (name: string): string => { + // (named `fromBody` rather than `form` — the openapi helper owns that name here) + const fromBody = body[name] + if (typeof fromBody === 'string' && fromBody !== '') return fromBody + return c.req.query(name) ?? '' + } + const roomId = Number.parseInt(param('roomId'), 10) + const role = Number.parseInt(param('role'), 10) + + const accountId = await authedId(c) + if (accountId === null || Number.isNaN(roomId)) return c.json(false) + + const room = await getRoomById(c.env.DB, roomId) + if (!room) return c.json(false) + + // The creator always passes. + if (room.CreatorAccountId === accountId) return c.json(true) + + // Otherwise the caller needs a room role at least as high as requested. + const roles = Array.isArray(room.Roles) ? (room.Roles as Array>) : [] + const hasRole = roles.some( + (r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0) + ) + return c.json(hasRole) } - const roomId = Number.parseInt(param('roomId'), 10) - const role = Number.parseInt(param('role'), 10) - - const accountId = await authedId(c) - if (accountId === null || Number.isNaN(roomId)) return c.json(false) - - const room = await getRoomById(c.env.DB, roomId) - if (!room) return c.json(false) - - // The creator always passes. - if (room.CreatorAccountId === accountId) return c.json(true) - - // Otherwise the caller needs a room role at least as high as requested. - const roles = Array.isArray(room.Roles) ? (room.Roles as Array>) : [] - const hasRole = roles.some( - (r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0) - ) - return c.json(hasRole) - }) + ) diff --git a/apps/api/src/routes/social.ts b/apps/api/src/routes/social.ts index ae30d0d..a88bfb7 100644 --- a/apps/api/src/routes/social.ts +++ b/apps/api/src/routes/social.ts @@ -1,8 +1,19 @@ import { Hono } from 'hono' +import { describeRoute } from 'hono-openapi' import { logger } from '@repo/hono-helpers' import { authedId, unauthorized } from '../http' +import { + AckResponse, + AUTHED, + ErrorResponse, + intQuery, + json, + JsonArray, + RelationshipDto, + UNAUTHORIZED_RESPONSE, +} from '../openapi' import { acceptFriendRequest, addFriend, @@ -14,7 +25,11 @@ import { import type { Context } from 'hono' import type { App } from '../context' -import type { RelationshipChange, RelationshipFlag, RelationshipResponse } from '../relationships-db' +import type { + RelationshipChange, + RelationshipFlag, + RelationshipResponse, +} from '../relationships-db' /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' @@ -106,15 +121,83 @@ async function targetPlayerId(c: Context): Promise { return null } +/** + * How every relationship mutation names its target. The handler is liberal — it also + * accepts `PlayerId`/`playerId`/`Id` from a JSON or form body — but the client sends the + * query param, so that's what the spec documents. + */ +const TARGET_PARAMS = [ + intQuery('id', 'The other player. The client uses this form.'), + intQuery('playerId', 'Accepted as an alias for `id`'), +] + +/** + * A `describeRoute` spec for one of the four friend-graph mutations. These change state + * both players can see, so each also pushes a RelationshipChanged notification to both + * sides; the HTTP body is the caller's own projection. + */ +function friendMutation(summary: string, description: string) { + return describeRoute({ + tags: ['Social'], + summary, + description, + security: AUTHED, + parameters: TARGET_PARAMS, + responses: { + 200: json(RelationshipDto, 'The relationship, from the caller’s point of view'), + 400: json(ErrorResponse, 'No target id, or the caller targeting themselves'), + 401: UNAUTHORIZED_RESPONSE, + }, + }) +} + +/** + * A `describeRoute` spec for a per-side flag toggle (favorite / ignore / mute and their + * inverses). The write lands on the caller's own side of the row, so only the caller is + * notified — and the resulting relationship rides that notification, not the response, + * which is just the ack. + */ +function flagToggle(summary: string, description: string) { + return describeRoute({ + tags: ['Social'], + summary, + description, + security: AUTHED, + parameters: TARGET_PARAMS, + responses: { + 200: json(AckResponse, 'The ack; the relationship arrives over the notification hub'), + 400: json(ErrorResponse, 'No target id, or the caller targeting themselves'), + 401: UNAUTHORIZED_RESPONSE, + }, + }) +} + // ---- Social ---------------------------------------------------------------- export const socialRoutes = new Hono({ strict: false }) // The authed player's relationships, projected from their point of view — a bare // array of RelationshipResponse. Auth-gated. - .get('/api/relationships/v2/get', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - return c.json(await getRelationshipsForPlayer(c.env.DB, id)) - }) + .get( + '/api/relationships/v2/get', + describeRoute({ + tags: ['Social'], + summary: 'The caller’s relationships', + description: + 'Every relationship the signed-in player has, projected from their point of view — ' + + 'a bare array. `None` rows are included: that is how an unfriending, or an ' + + 'ignore/mute of someone you were never friends with, is recorded, and they still ' + + 'carry the caller’s favorited/ignored/muted flags.', + security: AUTHED, + responses: { + 200: json(RelationshipDto.array(), 'The caller’s relationships'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json(await getRelationshipsForPlayer(c.env.DB, id)) + } + ) // Send a friend request to another player (the target arrives as `?id=`). The // client calls this as a GET; the mutations accept GET or POST (the Go handlers @@ -125,49 +208,84 @@ export const socialRoutes = new Hono({ strict: false }) // notifies BOTH sides with their own projection (see notifyBoth) on top of the HTTP // response. A no-op — re-sending an outstanding request, accepting nothing pending — // notifies nobody. - .on(['GET', 'POST'], '/api/relationships/v2/sendfriendrequest', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const target = await targetPlayerId(c) - if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - const change = await sendFriendRequest(c.env.DB, id, target) - await notifyBoth(c, id, target, change) - return c.json(change.self) - }) + .on( + ['GET', 'POST'], + '/api/relationships/v2/sendfriendrequest', + friendMutation( + 'Send a friend request', + 'Offer friendship to another player. Re-sending an outstanding request is a no-op ' + + 'and notifies nobody.' + ), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + const change = await sendFriendRequest(c.env.DB, id, target) + await notifyBoth(c, id, target, change) + return c.json(change.self) + } + ) // Accept a pending friend request from another player (`?id=`). Auth-gated. - .on(['GET', 'POST'], '/api/relationships/v2/acceptfriendrequest', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const target = await targetPlayerId(c) - if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - const change = await acceptFriendRequest(c.env.DB, id, target) - await notifyBoth(c, id, target, change) - return c.json(change.self) - }) + .on( + ['GET', 'POST'], + '/api/relationships/v2/acceptfriendrequest', + friendMutation( + 'Accept a friend request', + 'Turn a pending incoming request into a friendship. Accepting nothing pending is a ' + + 'no-op and notifies nobody.' + ), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + const change = await acceptFriendRequest(c.env.DB, id, target) + await notifyBoth(c, id, target, change) + return c.json(change.self) + } + ) // Remove a friend / cancel a request / decline a request (`?id=`). The row is kept as // a None relationship so the per-side flags survive (see removeFriend). Auth-gated. - .on(['GET', 'POST'], '/api/relationships/v2/removefriend', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const target = await targetPlayerId(c) - if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - const change = await removeFriend(c.env.DB, id, target) - await notifyBoth(c, id, target, change) - return c.json(change.self) - }) + .on( + ['GET', 'POST'], + '/api/relationships/v2/removefriend', + friendMutation( + 'Unfriend, or cancel/decline a request', + 'All three are the same operation. The row is kept as a `None` relationship so the ' + + 'per-side favorited/ignored/muted flags survive.' + ), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + const change = await removeFriend(c.env.DB, id, target) + await notifyBoth(c, id, target, change) + return c.json(change.self) + } + ) // Directly add another player as a friend, no pending-request step (`?id=`). Auth-gated. - .on(['GET', 'POST'], '/api/relationships/v2/addfriend', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const target = await targetPlayerId(c) - if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - const change = await addFriend(c.env.DB, id, target) - await notifyBoth(c, id, target, change) - return c.json(change.self) - }) + .on( + ['GET', 'POST'], + '/api/relationships/v2/addfriend', + friendMutation( + 'Befriend directly', + 'Become friends with no pending-request step. Already being friends is a no-op.' + ), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + const change = await addFriend(c.env.DB, id, target) + await notifyBoth(c, id, target, change) + return c.json(change.self) + } + ) // Ignore / mute another player, and their inverses unignore / unmute (target // arrives as `PlayerId` in the POST body). These set a per-player flag on the @@ -176,54 +294,116 @@ export const socialRoutes = new Hono({ strict: false }) // friended. The un- variants just clear the same flag. Auth-gated. The resulting // relationship is delivered via a RelationshipChanged hub notification (see // applyFlag); the HTTP body is just the { Success, Message } ack. - .on(['GET', 'POST'], '/api/relationships/v1/ignore', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const target = await targetPlayerId(c) - if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return applyFlag(c, id, target, 'ignored', true) - }) - .on(['GET', 'POST'], '/api/relationships/v1/unignore', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const target = await targetPlayerId(c) - if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return applyFlag(c, id, target, 'ignored', false) - }) - .on(['GET', 'POST'], '/api/relationships/v1/mute', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const target = await targetPlayerId(c) - if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return applyFlag(c, id, target, 'muted', true) - }) - .on(['GET', 'POST'], '/api/relationships/v1/unmute', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const target = await targetPlayerId(c) - if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return applyFlag(c, id, target, 'muted', false) - }) + .on( + ['GET', 'POST'], + '/api/relationships/v1/ignore', + flagToggle( + 'Ignore a player', + 'Sets the caller’s `ignored` flag. Ignoring someone you have no relationship with ' + + 'creates a bare (`None`) row to hold the flag.' + ), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + return applyFlag(c, id, target, 'ignored', true) + } + ) + .on( + ['GET', 'POST'], + '/api/relationships/v1/unignore', + flagToggle('Stop ignoring a player', 'Clears the caller’s `ignored` flag.'), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + return applyFlag(c, id, target, 'ignored', false) + } + ) + .on( + ['GET', 'POST'], + '/api/relationships/v1/mute', + flagToggle( + 'Mute a player', + 'Sets the caller’s `muted` flag. Like ignore, this works on a player you have no ' + + 'relationship with.' + ), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + return applyFlag(c, id, target, 'muted', true) + } + ) + .on( + ['GET', 'POST'], + '/api/relationships/v1/unmute', + flagToggle('Unmute a player', 'Clears the caller’s `muted` flag.'), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + return applyFlag(c, id, target, 'muted', false) + } + ) // Favorite / unfavorite another player (the client calls these as a GET with the // target in `?id=`). Same per-side flag mechanics as ignore/mute above: the write // lands on the *caller's* side of the row, and favoriting someone you have no // relationship with creates a bare (None) row. Auth-gated. Result rides a // RelationshipChanged notification; the body is the { Success, Message } ack. - .on(['GET', 'POST'], '/api/relationships/v1/favorite', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const target = await targetPlayerId(c) - if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return applyFlag(c, id, target, 'favorited', true) - }) - .on(['GET', 'POST'], '/api/relationships/v1/unfavorite', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) - const target = await targetPlayerId(c) - if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) - return applyFlag(c, id, target, 'favorited', false) - }) + .on( + ['GET', 'POST'], + '/api/relationships/v1/favorite', + flagToggle( + 'Favorite a player', + 'Sets the caller’s `favorited` flag — what pins a player to the top of their friends ' + + 'list. Works on a player you have no relationship with.' + ), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + return applyFlag(c, id, target, 'favorited', true) + } + ) + .on( + ['GET', 'POST'], + '/api/relationships/v1/unfavorite', + flagToggle('Unfavorite a player', 'Clears the caller’s `favorited` flag.'), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const target = await targetPlayerId(c) + if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400) + return applyFlag(c, id, target, 'favorited', false) + } + ) - .get('/api/messages/v2/get', (c) => c.json([])) - .get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([])) + .get( + '/api/messages/v2/get', + describeRoute({ + tags: ['Social'], + summary: 'Direct messages', + description: 'There is no message store yet, so this is always an empty list.', + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) + .get( + '/api/messages/v1/favoriteFriendOnlineStatus', + describeRoute({ + tags: ['Social'], + summary: 'Online status of favorited friends', + description: + 'Presence for the caller’s favorited friends. Presence lives in the `match` ' + + 'worker and is not joined in here yet, so this is an empty list.', + responses: { 200: json(JsonArray, 'An empty list') }, + }), + (c) => c.json([]) + ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index df5b950..8e21b28 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1850,3 +1850,144 @@ describe('relationships', () => { for (const n of sent) expect(n.data.RelationshipType).toBe(3) }) }) + +describe('openapi', () => { + 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'], …)` relationship routes contribute 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([ + 'DELETE /api/images/v1/deletesaved', + 'GET /api/PlayerReporting/v1/moderationBlockDetails', + 'GET /api/PlayerReporting/v1/voteToKickReasons', + 'GET /api/activities/charades/v1/words/{activity}', + 'GET /api/announcement/v1/get', + 'GET /api/communityboard/v2/current', + 'GET /api/config/v1/amplitude', + 'GET /api/config/v1/azurespeech', + 'GET /api/config/v1/backtrace', + 'GET /api/config/v2', + 'GET /api/consumables/v2/getUnlocked', + 'GET /api/customAvatarItems/v1/featured', + 'GET /api/customAvatarItems/v1/hot', + 'GET /api/customAvatarItems/v1/isCreationAllowedForAccount', + 'GET /api/customAvatarItems/v1/isCreationEnabled', + 'GET /api/customAvatarItems/v1/isRenderingEnabled', + 'GET /api/customAvatarItems/v2/fromCreator/{accountId}', + 'GET /api/equipment/v2/getUnlocked', + 'GET /api/gameconfigs/v1/all', + 'GET /api/images/v1/slideshow', + 'GET /api/images/v2/named', + 'GET /api/images/v3/feed/player/{playerId}', + 'GET /api/images/v4/player/{playerId}', + 'GET /api/images/v4/room/{roomId}', + 'GET /api/images/v5/cheered/bulk', + 'GET /api/images/v5/player/{playerId}', + 'GET /api/images/v6', + 'GET /api/inventions/v1', + 'GET /api/inventions/v1/details', + 'GET /api/inventions/v1/featured', + 'GET /api/inventions/v1/personaldetails/{inventionId}', + 'GET /api/inventions/v1/room', + 'GET /api/inventions/v1/tagfilters', + 'GET /api/inventions/v1/toptoday', + 'GET /api/inventions/v1/update', + 'GET /api/inventions/v1/version', + 'GET /api/inventions/v2/batch', + 'GET /api/inventions/v2/mine', + 'GET /api/inventions/v2/search', + 'GET /api/inventions/v3/publish', + 'GET /api/keepsakes/categories', + 'GET /api/keepsakes/globalconfig', + 'GET /api/keepsakes/rooms/{roomId}', + 'GET /api/messages/v1/favoriteFriendOnlineStatus', + 'GET /api/messages/v2/get', + 'GET /api/playerReputation/v1/{id}', + 'GET /api/playerReputation/v2/bulk', + 'GET /api/playerevents/v1/all', + 'GET /api/playerevents/v1/club/{clubId}', + 'GET /api/playerevents/v1/clubs', + 'GET /api/playerevents/v1/tagfilters', + 'GET /api/players/v1/progression/{id}', + 'GET /api/players/v2/progression/bulk', + 'GET /api/quickPlay/v1/getandclear', + 'GET /api/relationships/v1/favorite', + 'GET /api/relationships/v1/ignore', + 'GET /api/relationships/v1/mute', + 'GET /api/relationships/v1/unfavorite', + 'GET /api/relationships/v1/unignore', + 'GET /api/relationships/v1/unmute', + 'GET /api/relationships/v2/acceptfriendrequest', + 'GET /api/relationships/v2/addfriend', + 'GET /api/relationships/v2/get', + 'GET /api/relationships/v2/removefriend', + 'GET /api/relationships/v2/sendfriendrequest', + 'GET /api/roomkeys/v1/mine', + 'GET /api/roomkeys/v1/room', + 'GET /api/rooms/v1/filters', + 'GET /api/versioncheck/v4', + 'GET /voice/config', + 'POST /api/CampusCard/v1/UpdateAndGetSubscription', + 'POST /api/PlayerReporting/v1/deviceId', + 'POST /api/PlayerReporting/v1/hile', + 'POST /api/avatar/v2/gifts/generate', + 'POST /api/gamesight/event', + 'POST /api/images/v1/cheer', + 'POST /api/images/v4/uploadsaved', + 'POST /api/inventions/v1/settags', + 'POST /api/inventions/v1/updateprice', + 'POST /api/inventions/v6/save', + 'POST /api/playerReputation/v1/bulk', + 'POST /api/playerReputation/v2/bulk', + 'POST /api/players/v1/progression/bulk', + 'POST /api/players/v2/progression/bulk', + 'POST /api/relationships/v1/favorite', + 'POST /api/relationships/v1/ignore', + 'POST /api/relationships/v1/mute', + 'POST /api/relationships/v1/unfavorite', + 'POST /api/relationships/v1/unignore', + 'POST /api/relationships/v1/unmute', + 'POST /api/relationships/v2/acceptfriendrequest', + 'POST /api/relationships/v2/addfriend', + 'POST /api/relationships/v2/removefriend', + 'POST /api/relationships/v2/sendfriendrequest', + 'POST /api/rooms/v1/verifyRole', + 'POST /api/sanitize/v1', + 'POST /api/sanitize/v1/isPure', + 'POST /api/v1/progression/bulk', + ]) + + // Every operation carries a summary — an undescribed one renders as a bare path. + for (const [path, ops] of Object.entries(spec.paths)) { + for (const [method, op] of Object.entries(ops)) { + expect(op.summary, `${method.toUpperCase()} ${path} has no summary`).toBeTruthy() + } + } + }) + + // Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d schema + // used in a response emits a $ref this hono-openapi + zod v4 setup does not always + // hoist, leaving a dangling reference that breaks the docs UI. + test('the spec has no $refs', async () => { + const res = await exports.default.fetch(`${ORIGIN}/openapi.json`) + const raw = await res.text() + expect(raw.match(/\$ref/g)).toBeNull() + }) +}) diff --git a/apps/www/src/docs.ts b/apps/www/src/docs.ts index c2197c2..9e7ecd5 100644 --- a/apps/www/src/docs.ts +++ b/apps/www/src/docs.ts @@ -21,6 +21,7 @@ export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }> { slug: 'accounts', title: 'accounts — profiles & lookups' }, { slug: 'match', title: 'match — matchmaking & presence' }, { slug: 'econ', title: 'econ — avatar & economy' }, + { slug: 'api', title: 'api — everything else' }, ] /** Path (served as a static asset) of the self-hosted Scalar standalone bundle. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59319a5..11b0383 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,12 +114,27 @@ importers: '@repo/jwt': specifier: workspace:* version: link:../../packages/jwt + '@standard-community/standard-json': + specifier: 0.3.5 + version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3) + '@standard-community/standard-openapi': + specifier: 0.2.9 + version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3) hono: specifier: 4.12.27 version: 4.12.27 + hono-openapi: + specifier: 1.3.1 + version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3) + openapi-types: + specifier: 12.1.3 + version: 12.1.3 workers-tagged-logger: specifier: 1.0.1 version: 1.0.1 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.16.20