docs for api

This commit is contained in:
Devin Zuczek
2026-07-21 21:42:14 -04:00
parent ec324558a0
commit df618af435
15 changed files with 2620 additions and 589 deletions
+6 -1
View File
@@ -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",
+45
View File
@@ -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<App>({ 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 workers `POST /connect/token`.',
},
},
},
},
})
)
export default app
+528
View File
@@ -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 callers own flag'),
Ignored: z.int().describe('0/1 — the callers own flag'),
Muted: z.int().describe('0/1 — the callers 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 workers 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 dont 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(),
})
+566 -197
View File
@@ -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<App>({ 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` workers consume endpoint.',
security: AUTHED,
requestBody: form(GenerateGiftRequest, 'Where the gift was earned'),
responses: {
200: json(GeneratedGift, 'The generated (unpersisted) gift'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftContext =
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
const message = typeof body.Message === 'string' ? body.Message : ''
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const giftContext =
typeof body.GiftContext === 'string' ? Number.parseInt(body.GiftContext, 10) || 0 : 0
const message = typeof body.Message === 'string' ? body.Message : ''
const xp = typeof body.Xp === 'string' ? Number.parseInt(body.Xp, 10) || 0 : 0
// No EarnableRewards binding → always fall back to a token gift.
const tokenAmounts = [10, 25, 50, 100, 250, 500]
const currency = tokenAmounts[Math.floor(Math.random() * tokenAmounts.length)]
// 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 creators 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` workers `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 rooms inventions',
description: 'Published inventions created in that room, newest first.',
parameters: [intQuery('id', 'Room id; required'), ...pageParams(100)],
responses: {
200: json(InventionDto.array(), 'The rooms 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 callers 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<App>({ 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 inventions 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 callers 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 callers 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<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
.post(
'/api/inventions/v1/updateprice',
describeRoute({
tags: ['Inventions'],
summary: 'Set an inventions 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 callers invention'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
const 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<App>({ 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<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
.post(
'/api/inventions/v1/settags',
describeRoute({
tags: ['Inventions'],
summary: 'Replace an inventions tags',
description:
'`CustomTags` are the creators 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 callers invention'),
404: { description: 'No such invention' },
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const inventionId = typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
const gate = await creatorsInvention(c, inventionId)
if ('response' in gate) return gate.response
const 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 inventions 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 inventions 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 screens 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 callers 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 callers 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<App>({ 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 inventions 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 creators 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<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const 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))
})
)
+117 -37
View File
@@ -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<App>({ 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 clients 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 clients 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({})
)
+192 -21
View File
@@ -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<App>({ 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 activitys 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 rooms 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 callers player events',
description:
'Events the player created and events they have RSVPd 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 clients 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 callers 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 })
)
+349 -150
View File
@@ -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<number, string> = {
// ---- Images ----------------------------------------------------------------
export const imageRoutes = new Hono<App>({ 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 accounts ' +
'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<string, unknown>)
// 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<string, unknown>)
// 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<string, unknown> = {}
if (typeof body.imgMeta === 'string') {
try {
const parsed = JSON.parse(body.imgMeta)
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
} 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<string, unknown> = {}
if (typeof body.imgMeta === 'string') {
try {
const parsed = JSON.parse(body.imgMeta)
if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
} 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 callers 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 callers 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 rooms 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 rooms 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 players photos',
description:
'The public images that player has taken, newest first. Serves the clients ' +
'`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 players 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 players 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 players 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 players 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 players 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 creators 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 callers cheer and resyncs the images `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) }))
)
}
)
+38 -6
View File
@@ -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<App>({ 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 players 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
}
)
+81 -15
View File
@@ -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<App>({ 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<App>({ 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([])
)
+146 -26
View File
@@ -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<App>({ 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 players reputation',
description:
'The cheer counters shown on a players 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 players reputation') },
}),
(c) => c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
)
.get(
'/api/players/v1/progression/:id',
describeRoute({
tags: ['Progression'],
summary: 'A players 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 players 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([])
})
+129 -50
View File
@@ -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<App>({ 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 callers 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 rooms 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<App>({ 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<string, unknown>
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 callers 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 rooms 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<string, unknown>
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<Record<string, unknown>>) : []
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<Record<string, unknown>>) : []
const hasRole = roles.some(
(r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
)
return c.json(hasRole)
})
)
+266 -86
View File
@@ -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<App>): Promise<number | null> {
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 callers 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<App>({ 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 callers 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 callers favorited/ignored/muted flags.',
security: AUTHED,
responses: {
200: json(RelationshipDto.array(), 'The callers 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<App>({ 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<App>({ 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 callers `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 callers `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 callers `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 callers `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 callers `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 callers `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 callers 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([])
)
+141
View File
@@ -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<string, Record<string, { summary?: string }>>
}
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()
})
})