mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
docs for api
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the api worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||
* rationale as the auth/accounts/match/econ workers: a reverse-engineered protocol,
|
||||
* lenient handlers, no runtime validation.
|
||||
*
|
||||
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||
* schema inline, which renders correctly in any tool.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return jsonSchema as OpenAPIV3_1.SchemaObject
|
||||
}
|
||||
|
||||
/** A form-urlencoded / multipart request body (the client posts both). */
|
||||
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
const s = toOpenApiSchema(schema)
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
'application/x-www-form-urlencoded': { schema: s },
|
||||
'multipart/form-data': { schema: s },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** An `application/json` request body. */
|
||||
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
|
||||
}
|
||||
|
||||
/** The empty-body 401 the auth-gated routes return. */
|
||||
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */
|
||||
export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'path', required: true, description, schema: { type: 'integer' } }
|
||||
}
|
||||
|
||||
/** A string path parameter. */
|
||||
export function stringParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'path', required: true, description, schema: { type: 'string' } }
|
||||
}
|
||||
|
||||
/** An optional string query parameter. */
|
||||
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
||||
}
|
||||
|
||||
/** An optional integer query parameter (`skip` / `take` / `sort` / `filter`). */
|
||||
export function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
|
||||
}
|
||||
|
||||
/** The `skip`/`take` pair every paginated feed accepts. */
|
||||
export function pageParams(defaultTake: number): OpenAPIV3_1.ParameterObject[] {
|
||||
return [
|
||||
intQuery('skip', 'How many entries to skip (default 0)'),
|
||||
intQuery('take', `How many entries to return (default ${defaultTake})`),
|
||||
]
|
||||
}
|
||||
|
||||
// ---- Loose shapes ----------------------------------------------------------
|
||||
// Several routes serve opaque static config blobs (the game configs, the charades word
|
||||
// list) or empty-list stubs. Modelling every field adds noise without value, so these
|
||||
// use deliberately loose schemas.
|
||||
|
||||
/** An opaque JSON object (a static config blob, a stub, …). */
|
||||
export const JsonObject = z.record(z.string(), z.unknown())
|
||||
/** An opaque JSON array (a static list served verbatim, or an empty-list stub). */
|
||||
export const JsonArray = z.array(z.unknown())
|
||||
|
||||
/** A bare JSON boolean — several routes answer `true`/`false` with no envelope. */
|
||||
export const BareBoolean = z.boolean()
|
||||
|
||||
/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */
|
||||
export const BareString = z.string()
|
||||
|
||||
/** The `{ error }` body the 400 / 403 branches return. */
|
||||
export const ErrorResponse = z.object({ error: z.string() })
|
||||
|
||||
// ---- Config ----------------------------------------------------------------
|
||||
|
||||
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
|
||||
export const AmplitudeConfig = z.object({
|
||||
AmplitudeKey: z.string(),
|
||||
StatSigKey: z.string(),
|
||||
RudderStackKey: z.string(),
|
||||
UseRudderStack: z.boolean(),
|
||||
})
|
||||
|
||||
/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
|
||||
export const AzureSpeechConfig = z.object({
|
||||
Key: z.string(),
|
||||
Region: z.string(),
|
||||
Enabled: z.boolean(),
|
||||
})
|
||||
|
||||
/** `GET /api/config/v1/backtrace` — the client's crash-reporter budget and filters. */
|
||||
export const BacktraceConfig = z.object({
|
||||
ReportBudget: z.int(),
|
||||
FilterType: z.int(),
|
||||
SampleRate: z.int(),
|
||||
LogLineCount: z.int(),
|
||||
CaptureNativeCrashes: z.int(),
|
||||
AMRThresholdMS: z.int(),
|
||||
MessageCount: z.int(),
|
||||
MessageRegex: z.string(),
|
||||
VersionRegex: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/config/v2` — the big client config blob (a static asset), with
|
||||
* `ShareBaseUrl` derived from the deploy-time base domain.
|
||||
*/
|
||||
export const ApiConfigV2 = JsonObject.describe(
|
||||
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
|
||||
)
|
||||
|
||||
/** `GET /api/versioncheck/v4` — always the "you are up to date" answer. */
|
||||
export const VersionCheck = z.object({
|
||||
VersionStatus: z.int().describe('0 = current'),
|
||||
UpdateNotificationStage: z.int(),
|
||||
IsVersionIslanded: z.boolean(),
|
||||
IsCrossPlayDisabled: z.boolean(),
|
||||
})
|
||||
|
||||
// ---- Social ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The per-player relationship projection (`RelationshipResponse`). `PlayerID` is the
|
||||
* OTHER player; the type and flags are taken from the caller's own side of the row, so
|
||||
* the two players in a pair see different projections of it.
|
||||
*/
|
||||
export const RelationshipDto = z.object({
|
||||
PlayerID: z.int().describe('The other player in the pair'),
|
||||
RelationshipType: z
|
||||
.int()
|
||||
.describe('0 = none, 1 = friend request sent, 2 = friend request received, 3 = friend'),
|
||||
Favorited: z.int().describe('0/1 — the caller‘s own flag'),
|
||||
Ignored: z.int().describe('0/1 — the caller‘s own flag'),
|
||||
Muted: z.int().describe('0/1 — the caller‘s own flag'),
|
||||
})
|
||||
|
||||
/** The `{ Success, Message }` ack the flag toggles answer with. */
|
||||
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
|
||||
|
||||
// ---- Progression -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A player's reputation (cheer counters). Nobody has earned cheers yet, so every
|
||||
* counter is 0 and everyone has their full credit. `SelectedCheer` is an int (0 = none),
|
||||
* not null, and `IsCheerful` is true — the client reads it to decide whether the player
|
||||
* may hand out cheers at all.
|
||||
*/
|
||||
export const ReputationDto = z.object({
|
||||
AccountId: z.int(),
|
||||
IsCheerful: z.boolean(),
|
||||
Noteriety: z.int(),
|
||||
SelectedCheer: z.int().describe('0 = none selected'),
|
||||
CheerCredit: z.int(),
|
||||
CheerGeneral: z.int(),
|
||||
CheerHelpful: z.int(),
|
||||
CheerCreative: z.int(),
|
||||
CheerGreatHost: z.int(),
|
||||
CheerSportsman: z.int(),
|
||||
SubscriberCount: z.int(),
|
||||
SubscribedCount: z.int(),
|
||||
})
|
||||
|
||||
/** A player's level/XP (`/api/players/v1/progression/:id`). */
|
||||
export const ProgressionDto = z.object({
|
||||
PlayerId: z.int(),
|
||||
Level: z.int(),
|
||||
XP: z.int(),
|
||||
})
|
||||
|
||||
/** The `Ids` form body the bulk POST endpoints take. */
|
||||
export const BulkIdsRequest = z.object({
|
||||
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
|
||||
})
|
||||
|
||||
// ---- Inventions ------------------------------------------------------------
|
||||
|
||||
/** One version of an invention — carries the blob name the client downloads. */
|
||||
export const InventionVersionDto = z.object({
|
||||
InventionId: z.int(),
|
||||
ReplicationId: z.string(),
|
||||
VersionNumber: z.int(),
|
||||
BlobName: z.string().describe('The `.inv` key in the storage worker‘s bucket'),
|
||||
BlobHash: z.string().nullable(),
|
||||
InstantiationCost: z.int(),
|
||||
LightsCost: z.int(),
|
||||
ChipsCost: z.int(),
|
||||
CloudVariablesCost: z.int(),
|
||||
AICost: z.int(),
|
||||
})
|
||||
|
||||
/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
|
||||
export const InventionTagDto = z.object({
|
||||
Tag: z.string(),
|
||||
Type: z.int().describe('0 = custom, 2 = auto'),
|
||||
})
|
||||
|
||||
/** A stored invention record (the reference's `RRInvention`). */
|
||||
export const InventionDto = z.object({
|
||||
InventionId: z.int(),
|
||||
ReplicationId: z.string(),
|
||||
CreatorPlayerId: z.int(),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
ImageName: z.string(),
|
||||
CurrentVersionNumber: z.int(),
|
||||
CurrentVersion: InventionVersionDto,
|
||||
Accessibility: z.int(),
|
||||
IsPublished: z.boolean().describe('Unpublished inventions are visible only to their creator'),
|
||||
IsFeatured: z.boolean(),
|
||||
ModifiedAt: z.string(),
|
||||
CreatedAt: z.string(),
|
||||
FirstPublishedAt: z.string().nullable(),
|
||||
CreationRoomId: z.int(),
|
||||
NumPlayersHaveUsedInRoom: z.int(),
|
||||
NumDownloads: z.int(),
|
||||
CheerCount: z.int(),
|
||||
CreatorPermission: z.int(),
|
||||
GeneralPermission: z.int().describe('What other players may do with it once published'),
|
||||
IsAGInvention: z.boolean(),
|
||||
IsCertifiedInvention: z.boolean(),
|
||||
Price: z.int(),
|
||||
AllowTrial: z.boolean(),
|
||||
HideFromPlayer: z.boolean(),
|
||||
ReferencedInventions: z.array(z.int()),
|
||||
Tags: z
|
||||
.array(InventionTagDto)
|
||||
.optional()
|
||||
.describe('Unset on save — the real RRInvention carries no Tags field'),
|
||||
})
|
||||
|
||||
/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
|
||||
export const InventionSaveResult = z.object({
|
||||
Status: z.int().describe('0 = success'),
|
||||
Invention: InventionDto,
|
||||
InventionVersion: InventionVersionDto,
|
||||
})
|
||||
|
||||
/** The tag filter chips on a browse screen, derived from the tags actually in use. */
|
||||
export const TagFilters = z.object({
|
||||
PinnedFilters: z.array(z.string()),
|
||||
PopularFilters: z.array(z.string()),
|
||||
TrendingFilters: z
|
||||
.array(z.string())
|
||||
.nullable()
|
||||
.describe('Null — needs recent-activity data we don‘t keep'),
|
||||
})
|
||||
|
||||
/** `GET /api/inventions/v1/details` — an invention's detail card is just its tags. */
|
||||
export const InventionDetails = z.object({ Tags: z.array(InventionTagDto) })
|
||||
|
||||
/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */
|
||||
export const InventionPersonalDetails = z.object({
|
||||
IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
|
||||
export const SetTagsRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
AutoTags: z.array(z.string()).optional().describe('Client-derived tags (Type 2)'),
|
||||
CustomTags: z.array(z.string()).optional().describe('Creator-submitted tags (Type 0)'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
|
||||
export const SetTagsResponse = z.object({
|
||||
Result: z.int().describe('0 = success'),
|
||||
Tags: z.array(z.string()).describe('Auto tags first, then custom'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/updateprice` JSON body. */
|
||||
export const UpdatePriceRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
Price: z.int().describe('Must be >= 0'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v6/save` JSON body — camelCase, unlike the read shapes. */
|
||||
export const SaveInventionRequest = z.object({
|
||||
inventionDataFilename: z
|
||||
.string()
|
||||
.describe('The blob uploaded through the storage worker; the one required field'),
|
||||
name: z.string().optional().describe('Defaults to “Untitled”'),
|
||||
description: z.string().optional(),
|
||||
imageName: z.string().optional(),
|
||||
instantiationCost: z.int().optional(),
|
||||
lightsCost: z.int().optional(),
|
||||
chipsCost: z.int().optional(),
|
||||
cloudVariablesCost: z.int().optional(),
|
||||
aiCost: z.int().optional(),
|
||||
creationRoomId: z.int().optional(),
|
||||
referencedInventions: z.array(z.int()).optional(),
|
||||
})
|
||||
|
||||
// ---- Avatar / custom avatar items ------------------------------------------
|
||||
|
||||
/** `POST /api/avatar/v2/gifts/generate` — a generated gift box (always a token gift). */
|
||||
export const GeneratedGift = z.object({
|
||||
Id: z.int().describe('Always 0 — gifts generated here are not persisted'),
|
||||
FromPlayerId: z.int(),
|
||||
ConsumableItemDesc: z.string(),
|
||||
AvatarItemDesc: z.string(),
|
||||
FriendlyName: z.string(),
|
||||
AvatarItemType: z.int(),
|
||||
EquipmentPrefabName: z.string(),
|
||||
EquipmentModificationGuid: z.string(),
|
||||
CurrencyType: z.int(),
|
||||
Currency: z.int().describe('A random token amount'),
|
||||
Xp: z.int(),
|
||||
Level: z.int(),
|
||||
Platform: z.int(),
|
||||
PlatformsToSpawnOn: z.int(),
|
||||
BalanceType: z.int(),
|
||||
GiftContext: z.int(),
|
||||
GiftRarity: z.int(),
|
||||
Message: z.string(),
|
||||
})
|
||||
|
||||
/** `POST /api/avatar/v2/gifts/generate` form body. */
|
||||
export const GenerateGiftRequest = z.object({
|
||||
GiftContext: z.string().optional().describe('Where the gift was earned'),
|
||||
Message: z.string().optional(),
|
||||
Xp: z.string().optional(),
|
||||
})
|
||||
|
||||
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
|
||||
export const CustomAvatarItemsPage = z.object({
|
||||
Results: JsonArray,
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
|
||||
export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() })
|
||||
|
||||
// ---- Gameplay --------------------------------------------------------------
|
||||
|
||||
/** `POST /api/sanitize/v1` JSON body — the text to clean. */
|
||||
export const SanitizeRequest = z.object({ Value: z.string() })
|
||||
|
||||
/** `POST /api/sanitize/v1/isPure` — whether the text is clean (always true here). */
|
||||
export const IsPureResponse = z.object({ IsPure: z.boolean() })
|
||||
|
||||
/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
|
||||
export const KeepsakeConfig = z.object({
|
||||
KeepsakeFeatureEnabled: z.boolean(),
|
||||
KeepsakeRoomLimit: z.int(),
|
||||
SocialXpBoostEnabled: z.boolean(),
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
|
||||
export const PlayerEventsAll = z.object({
|
||||
Created: JsonArray,
|
||||
Responses: JsonArray,
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
|
||||
export const PlayerEventsPage = z.object({
|
||||
ContinuationToken: z.string().describe('Empty = no next page'),
|
||||
Events: JsonArray,
|
||||
})
|
||||
|
||||
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both null (no subs yet). */
|
||||
export const SubscriptionResponse = z.object({
|
||||
subscription: z.null(),
|
||||
platformAccountSubscribedPlayerId: z.null(),
|
||||
})
|
||||
|
||||
// ---- Moderation ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `GET /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
||||
* answer (no ban storage yet). `ReportCategory` is -1 (no category) rather than 0,
|
||||
* which is a real category; `Message` is null, not an empty string — the client
|
||||
* distinguishes "no message" from a blank one.
|
||||
*/
|
||||
export const ModerationBlockDetails = z.object({
|
||||
ReportCategory: z.int().describe('-1 = no category (0 is a real one)'),
|
||||
Duration: z.int(),
|
||||
GameSessionId: z.int(),
|
||||
IsBan: z.boolean(),
|
||||
IsHostKick: z.boolean(),
|
||||
IsVoiceModAutoban: z.boolean(),
|
||||
Message: z.string().nullable(),
|
||||
PlayerIdReporter: z.int().nullable(),
|
||||
TimeoutStartedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
|
||||
export const DeviceIdRequest = z.object({
|
||||
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
||||
newDeviceId: z.string().optional(),
|
||||
platform: z.string().optional(),
|
||||
})
|
||||
|
||||
// ---- Rooms -----------------------------------------------------------------
|
||||
|
||||
/** `GET /api/quickPlay/v1/getandclear` — a pending quick-play action; all null = none. */
|
||||
export const QuickPlayResponse = z.object({
|
||||
RoomName: z.string().nullable(),
|
||||
ActionCode: z.string().nullable(),
|
||||
TargetPlayerId: z.int().nullable(),
|
||||
})
|
||||
|
||||
/** `POST /api/rooms/v1/verifyRole` form body. */
|
||||
export const VerifyRoleRequest = z.object({
|
||||
roomId: z.string(),
|
||||
role: z.string().describe('The minimum role level required'),
|
||||
context: z.string().optional().describe('e.g. MakerPen — accepted and ignored'),
|
||||
})
|
||||
|
||||
// ---- Images ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A stored image record. Note the room photo feed (`/api/images/v4/room/:roomId`)
|
||||
* serves this shape raw, while the player lists serve the `ImagesPlayer` projection
|
||||
* below — deliberately different, see the client-contract notes in CLAUDE.md.
|
||||
*/
|
||||
export const SavedImageDto = z.object({
|
||||
Id: z.int(),
|
||||
Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'),
|
||||
Accessibility: z.int(),
|
||||
AccessibilityLocked: z.boolean(),
|
||||
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
|
||||
Description: z.string().nullable(),
|
||||
PlayerId: z.int(),
|
||||
TaggedPlayerIds: z.array(z.int()),
|
||||
RoomId: z.int().nullable(),
|
||||
PlayerEventId: z.int().nullable(),
|
||||
CreatedAt: z.string(),
|
||||
CheerCount: z.int(),
|
||||
CommentCount: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`,
|
||||
* `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
|
||||
* MUST serve this: the raw SavedImage renders blank thumbnails.
|
||||
*/
|
||||
export const ImagesPlayerDto = z.object({
|
||||
SavedImageId: z.int(),
|
||||
SavedImageType: z.int(),
|
||||
Accessibility: z.int(),
|
||||
AccessibilityLocked: z.boolean(),
|
||||
CheerCount: z.int(),
|
||||
CommentCount: z.int(),
|
||||
CreatedAt: z.string(),
|
||||
Description: z.string().nullable(),
|
||||
ImageName: z.string(),
|
||||
PlayerEventId: z.int().nullable(),
|
||||
PlayerId: z.int(),
|
||||
RoomId: z.int().nullable(),
|
||||
})
|
||||
|
||||
/** One entry in the anonymous slideshow feed, joined to its creator and room. */
|
||||
export const SlideshowImageDto = z.object({
|
||||
SavedImageId: z.int(),
|
||||
ImageName: z.string(),
|
||||
Username: z.string(),
|
||||
RoomName: z.string().nullable(),
|
||||
RoomId: z.int().nullable(),
|
||||
SavedImageType: z.int(),
|
||||
PlayerEventId: z.int().nullable(),
|
||||
Accessibility: z.int(),
|
||||
PlayerIds: z.array(z.int()),
|
||||
})
|
||||
|
||||
/** `GET /api/images/v1/slideshow` — the feed plus a short cache hint. */
|
||||
export const SlideshowResponse = z.object({
|
||||
Images: z.array(SlideshowImageDto),
|
||||
ValidTill: z.string().describe('ISO timestamp ~2 minutes out; the client refreshes against it'),
|
||||
})
|
||||
|
||||
/** `POST /api/images/v4/uploadsaved` multipart body. */
|
||||
export const UploadImageRequest = z.object({
|
||||
image: z.string().describe('The image file (`file` is accepted too)'),
|
||||
imgMeta: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'A JSON `SavedImageMetaDTO`: { playerIds, savedImageType, roomId, playerEventId, accessibility, description }'
|
||||
),
|
||||
})
|
||||
|
||||
/** `POST /api/images/v4/uploadsaved` — the stored bucket key. */
|
||||
export const UploadImageResponse = z.object({
|
||||
ImageName: z.string().describe('The bucket key; the img worker serves the object by it'),
|
||||
})
|
||||
|
||||
/** `DELETE /api/images/v1/deletesaved` JSON body. */
|
||||
export const DeleteImageRequest = z.object({ ImageName: z.string() })
|
||||
|
||||
/** `POST /api/images/v1/cheer` JSON body. */
|
||||
export const CheerImageRequest = z.object({
|
||||
SavedImageId: z.int(),
|
||||
Cheer: z.boolean().describe('True to cheer, false to un-cheer'),
|
||||
})
|
||||
|
||||
/** The bare `{ success: true }` ack the image writes answer with. */
|
||||
export const SuccessResponse = z.object({ success: z.boolean() })
|
||||
|
||||
/** One entry of `GET /api/images/v5/cheered/bulk`, one per requested id, in order. */
|
||||
export const CheeredEntry = z.object({
|
||||
SavedImageId: z.int(),
|
||||
IsCheered: z.boolean(),
|
||||
})
|
||||
+566
-197
@@ -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` worker’s consume endpoint.',
|
||||
security: AUTHED,
|
||||
requestBody: form(GenerateGiftRequest, 'Where the gift was earned'),
|
||||
responses: {
|
||||
200: json(GeneratedGift, 'The generated (unpersisted) gift'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<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 creator’s custom avatar items',
|
||||
description:
|
||||
'The items an account has authored. Nothing stores custom items yet, so this is an ' +
|
||||
'empty page — in the same shape as the `econ` worker’s `customAvatarItems/v1/owned`.',
|
||||
parameters: [idParam('accountId', 'Creator account id')],
|
||||
responses: { 200: json(CustomAvatarItemsPage, 'An empty page') },
|
||||
}),
|
||||
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||
)
|
||||
|
||||
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
|
||||
// or 404 when there's no such invention.
|
||||
.get('/api/inventions/v1', async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const invention = await getInventionById(c.env.DB, inventionId)
|
||||
return invention ? c.json(invention) : c.notFound()
|
||||
})
|
||||
.get(
|
||||
'/api/inventions/v1',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'One invention by id',
|
||||
description: 'The stored `RRInvention`. Public — an unpublished invention is served too.',
|
||||
parameters: [intQuery('inventionId', 'Invention id; required')],
|
||||
responses: {
|
||||
200: json(InventionDto, 'The invention'),
|
||||
400: json(ErrorResponse, 'Missing or non-numeric inventionId'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const invention = await getInventionById(c.env.DB, inventionId)
|
||||
return invention ? c.json(invention) : c.notFound()
|
||||
}
|
||||
)
|
||||
|
||||
// The tag filter chips on the invention browse screen. Derived from the tags in
|
||||
// use on published inventions — most popular first, top few pinned. Public.
|
||||
.get('/api/inventions/v1/tagfilters', async (c) => c.json(await getInventionTagFilters(c.env.DB)))
|
||||
.get(
|
||||
'/api/inventions/v1/tagfilters',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Invention browse filter chips',
|
||||
description:
|
||||
'The filter chips on the invention browse screen, derived from the tags actually in ' +
|
||||
'use on published inventions — most popular first, the top few pinned. ' +
|
||||
'`TrendingFilters` is null: that needs recent-activity data we do not keep, and the ' +
|
||||
'client treats null as absent.',
|
||||
responses: { 200: json(TagFilters, 'The chips in use') },
|
||||
}),
|
||||
async (c) => c.json(await getInventionTagFilters(c.env.DB))
|
||||
)
|
||||
|
||||
// A batch of inventions by id (`?id=1&id=2`, and each `id` may itself be a
|
||||
// comma-separated list). Unknown ids are dropped rather than 404ing, and an empty
|
||||
// request is an empty list. Auth is optional and only widens what you see: an
|
||||
// unpublished invention comes back only to its creator. Bare array.
|
||||
.get('/api/inventions/v2/batch', async (c) => {
|
||||
const ids = c.req
|
||||
.queries('id')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((id) => !Number.isNaN(id))
|
||||
if (ids === undefined || ids.length === 0) return c.json([])
|
||||
.get(
|
||||
'/api/inventions/v2/batch',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Inventions by id, in bulk',
|
||||
description:
|
||||
'Look up several inventions at once. Unknown ids are dropped rather than 404ing, ' +
|
||||
'and an empty request is an empty list. Auth is optional and only widens what you ' +
|
||||
'see: an unpublished invention comes back only to its creator.',
|
||||
parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')],
|
||||
responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') },
|
||||
}),
|
||||
async (c) => {
|
||||
const ids = c.req
|
||||
.queries('id')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((id) => !Number.isNaN(id))
|
||||
if (ids === undefined || ids.length === 0) return c.json([])
|
||||
|
||||
const playerId = await authedId(c)
|
||||
const inventions = await getInventionsByIds(c.env.DB, ids)
|
||||
return c.json(
|
||||
inventions.filter(
|
||||
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
|
||||
const playerId = await authedId(c)
|
||||
const inventions = await getInventionsByIds(c.env.DB, ids)
|
||||
return c.json(
|
||||
inventions.filter(
|
||||
(i) => i.IsPublished || (playerId !== null && i.CreatorPlayerId === playerId)
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// A room's inventions (`?id=76`) — published inventions created in that room,
|
||||
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
|
||||
.get('/api/inventions/v1/room', async (c) => {
|
||||
const roomId = Number.parseInt(c.req.query('id') ?? '', 10)
|
||||
if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getInventionsByRoom(c.env.DB, roomId, skip, take))
|
||||
})
|
||||
.get(
|
||||
'/api/inventions/v1/room',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'A room’s inventions',
|
||||
description: 'Published inventions created in that room, newest first.',
|
||||
parameters: [intQuery('id', 'Room id; required'), ...pageParams(100)],
|
||||
responses: {
|
||||
200: json(InventionDto.array(), 'The room’s inventions'),
|
||||
400: json(ErrorResponse, 'Missing or non-numeric id'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const roomId = Number.parseInt(c.req.query('id') ?? '', 10)
|
||||
if (Number.isNaN(roomId)) return c.json({ error: 'id is required' }, 400)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getInventionsByRoom(c.env.DB, roomId, skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
|
||||
// — just whether they're cheering it. We store no cheers (nothing can cheer an
|
||||
// invention yet), so this is always false; it stays a 200 for signed-out callers
|
||||
// too, since the client only reads the flag.
|
||||
.get('/api/inventions/v1/personaldetails/:inventionId{[0-9]+}', (c) =>
|
||||
c.json({ IsCheering: false })
|
||||
.get(
|
||||
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The caller’s own relation to an invention',
|
||||
description:
|
||||
'Just whether the caller is cheering it. We store no cheers, so it is always false ' +
|
||||
'— and this stays a 200 for signed-out callers too, since the client only reads the ' +
|
||||
'flag.',
|
||||
parameters: [idParam('inventionId', 'Invention id')],
|
||||
responses: { 200: json(InventionPersonalDetails, 'Always not cheering') },
|
||||
}),
|
||||
(c) => c.json({ IsCheering: false })
|
||||
)
|
||||
|
||||
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
||||
// RRInventionVersion, which carries the blob name the client downloads. Public.
|
||||
// Only the current version exists (nothing writes version history yet), so any
|
||||
// other version number 404s rather than naming a blob that isn't there.
|
||||
.get('/api/inventions/v1/version', async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
|
||||
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
|
||||
.get(
|
||||
'/api/inventions/v1/version',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'One version of an invention',
|
||||
description:
|
||||
'The bare `RRInventionVersion`, which carries the blob name the client downloads. ' +
|
||||
'Only the current version exists — nothing writes version history yet — so any ' +
|
||||
'other version number 404s rather than naming a blob that is not there.',
|
||||
parameters: [
|
||||
intQuery('inventionId', 'Invention id; required'),
|
||||
intQuery('version', 'Version number; required'),
|
||||
],
|
||||
responses: {
|
||||
200: json(InventionVersionDto, 'The version'),
|
||||
400: json(ErrorResponse, 'Missing inventionId or version'),
|
||||
404: { description: 'No such invention, or not the current version' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
|
||||
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
|
||||
|
||||
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
|
||||
return version === null ? c.notFound() : c.json(version)
|
||||
})
|
||||
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
|
||||
return version === null ? c.notFound() : c.json(version)
|
||||
}
|
||||
)
|
||||
|
||||
// Edit an invention's metadata. A GET that writes — that's what the client sends
|
||||
// (`?inventionId=1&description=my+description`), with the fields to change as
|
||||
@@ -183,67 +370,135 @@ export const avatarRoutes = new Hono<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 invention’s metadata',
|
||||
description:
|
||||
'A GET that writes — that is what the client sends, with the fields to change as ' +
|
||||
'query params. Absent params keep their stored value. An empty `description` ' +
|
||||
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
|
||||
'invention. Publishing and pricing are separate endpoints.',
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
intQuery('inventionId', 'Invention id; required'),
|
||||
stringQuery('name', 'New name; empty is ignored'),
|
||||
stringQuery('description', 'New description; present-but-empty clears it'),
|
||||
stringQuery('imageName', 'New thumbnail; empty is ignored'),
|
||||
stringQuery('allowTrial', '`true`/`1` to allow trials'),
|
||||
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
|
||||
],
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
|
||||
if ('response' in gate) return gate.response
|
||||
|
||||
// Query params arrive as strings; only the ones actually present are applied.
|
||||
const nonEmpty = (name: string): string | undefined => {
|
||||
const v = c.req.query(name)?.trim()
|
||||
return v === undefined || v === '' ? undefined : v
|
||||
// Query params arrive as strings; only the ones actually present are applied.
|
||||
const nonEmpty = (name: string): string | undefined => {
|
||||
const v = c.req.query(name)?.trim()
|
||||
return v === undefined || v === '' ? undefined : v
|
||||
}
|
||||
const allowTrial = c.req.query('allowTrial')
|
||||
const permission = c.req.query('permission')
|
||||
|
||||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||
name: nonEmpty('name'),
|
||||
// Present-but-empty clears the description, so this checks presence.
|
||||
description: c.req.query('description'),
|
||||
imageName: nonEmpty('imageName'),
|
||||
allowTrial:
|
||||
allowTrial === undefined
|
||||
? undefined
|
||||
: allowTrial.toLowerCase() === 'true' || allowTrial === '1',
|
||||
generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission),
|
||||
})
|
||||
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
|
||||
}
|
||||
const allowTrial = c.req.query('allowTrial')
|
||||
const permission = c.req.query('permission')
|
||||
|
||||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||
name: nonEmpty('name'),
|
||||
// Present-but-empty clears the description, so this checks presence.
|
||||
description: c.req.query('description'),
|
||||
imageName: nonEmpty('imageName'),
|
||||
allowTrial:
|
||||
allowTrial === undefined
|
||||
? undefined
|
||||
: allowTrial.toLowerCase() === 'true' || allowTrial === '1',
|
||||
generalPermission: permission === undefined ? undefined : parsePermissionLevel(permission),
|
||||
})
|
||||
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
|
||||
})
|
||||
)
|
||||
|
||||
// Publish an invention — this is what puts it into search and the feeds. Sets the
|
||||
// permission other players get (`permissionLevel`, defaulting to UseOnly) and its
|
||||
// `price`. Auth-gated, creator only; answers the save envelope.
|
||||
.get('/api/inventions/v3/publish', async (c) => {
|
||||
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
|
||||
if ('response' in gate) return gate.response
|
||||
.get(
|
||||
'/api/inventions/v3/publish',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Publish an invention',
|
||||
description:
|
||||
'What puts an invention into search and the feeds. Sets the permission other ' +
|
||||
'players get (defaulting to UseOnly) and its price. Another GET that writes.',
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
intQuery('inventionId', 'Invention id; required'),
|
||||
stringQuery('permissionLevel', 'A name like `useonly`, or the raw number'),
|
||||
intQuery('price', 'Price in tokens; negative is ignored'),
|
||||
],
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The published invention, in the save envelope'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const gate = await creatorsInvention(c, Number.parseInt(c.req.query('inventionId') ?? '', 10))
|
||||
if ('response' in gate) return gate.response
|
||||
|
||||
const permissionLevel = c.req.query('permissionLevel')
|
||||
const price = Number.parseInt(c.req.query('price') ?? '', 10)
|
||||
const permissionLevel = c.req.query('permissionLevel')
|
||||
const price = Number.parseInt(c.req.query('price') ?? '', 10)
|
||||
|
||||
const published = await publishInvention(
|
||||
c.env.DB,
|
||||
gate.invention.InventionId,
|
||||
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
|
||||
Number.isNaN(price) || price < 0 ? undefined : price
|
||||
)
|
||||
return published === null ? c.notFound() : c.json(toSaveResult(published))
|
||||
})
|
||||
const published = await publishInvention(
|
||||
c.env.DB,
|
||||
gate.invention.InventionId,
|
||||
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
|
||||
Number.isNaN(price) || price < 0 ? undefined : price
|
||||
)
|
||||
return published === null ? c.notFound() : c.json(toSaveResult(published))
|
||||
}
|
||||
)
|
||||
|
||||
// Set an invention's price. Unlike update/publish this one POSTs a JSON body.
|
||||
// Auth-gated, creator only; answers the save envelope.
|
||||
.post('/api/inventions/v1/updateprice', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<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 invention’s price',
|
||||
description:
|
||||
'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' +
|
||||
'is rejected.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'),
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The repriced invention, in the save envelope'),
|
||||
400: json(ErrorResponse, 'Unparseable body, or a price below 0'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<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 invention’s tags',
|
||||
description:
|
||||
'`CustomTags` are the creator’s own (Type 0), `AutoTags` the ones the client ' +
|
||||
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
|
||||
'only.\n\n' +
|
||||
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
|
||||
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
|
||||
responses: {
|
||||
200: json(SetTagsResponse, 'The resulting tag names'),
|
||||
400: json(ErrorResponse, 'Unparseable body'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<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 invention’s detail card',
|
||||
description:
|
||||
'Which in practice is just its tags, as typed `{ Tag, Type }` objects. An untagged ' +
|
||||
'invention reports an empty list.',
|
||||
parameters: [intQuery('inventionId', 'Invention id; required')],
|
||||
responses: {
|
||||
200: json(InventionDetails, 'The invention’s tags'),
|
||||
400: json(ErrorResponse, 'Missing or non-numeric inventionId'),
|
||||
404: { description: 'No such invention' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
const tags = await getInventionTags(c.env.DB, inventionId)
|
||||
return tags === null ? c.notFound() : c.json({ Tags: tags })
|
||||
}
|
||||
)
|
||||
|
||||
// The "top today" invention feed — published inventions ranked by engagement
|
||||
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
|
||||
// (take defaults to 50, as the client asks for). Bare array.
|
||||
.get('/api/inventions/v1/toptoday', async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||||
return c.json(await getTopInventions(c.env.DB, skip, take))
|
||||
})
|
||||
.get(
|
||||
'/api/inventions/v1/toptoday',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The “top today” feed',
|
||||
description:
|
||||
'Published inventions ranked by engagement — lifetime, not per-day: we keep no ' +
|
||||
'daily counters, so “today” is a label, not a window.',
|
||||
parameters: pageParams(50),
|
||||
responses: { 200: json(InventionDto.array(), 'The top inventions') },
|
||||
}),
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||||
return c.json(await getTopInventions(c.env.DB, skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
|
||||
// to the top feed while nothing is curated. Bare array, like toptoday.
|
||||
.get('/api/inventions/v1/featured', async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||||
return c.json(await getFeaturedInventions(c.env.DB, skip, take))
|
||||
})
|
||||
.get(
|
||||
'/api/inventions/v1/featured',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The featured feed',
|
||||
description:
|
||||
'Curated (`IsFeatured`) inventions, falling back to the top feed while nothing is ' +
|
||||
'curated — so this is never empty just because no one has picked favourites.',
|
||||
parameters: pageParams(50),
|
||||
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
|
||||
}),
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '50', 10) || 50
|
||||
return c.json(await getFeaturedInventions(c.env.DB, skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// Invention search/browse: published inventions matching `value` (matched against
|
||||
// name + description; absent → browse everything published), newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
.get('/api/inventions/v2/search', async (c) => {
|
||||
const value = c.req.query('value') ?? ''
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await searchInventions(c.env.DB, value, skip, take))
|
||||
})
|
||||
.get(
|
||||
'/api/inventions/v2/search',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Search / browse inventions',
|
||||
description:
|
||||
'Published inventions matching `value` (matched against name and description), ' +
|
||||
'newest first. An absent `value` browses everything published — that is the ' +
|
||||
'browse screen’s initial request.',
|
||||
parameters: [
|
||||
stringQuery('value', 'Search text; absent browses everything'),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(InventionDto.array(), 'The matching inventions') },
|
||||
}),
|
||||
async (c) => {
|
||||
const value = c.req.query('value') ?? ''
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await searchInventions(c.env.DB, value, skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// The signed-in player's saved inventions ("my inventions"), newest first.
|
||||
// Auth-gated; returns a bare array (empty when the player has saved none).
|
||||
.get('/api/inventions/v2/mine', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getInventionsByCreator(c.env.DB, id))
|
||||
})
|
||||
.get(
|
||||
'/api/inventions/v2/mine',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The caller’s own inventions',
|
||||
description:
|
||||
'“My inventions”, newest first — including unpublished ones, which nobody else can ' +
|
||||
'see. Not paginated.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(InventionDto.array(), 'The caller’s inventions'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getInventionsByCreator(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Save an invention's metadata. The data file itself is uploaded separately
|
||||
// through the `storage` worker and referenced here by `inventionDataFilename` —
|
||||
@@ -321,36 +669,57 @@ export const avatarRoutes = new Hono<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 invention’s metadata. The data file itself is uploaded separately ' +
|
||||
'through the `storage` worker and referenced here by `inventionDataFilename` — the ' +
|
||||
'one required field, since an invention with no data blob is unusable. An omitted ' +
|
||||
'name/description is defaulted rather than rejected.\n\n' +
|
||||
'A freshly saved invention is private: it shows up only in the creator’s own list ' +
|
||||
'until they call `v3/publish`.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SaveInventionRequest, 'The invention metadata (camelCase)'),
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
|
||||
400: json(ErrorResponse, 'Unparseable body, or no inventionDataFilename'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as Record<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
@@ -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 client’s voice transcription. `Enabled` is false ' +
|
||||
'here, so the key and region are never used.',
|
||||
responses: { 200: json(AzureSpeechConfig, 'Speech config, disabled') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
|
||||
Region: 'eastus',
|
||||
Enabled: false,
|
||||
})
|
||||
)
|
||||
.get('/api/config/v1/backtrace', (c) =>
|
||||
c.json({
|
||||
ReportBudget: 125,
|
||||
FilterType: 0,
|
||||
SampleRate: 1,
|
||||
LogLineCount: 50,
|
||||
CaptureNativeCrashes: 1,
|
||||
AMRThresholdMS: 0,
|
||||
MessageCount: 1000,
|
||||
MessageRegex:
|
||||
"^.*$",
|
||||
VersionRegex: '.*',
|
||||
})
|
||||
.get(
|
||||
'/api/config/v1/backtrace',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Crash reporter config',
|
||||
description:
|
||||
'Budget, sampling and log-capture settings for the client’s Backtrace crash ' +
|
||||
'reporter. Nothing on this server receives the reports.',
|
||||
responses: { 200: json(BacktraceConfig, 'Crash reporter settings') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
ReportBudget: 125,
|
||||
FilterType: 0,
|
||||
SampleRate: 1,
|
||||
LogLineCount: 50,
|
||||
CaptureNativeCrashes: 1,
|
||||
AMRThresholdMS: 0,
|
||||
MessageCount: 1000,
|
||||
MessageRegex: '^.*$',
|
||||
VersionRegex: '.*',
|
||||
})
|
||||
)
|
||||
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
|
||||
// config is static.
|
||||
.get('/api/config/v2', (c) =>
|
||||
c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
|
||||
.get(
|
||||
'/api/config/v2',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'The main client config blob',
|
||||
description:
|
||||
'The large feature-switch / endpoint config the client reads at startup. Served ' +
|
||||
'from a static asset, except `ShareBaseUrl`, which is templated from the ' +
|
||||
'deploy-time base domain so share links point at this deployment.',
|
||||
responses: { 200: json(ApiConfigV2, 'The client config') },
|
||||
}),
|
||||
(c) => c.json({ ...apiConfigV2, ShareBaseUrl: `https://www.${c.env.DOMAIN}/{0}` })
|
||||
)
|
||||
.get('/api/versioncheck/v4', (c) =>
|
||||
c.json({
|
||||
VersionStatus: 0,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
})
|
||||
.get(
|
||||
'/api/versioncheck/v4',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Client version check',
|
||||
description:
|
||||
'Whether the client build is current. Always the “up to date, nothing islanded” ' +
|
||||
'answer — this server does not gate on client version.',
|
||||
responses: { 200: json(VersionCheck, 'Always current') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
VersionStatus: 0,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
})
|
||||
)
|
||||
.get(
|
||||
'/api/gameconfigs/v1/all',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Per-game configuration',
|
||||
description: 'An opaque static catalog of per-game settings, served verbatim.',
|
||||
responses: { 200: json(JsonObject, 'The game config catalog') },
|
||||
}),
|
||||
(c) => c.json(gameConfigsV1All)
|
||||
)
|
||||
.get('/api/gameconfigs/v1/all', (c) => c.json(gameConfigsV1All))
|
||||
|
||||
// Voice chat config. The client fetches it to set up voice.
|
||||
// No reference shape, so return an empty object until the client needs fields.
|
||||
.get('/voice/config', (c) => c.json({}))
|
||||
.get(
|
||||
'/voice/config',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Voice chat config',
|
||||
description:
|
||||
'Fetched by the client while setting up voice. We have no reference shape for it, ' +
|
||||
'so it stays an empty object until the client is observed needing a field.',
|
||||
responses: { 200: json(JsonObject, 'An empty object') },
|
||||
}),
|
||||
(c) => c.json({})
|
||||
)
|
||||
|
||||
+192
-21
@@ -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 activity’s word bank',
|
||||
description:
|
||||
'The words the Charades activity draws from. The client asks by activity name ' +
|
||||
'(`.../words/Charades`); the name is not matched on, so every activity gets the ' +
|
||||
'charades list — no other activity has data yet.',
|
||||
parameters: [stringParam('activity', 'Activity name, e.g. `Charades`. Not matched on.')],
|
||||
responses: { 200: json(JsonArray, 'The word list') },
|
||||
}),
|
||||
(c) => c.json(charadesWords)
|
||||
)
|
||||
|
||||
// Keepsakes (room mementos). Stubbed empty.
|
||||
.get('/api/keepsakes/globalconfig', (c) =>
|
||||
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
|
||||
.get(
|
||||
'/api/keepsakes/globalconfig',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Keepsake feature switches',
|
||||
description:
|
||||
'Whether keepsakes (room mementos) are on and how many a room may hold. The ' +
|
||||
'feature reports as enabled, but nothing stores keepsakes yet.',
|
||||
responses: { 200: json(KeepsakeConfig, 'The keepsake config') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
|
||||
)
|
||||
.get(
|
||||
'/api/keepsakes/rooms/:roomId',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'A room’s keepsakes',
|
||||
description:
|
||||
'No keepsake storage yet. Answers 204 with no body rather than an empty list — ' +
|
||||
'that is what the reference does, and the client treats a body here as data.',
|
||||
parameters: [idParam('roomId', 'Room id')],
|
||||
responses: { 204: { description: 'No keepsakes (empty body)' } },
|
||||
}),
|
||||
(c) => c.body(null, 204)
|
||||
)
|
||||
.get(
|
||||
'/api/keepsakes/categories',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Keepsake categories',
|
||||
description: 'No keepsake catalog yet, so this is an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get('/api/keepsakes/rooms/:roomId', (c) => c.body(null, 204))
|
||||
.get('/api/keepsakes/categories', (c) => c.json([]))
|
||||
|
||||
// ---- Objectives / events / rewards ---------------------------------------
|
||||
// Objectives live on the `econ` host (`updateobjective` / `myprogress`), which is
|
||||
// where the client calls them — they are not served here.
|
||||
.get('/api/communityboard/v2/current', (c) => c.json({})) // TODO: hydrate from JSON/communityboard.json
|
||||
.get('/api/playerevents/v1/all', (c) => c.json({ Created: [], Responses: [] }))
|
||||
.get(
|
||||
'/api/communityboard/v2/current',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'The current community board',
|
||||
description:
|
||||
'The rotating community board on the home screen. Not hydrated yet, so it is an ' +
|
||||
'empty object.',
|
||||
responses: { 200: json(JsonObject, 'An empty object') },
|
||||
}),
|
||||
(c) => c.json({})
|
||||
) // TODO: hydrate from JSON/communityboard.json
|
||||
.get(
|
||||
'/api/playerevents/v1/all',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'The caller’s player events',
|
||||
description:
|
||||
'Events the player created and events they have RSVP’d to. No player-event ' +
|
||||
'storage yet, so both lists are empty.',
|
||||
responses: { 200: json(PlayerEventsAll, 'Two empty lists') },
|
||||
}),
|
||||
(c) => c.json({ Created: [], Responses: [] })
|
||||
)
|
||||
|
||||
// The tag filter chips on the player-events browse screen. Derived from the tags in
|
||||
// use across events — we store no events, so there are no chips to offer.
|
||||
// `TrendingFilters` is null even in the reference (it needs recent-activity data).
|
||||
.get('/api/playerevents/v1/tagfilters', (c) =>
|
||||
c.json({ PinnedFilters: [], PopularFilters: [], TrendingFilters: null })
|
||||
.get(
|
||||
'/api/playerevents/v1/tagfilters',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Player-event filter chips',
|
||||
description:
|
||||
'The filter chips on the player-events browse screen, derived from the tags in use ' +
|
||||
'across events. We store no events, so there are no chips to offer. ' +
|
||||
'`TrendingFilters` is null even in the reference — it needs recent-activity data.',
|
||||
responses: { 200: json(TagFilters, 'Empty chip lists') },
|
||||
}),
|
||||
(c) => c.json({ PinnedFilters: [], PopularFilters: [], TrendingFilters: null })
|
||||
)
|
||||
|
||||
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
|
||||
// page. A bare array: the client deserializes this one as a list, and chokes on the
|
||||
// `{ ContinuationToken, Events }` envelope the single-club form uses. No
|
||||
// player-event storage yet, so the feed is empty.
|
||||
.get('/api/playerevents/v1/clubs', (c) => c.json([]))
|
||||
.get(
|
||||
'/api/playerevents/v1/clubs',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Player events across several clubs',
|
||||
description:
|
||||
'The events shelf for a set of clubs (`?id=1&id=2`). This form returns a BARE ' +
|
||||
'ARRAY — the client deserializes it as a list and chokes on the paged envelope the ' +
|
||||
'single-club form below uses. Do not unify the two. No player-event storage yet, ' +
|
||||
'so the feed is empty.',
|
||||
parameters: [intQuery('id', 'Repeatable club id')],
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// The same feed for a single club (`/club/1`) — the form the reference serves,
|
||||
// which *does* wrap the events with a paging cursor (empty = no next page).
|
||||
.get('/api/playerevents/v1/club/:clubId{[0-9]+}', (c) =>
|
||||
c.json({ ContinuationToken: '', Events: [] })
|
||||
.get(
|
||||
'/api/playerevents/v1/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Player events for one club',
|
||||
description:
|
||||
'The same feed for a single club — and this form DOES wrap the events with a ' +
|
||||
'paging cursor, matching the reference. An empty `ContinuationToken` means no next ' +
|
||||
'page.',
|
||||
parameters: [idParam('clubId', 'Club id')],
|
||||
responses: { 200: json(PlayerEventsPage, 'An empty page') },
|
||||
}),
|
||||
(c) => c.json({ ContinuationToken: '', Events: [] })
|
||||
)
|
||||
.get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json
|
||||
.get(
|
||||
'/api/announcement/v1/get',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Announcements',
|
||||
description: 'The announcement banners on the home screen. Not hydrated yet.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
) // TODO: hydrate from JSON/announcements.json
|
||||
|
||||
// GameSight attribution/analytics event sink. Accept and ack without persisting.
|
||||
.post('/api/gamesight/event', (c) => c.body(null, 200))
|
||||
.post(
|
||||
'/api/gamesight/event',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Analytics event sink',
|
||||
description:
|
||||
'The client’s GameSight attribution/analytics events. Accepted and dropped — ' +
|
||||
'nothing is persisted. Answers 200 with an empty body.',
|
||||
responses: { 200: { description: 'Accepted (empty body)' } },
|
||||
}),
|
||||
(c) => c.body(null, 200)
|
||||
)
|
||||
|
||||
// ---- Subscription ---------------------------------------------------------
|
||||
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
|
||||
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||
.post(
|
||||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'The caller’s subscription',
|
||||
description:
|
||||
'Rec Room Plus subscription state. There are no subscriptions on this server, so ' +
|
||||
'both fields are null. Also served by the `econ` worker on its own host.',
|
||||
responses: { 200: json(SubscriptionResponse, 'No subscription') },
|
||||
}),
|
||||
(c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||
)
|
||||
|
||||
+349
-150
@@ -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 account’s ' +
|
||||
'avatar, persisted on the account row.',
|
||||
security: AUTHED,
|
||||
requestBody: form(UploadImageRequest, 'The image file plus its metadata'),
|
||||
responses: {
|
||||
200: json(UploadImageResponse, 'The stored bucket key'),
|
||||
400: json(ErrorResponse, 'No file in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<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 caller’s photos',
|
||||
description:
|
||||
'Looks the image up by name and refuses unless the caller took it, then removes ' +
|
||||
'the metadata row (and its cheers) and the object from the bucket. The metadata ' +
|
||||
'goes first; the R2 delete is idempotent, so a missing object is fine.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(DeleteImageRequest, 'The image to delete'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Deleted'),
|
||||
400: json(ErrorResponse, 'No ImageName given'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s image'),
|
||||
404: { description: 'No image by that name' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as { ImageName?: unknown } | null
|
||||
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
|
||||
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
|
||||
const body = (await c.req.json().catch(() => null)) as { ImageName?: unknown } | null
|
||||
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
|
||||
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
|
||||
|
||||
const image = await getImageByName(c.env.DB, imageName)
|
||||
if (!image) return c.notFound()
|
||||
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
|
||||
const image = await getImageByName(c.env.DB, imageName)
|
||||
if (!image) return c.notFound()
|
||||
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
|
||||
|
||||
// Drop the metadata (and cheers) first, then the object. An R2 delete is
|
||||
// idempotent, so a missing object is fine.
|
||||
await deleteImage(c.env.DB, image)
|
||||
await c.env.IMAGES.delete(imageName)
|
||||
// Drop the metadata (and cheers) first, then the object. An R2 delete is
|
||||
// idempotent, so a missing object is fine.
|
||||
await deleteImage(c.env.DB, image)
|
||||
await c.env.IMAGES.delete(imageName)
|
||||
|
||||
return c.json({ success: true })
|
||||
})
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// A room's photo feed — the public images taken in that room. `sort` orders the
|
||||
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
|
||||
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
.get('/api/images/v4/room/:roomId{[0-9]+}', async (c) => {
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
|
||||
})
|
||||
.get(
|
||||
'/api/images/v4/room/:roomId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A room’s photo feed',
|
||||
description:
|
||||
'The public images taken in that room.\n\n' +
|
||||
'This feed serves the RAW `SavedImage` record — unlike the player photo lists ' +
|
||||
'below, which must serve the `ImagesPlayer` projection. The inconsistency is real ' +
|
||||
'and load-bearing: both render correctly as they are, and unifying them breaks one ' +
|
||||
'of them.',
|
||||
parameters: [
|
||||
idParam('roomId', 'Room id'),
|
||||
intQuery('sort', '1 = most cheered; anything else = newest first'),
|
||||
intQuery('filter', 'Narrow by SavedImageType; 0 = all'),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(SavedImageDto.array(), 'The room’s photos') },
|
||||
}),
|
||||
async (c) => {
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const filter = Number.parseInt(c.req.query('filter') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getImagesByRoom(c.env.DB, roomId, sort, filter, skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// A player's photos — the public images that player has taken, newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array of the
|
||||
// client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type).
|
||||
.get('/api/images/v4/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
})
|
||||
.get(
|
||||
'/api/images/v4/player/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photos',
|
||||
description:
|
||||
'The public images that player has taken, newest first. Serves the client’s ' +
|
||||
'`ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) ' +
|
||||
'— the raw `SavedImage` renders blank thumbnails here.',
|
||||
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') },
|
||||
}),
|
||||
async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
|
||||
// A player's photos with a sort option. `sort` orders the list (1 = most
|
||||
// cheered, else newest). Paginated via skip/take (take defaults to 100). Bare array.
|
||||
.get('/api/images/v5/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
})
|
||||
.get(
|
||||
'/api/images/v5/player/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photos, sortable',
|
||||
description: 'v4 plus a `sort` option. Same `ImagesPlayer` projection — see the note on v4.',
|
||||
parameters: [
|
||||
idParam('playerId', 'Account id'),
|
||||
intQuery('sort', '1 = most cheered; anything else = newest first'),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') },
|
||||
}),
|
||||
async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
|
||||
// A player's photo feed — the public images they took plus ones they're tagged
|
||||
// in, newest first. Paginated via skip/take (take defaults to 100). Bare array of
|
||||
// the same ImagesPlayer projection the player photo lists use.
|
||||
.get('/api/images/v3/feed/player/:playerId{[0-9]+}', async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getPlayerFeed(c.env.DB, playerId, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
})
|
||||
.get(
|
||||
'/api/images/v3/feed/player/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photo feed',
|
||||
description:
|
||||
'The public images they took PLUS the ones they are tagged in, newest first — the ' +
|
||||
'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.',
|
||||
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s feed') },
|
||||
}),
|
||||
async (c) => {
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getPlayerFeed(c.env.DB, playerId, skip, take)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
|
||||
// Global slideshow feed — the most recent publicly-listable ShareCamera photos
|
||||
// (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its
|
||||
// creator's username and room name. Public (no auth): it only surfaces already-public
|
||||
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
|
||||
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
|
||||
.get('/api/images/v1/slideshow', async (c) => {
|
||||
const Images = await getSlideshowImages(c.env.DB)
|
||||
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
|
||||
return c.json({ Images, ValidTill })
|
||||
})
|
||||
.get(
|
||||
'/api/images/v1/slideshow',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'The global slideshow feed',
|
||||
description:
|
||||
'The most recent publicly-listable ShareCamera photos across all rooms, newest ' +
|
||||
'first, each joined to its creator’s username and room name.\n\n' +
|
||||
'Deliberately public — it surfaces only already-public images and backs the ' +
|
||||
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
|
||||
'client refreshes against.',
|
||||
responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
|
||||
}),
|
||||
async (c) => {
|
||||
const Images = await getSlideshowImages(c.env.DB)
|
||||
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
|
||||
return c.json({ Images, ValidTill })
|
||||
}
|
||||
)
|
||||
|
||||
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
|
||||
// there's no metadata row for that name.
|
||||
.get('/api/images/v6', async (c) => {
|
||||
const name = c.req.query('name') ?? ''
|
||||
if (name === '') return c.json({ error: 'name is required' }, 400)
|
||||
const image = await getImageByName(c.env.DB, name)
|
||||
return image ? c.json(image) : c.notFound()
|
||||
})
|
||||
.get(
|
||||
'/api/images/v6',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Image metadata by filename',
|
||||
description:
|
||||
'The stored `SavedImage` record for a bucket key. 404s when the object exists but ' +
|
||||
'has no metadata row.',
|
||||
parameters: [stringQuery('name', 'The image name (bucket key); required')],
|
||||
responses: {
|
||||
200: json(SavedImageDto, 'The image record'),
|
||||
400: json(ErrorResponse, 'No name given'),
|
||||
404: { description: 'No metadata for that name' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.query('name') ?? ''
|
||||
if (name === '') return c.json({ error: 'name is required' }, 400)
|
||||
const image = await getImageByName(c.env.DB, name)
|
||||
return image ? c.json(image) : c.notFound()
|
||||
}
|
||||
)
|
||||
|
||||
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Persists the
|
||||
// caller's cheer to `image_interaction` and resyncs the image's CheerCount.
|
||||
.post('/api/images/v1/cheer', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
SavedImageId?: number
|
||||
Cheer?: boolean
|
||||
} | null
|
||||
if (body && typeof body.SavedImageId === 'number') {
|
||||
await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true)
|
||||
.post(
|
||||
'/api/images/v1/cheer',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Cheer or un-cheer a photo',
|
||||
description:
|
||||
'Persists the caller’s cheer and resyncs the image’s `CheerCount`. A body naming no ' +
|
||||
'`SavedImageId` is accepted and ignored — the ack is the same either way.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(CheerImageRequest, 'The image and the new cheer state'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Recorded'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
SavedImageId?: number
|
||||
Cheer?: boolean
|
||||
} | null
|
||||
if (body && typeof body.SavedImageId === 'number') {
|
||||
await setImageCheer(c.env.DB, id, body.SavedImageId, body.Cheer === true)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
)
|
||||
|
||||
// Whether the caller has cheered each of the given saved-image ids (`?id=55&id=54`,
|
||||
// and each `id` may itself be a comma-separated list). Auth-gated. Returns one
|
||||
// `{ SavedImageId, IsCheered }` per requested id, in order.
|
||||
.get('/api/images/v5/cheered/bulk', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const ids =
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((imageId) => !Number.isNaN(imageId)) ?? []
|
||||
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
|
||||
return c.json(
|
||||
ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) }))
|
||||
)
|
||||
})
|
||||
.get(
|
||||
'/api/images/v5/cheered/bulk',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Which photos the caller has cheered',
|
||||
description:
|
||||
'One `{ SavedImageId, IsCheered }` per requested id, in request order — the client ' +
|
||||
'fills in the cheer buttons on a photo grid from this.',
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
intQuery('id', 'Repeatable; each value may be a comma-separated list of image ids'),
|
||||
],
|
||||
responses: {
|
||||
200: json(CheeredEntry.array(), 'One entry per requested id, in order'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const ids =
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((imageId) => !Number.isNaN(imageId)) ?? []
|
||||
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
|
||||
return c.json(
|
||||
ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) }))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 player’s equipment. Always an empty list here, and ' +
|
||||
'unlike the econ route it does not require a token.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/api/consumables/v2/getUnlocked',
|
||||
describeRoute({
|
||||
tags: ['Inventory'],
|
||||
summary: 'Unlocked consumables',
|
||||
description:
|
||||
'A stub on this host — the real consumables live in the `econ` worker. Auth-gated ' +
|
||||
'even so, then always an empty list.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonArray, 'An empty list'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([]) // TODO: query ConsumableItems
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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([])
|
||||
)
|
||||
|
||||
@@ -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 player’s reputation',
|
||||
description:
|
||||
'The cheer counters shown on a player’s profile. No cheers are stored yet, so ' +
|
||||
'every player gets the same all-zero record with full cheer credit.',
|
||||
parameters: [idParam('id', 'Account id')],
|
||||
responses: { 200: json(ReputationDto, 'The player’s reputation') },
|
||||
}),
|
||||
(c) => c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
|
||||
)
|
||||
.get(
|
||||
'/api/players/v1/progression/:id',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'A player’s level and XP',
|
||||
description: 'Nothing awards XP yet, so everyone is level 1 with 0 XP.',
|
||||
parameters: [idParam('id', 'Account id')],
|
||||
responses: { 200: json(ProgressionDto, 'The player’s progression') },
|
||||
}),
|
||||
(c) => {
|
||||
const id = Number.parseInt(c.req.param('id'), 10)
|
||||
return c.json({ PlayerId: id, Level: 1, XP: 0 })
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/api/playerReputation/v1/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Reputations in bulk (v1)',
|
||||
description:
|
||||
'The older bulk form, superseded by v2. It answers an empty list rather than ' +
|
||||
'synthesizing defaults — the client only uses v2.',
|
||||
requestBody: BULK_ID_BODY,
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get('/api/players/v1/progression/:id', (c) => {
|
||||
const id = Number.parseInt(c.req.param('id'), 10)
|
||||
return c.json({ PlayerId: id, Level: 1, XP: 0 })
|
||||
})
|
||||
.post('/api/playerReputation/v1/bulk', (c) => c.json([])) // TODO: hydrate from JSON/bulkprogression.json
|
||||
// Synthesize a default reputation per requested id (the intended behavior;
|
||||
// the DB-less fallback reads a static JSON file instead).
|
||||
.post('/api/playerReputation/v2/bulk', async (c) => {
|
||||
const ids = await parseFormIds(c)
|
||||
return c.json(ids.map(defaultReputation))
|
||||
})
|
||||
.post(
|
||||
'/api/playerReputation/v2/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Reputations in bulk',
|
||||
description:
|
||||
'One default reputation per requested id, in request order. Ids that name no ' +
|
||||
'account still get a record — the client renders a profile card from it.',
|
||||
requestBody: BULK_ID_BODY,
|
||||
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
|
||||
}),
|
||||
async (c) => {
|
||||
const ids = await parseFormIds(c)
|
||||
return c.json(ids.map(defaultReputation))
|
||||
}
|
||||
)
|
||||
// The 2023 client calls this as a GET with repeated `id` query params.
|
||||
.get('/api/playerReputation/v2/bulk', (c) => c.json(queryIds(c).map(defaultReputation)))
|
||||
.post('/api/players/v1/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
.get(
|
||||
'/api/playerReputation/v2/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Reputations in bulk (GET form)',
|
||||
description:
|
||||
'What the 2023 client sends: the same bulk lookup with the ids as repeated query ' +
|
||||
'params instead of a form body.',
|
||||
parameters: BULK_ID_QUERY,
|
||||
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
|
||||
}),
|
||||
(c) => c.json(queryIds(c).map(defaultReputation))
|
||||
)
|
||||
.post(
|
||||
'/api/players/v1/progression/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Progressions in bulk (v1)',
|
||||
description: 'No progression is stored yet, so this is an empty list.',
|
||||
requestBody: BULK_ID_BODY,
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
// v2 is identical to v1 — same form-id parse + PlayerProgressions query.
|
||||
.post('/api/players/v2/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
.post(
|
||||
'/api/players/v2/progression/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Progressions in bulk (v2)',
|
||||
description: 'Identical to v1 — same ids in, same empty list out.',
|
||||
requestBody: BULK_ID_BODY,
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
// The 2023 client calls this as a GET with repeated `id` query params.
|
||||
// Return a default progression per requested id.
|
||||
.get('/api/players/v2/progression/bulk', (c) =>
|
||||
c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
|
||||
.get(
|
||||
'/api/players/v2/progression/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Progressions in bulk (GET form)',
|
||||
description:
|
||||
'What the 2023 client sends. Unlike the POST forms this one does answer — a ' +
|
||||
'default level-1 progression per requested id, in request order.',
|
||||
parameters: BULK_ID_QUERY,
|
||||
responses: { 200: json(ProgressionDto.array(), 'One progression per requested id') },
|
||||
}),
|
||||
(c) => c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
|
||||
)
|
||||
.post(
|
||||
'/api/v1/progression/bulk',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Progressions in bulk (unversioned path)',
|
||||
description:
|
||||
'An older unversioned path some client builds still call. Same empty answer as ' +
|
||||
'the versioned POST forms.',
|
||||
requestBody: BULK_ID_BODY,
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
.post('/api/v1/progression/bulk', async (c) => {
|
||||
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
|
||||
return c.json([])
|
||||
})
|
||||
|
||||
+129
-50
@@ -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 caller’s room keys',
|
||||
description: 'Nothing issues room keys yet, so this is an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/api/roomkeys/v1/room',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'A room’s keys',
|
||||
description: 'Nothing issues room keys yet, so this is an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/api/quickPlay/v1/getandclear',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'Take the pending quick-play action',
|
||||
description:
|
||||
'A read-and-clear of whatever quick-play action is queued for the caller (joining ' +
|
||||
'a friend, an invite deep link). Nothing queues one yet, so all three fields are ' +
|
||||
'null — which the client reads as “nothing to do”.',
|
||||
responses: { 200: json(QuickPlayResponse, 'All null — no pending action') },
|
||||
}),
|
||||
(c) => c.json({ RoomName: null, ActionCode: null, TargetPlayerId: null })
|
||||
)
|
||||
|
||||
// Room search filters. The client deserializes this into an object (not an
|
||||
// array) — shape from the 2025 reference.
|
||||
.get('/api/rooms/v1/filters', (c) =>
|
||||
c.json({
|
||||
PinnedFilters: [
|
||||
'recroomoriginal',
|
||||
'community',
|
||||
'featured',
|
||||
'quest',
|
||||
'pvp',
|
||||
'hangout',
|
||||
'game',
|
||||
'art',
|
||||
'store',
|
||||
'tutorial',
|
||||
'fandom',
|
||||
'performance',
|
||||
'action',
|
||||
'horror',
|
||||
],
|
||||
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
|
||||
TrendingFilters: ['roleplay', 'nomp', 'rp', 'casual', 'fun', 'action', 'military', 'sports'],
|
||||
})
|
||||
.get(
|
||||
'/api/rooms/v1/filters',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'Room browse filter chips',
|
||||
description:
|
||||
'The filter chips on the room browse screen. Static, taken from the 2025 ' +
|
||||
'reference. The client deserializes this as an OBJECT, not an array — and unlike ' +
|
||||
'the invention/event filters, `TrendingFilters` here is a real list.',
|
||||
responses: { 200: json(TagFilters, 'The filter chips') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
PinnedFilters: [
|
||||
'recroomoriginal',
|
||||
'community',
|
||||
'featured',
|
||||
'quest',
|
||||
'pvp',
|
||||
'hangout',
|
||||
'game',
|
||||
'art',
|
||||
'store',
|
||||
'tutorial',
|
||||
'fandom',
|
||||
'performance',
|
||||
'action',
|
||||
'horror',
|
||||
],
|
||||
PopularFilters: ['pvp', 'quest', 'game', 'hangout', 'art'],
|
||||
TrendingFilters: [
|
||||
'roleplay',
|
||||
'nomp',
|
||||
'rp',
|
||||
'casual',
|
||||
'fun',
|
||||
'action',
|
||||
'military',
|
||||
'sports',
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
// Verify the caller holds at least `role` in a room. Params come from the form
|
||||
@@ -44,29 +103,49 @@ export const roomRoutes = new Hono<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 caller’s role in a room',
|
||||
description:
|
||||
'Whether the caller holds at least `role` in the room — the gate the client checks ' +
|
||||
'before letting someone into the Maker Pen. The room’s creator always passes; ' +
|
||||
'anyone else needs a `Roles` entry at that level or higher.\n\n' +
|
||||
'Answers a bare `true`/`false`, and every failure is `false` rather than an error ' +
|
||||
'status: no token, an unknown room, and an insufficient role are indistinguishable ' +
|
||||
'to the client. Params are read from the form body, falling back to the query ' +
|
||||
'string. Room data is read from the shared rooms database (owned by the `rooms` ' +
|
||||
'worker).',
|
||||
security: AUTHED,
|
||||
requestBody: form(VerifyRoleRequest, 'The room and the role level to check'),
|
||||
responses: { 200: json(BareBoolean, 'Whether the caller holds the role') },
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<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
@@ -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 caller’s point of view'),
|
||||
400: json(ErrorResponse, 'No target id, or the caller targeting themselves'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A `describeRoute` spec for a per-side flag toggle (favorite / ignore / mute and their
|
||||
* inverses). The write lands on the caller's own side of the row, so only the caller is
|
||||
* notified — and the resulting relationship rides that notification, not the response,
|
||||
* which is just the ack.
|
||||
*/
|
||||
function flagToggle(summary: string, description: string) {
|
||||
return describeRoute({
|
||||
tags: ['Social'],
|
||||
summary,
|
||||
description,
|
||||
security: AUTHED,
|
||||
parameters: TARGET_PARAMS,
|
||||
responses: {
|
||||
200: json(AckResponse, 'The ack; the relationship arrives over the notification hub'),
|
||||
400: json(ErrorResponse, 'No target id, or the caller targeting themselves'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Social ----------------------------------------------------------------
|
||||
export const socialRoutes = new Hono<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 caller’s relationships',
|
||||
description:
|
||||
'Every relationship the signed-in player has, projected from their point of view — ' +
|
||||
'a bare array. `None` rows are included: that is how an unfriending, or an ' +
|
||||
'ignore/mute of someone you were never friends with, is recorded, and they still ' +
|
||||
'carry the caller’s favorited/ignored/muted flags.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(RelationshipDto.array(), 'The caller’s relationships'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getRelationshipsForPlayer(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Send a friend request to another player (the target arrives as `?id=`). The
|
||||
// client calls this as a GET; the mutations accept GET or POST (the Go handlers
|
||||
@@ -125,49 +208,84 @@ export const socialRoutes = new Hono<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 caller’s `ignored` flag. Ignoring someone you have no relationship with ' +
|
||||
'creates a bare (`None`) row to hold the flag.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'ignored', true)
|
||||
}
|
||||
)
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v1/unignore',
|
||||
flagToggle('Stop ignoring a player', 'Clears the caller’s `ignored` flag.'),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'ignored', false)
|
||||
}
|
||||
)
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v1/mute',
|
||||
flagToggle(
|
||||
'Mute a player',
|
||||
'Sets the caller’s `muted` flag. Like ignore, this works on a player you have no ' +
|
||||
'relationship with.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'muted', true)
|
||||
}
|
||||
)
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v1/unmute',
|
||||
flagToggle('Unmute a player', 'Clears the caller’s `muted` flag.'),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'muted', false)
|
||||
}
|
||||
)
|
||||
|
||||
// Favorite / unfavorite another player (the client calls these as a GET with the
|
||||
// target in `?id=`). Same per-side flag mechanics as ignore/mute above: the write
|
||||
// lands on the *caller's* side of the row, and favoriting someone you have no
|
||||
// relationship with creates a bare (None) row. Auth-gated. Result rides a
|
||||
// RelationshipChanged notification; the body is the { Success, Message } ack.
|
||||
.on(['GET', 'POST'], '/api/relationships/v1/favorite', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'favorited', true)
|
||||
})
|
||||
.on(['GET', 'POST'], '/api/relationships/v1/unfavorite', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'favorited', false)
|
||||
})
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v1/favorite',
|
||||
flagToggle(
|
||||
'Favorite a player',
|
||||
'Sets the caller’s `favorited` flag — what pins a player to the top of their friends ' +
|
||||
'list. Works on a player you have no relationship with.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'favorited', true)
|
||||
}
|
||||
)
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/relationships/v1/unfavorite',
|
||||
flagToggle('Unfavorite a player', 'Clears the caller’s `favorited` flag.'),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return applyFlag(c, id, target, 'favorited', false)
|
||||
}
|
||||
)
|
||||
|
||||
.get('/api/messages/v2/get', (c) => c.json([]))
|
||||
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
|
||||
.get(
|
||||
'/api/messages/v2/get',
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'Direct messages',
|
||||
description: 'There is no message store yet, so this is always an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/api/messages/v1/favoriteFriendOnlineStatus',
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'Online status of favorited friends',
|
||||
description:
|
||||
'Presence for the caller’s favorited friends. Presence lives in the `match` ' +
|
||||
'worker and is not joined in here yet, so this is an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user