import { resolver } from 'hono-openapi' import { z } from 'zod' import type { OpenAPIV3_1 } from 'openapi-types' /** * OpenAPI schemas for the auth worker. * * IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to * generate the spec and are never wired into `hono-openapi`'s `validator()`. * * That is deliberate, not an oversight. This worker serves a reverse-engineered * protocol: the Rec Room client is the only real consumer, and the handlers are * intentionally lenient — every field is read as * `typeof body.x === 'string' ? body.x : ''` and missing/malformed input falls * through to a graceful path rather than a 400. Which parts of that tolerance the * client actually depends on is not fully known, so enforcing a schema would risk * rejecting requests that work today, for a client that is hard to debug against. * * So: these schemas record what the client is *observed* to send and what we send * back. If you want to enforce one, do it per-route and land a test with it. */ /** 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) } } } } /** * Emit a zod schema as an `application/x-www-form-urlencoded` request body. * * Unlike `responses`, `describeRoute`'s `requestBody` takes a plain OpenAPI schema * and won't accept a `resolver()`, so convert here. zod's `$schema` key is dropped * (not meaningful in an OpenAPI schema position), as is `additionalProperties: false` * — these handlers read the fields they know and ignore the rest, so claiming a * closed object would misreport the server as stricter than it is. */ export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject { const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema) return { description, content: { // zod's JSONSchema type is far wider than OpenAPI's SchemaObject (it carries // `~standard` and every draft keyword), so the two never match structurally // even though the emitted value is valid OpenAPI 3.1. Cast at the boundary. 'application/x-www-form-urlencoded': { schema: jsonSchema as OpenAPIV3_1.SchemaObject }, }, } } /** * PlatformType, the client's platform enum. Declaration order is wire order, and is * the single source for the schema and description below. The `platform` form field * is posted as the integer; the token's `platform` claim carries the name. */ export const PlatformType = { All: -1, Steam: 0, Oculus: 1, PlayStation: 2, Xbox: 3, RecNet: 4, IOS: 5, GooglePlay: 6, Standalone: 7, Pico: 8, } as const export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType] /** * A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified — * see the platform-auth notes on `POST /connect/token`. */ export const PlatformTypeSchema = z .union([ z.literal(-1), z .int() .min(0) .max(Math.max(...Object.values(PlatformType))), ]) .describe( Object.entries(PlatformType) .map(([name, value]) => `${value} ${name}`) .join(', ') ) /** * One entry on the client's login screen, from `toCachedLogin` — an account ↔ platform * identity LINK, not an account. An account linked to two platforms yields one entry in * each of their pickers, each reporting the identity that picker was asked about. */ export const CachedLogin = z.object({ platform: PlatformTypeSchema, platformId: z .string() .describe('The linked platform-native id — a SteamID64 for Steam, a user id for Meta'), accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'), lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"), requirePassword: z .literal(false) .describe('Always false — platform ownership is the credential for a cached login'), }) /** * The stubbed Oculus cached login served to sideloaded APKs. Same shape as `CachedLogin`, * but `requirePassword` is true — with no Meta SDK there is nothing to prove platform * ownership with, so the client falls through to username/password. */ export const FakeCachedLogin = CachedLogin.extend({ requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'), }) /** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */ export const OAuthError = z.object({ error: z.enum(['invalid_grant', 'invalid_request', 'server_error']), error_description: z.string(), }) /** Successful `POST /connect/token` body. */ export const TokenResponse = z.object({ access_token: z.string().describe('Signed JWT; `sub` is the account id'), expires_in: z.int().describe('Access-token lifetime in seconds (TOKEN_TTL_SECONDS)'), token_type: z.literal('Bearer'), refresh_token: z .string() .describe('Single-use; redeem via grant_type=refresh_token, which rotates it'), scope: z.string().describe('Space-separated granted scopes'), key: z.string().describe('@kludge Constant the client appears to require. Purpose unknown.'), }) /** * `POST /connect/token` form body — the union of every grant's fields, since * OpenAPI cannot express "these fields iff grant_type=X" without splitting the * endpoint. Per-grant requirements are spelled out in the route description. */ export const TokenRequest = z.object({ grant_type: z .enum(['create_account', 'cached_login', 'refresh_token', 'password']) .describe('Anything unrecognised (including absent) is treated as a password grant'), account_id: z.string().optional().describe('Numeric account id, as a string'), username: z .string() .optional() .describe('Password grant alternative to account_id; case-insensitive, trimmed'), password: z .string() .optional() .describe('Required on a password grant. On create_account, sets the initial password'), platform: z.string().optional().describe('PlatformType as an integer string'), platform_id: z .string() .optional() .describe( 'On Steam, unverified and ignored in favour of the id the ticket carries. On Meta it is ' + 'the id the nonce is validated against, so it must be the real (numeric) user id' ), platform_auth: z .string() .optional() .describe( 'Platform proof, required for cached_login and platform create_account, and used to ' + 'link the identity on a password grant. Steam: `{"Ticket":"","AppId":…}`. ' + 'Meta: `{"Nonce":…,"AppId":…,"Source":…}`' ), refresh_token: z.string().optional().describe('Required on a refresh_token grant'), device_id: z .string() .optional() .describe('Client-chosen, unverified. Recorded on the account, never trusted'), device_class: z.string().optional().describe('Integer string; defaults to 0'), ver: z .string() .optional() .describe( 'The client’s build, e.g. `20250718.01`. Stamped into the token’s `rn.ver` claim and ' + 'read back by `match` when it writes presence, so a player reports the build they ' + 'are running. Absent (or empty) falls back to the server’s GAME_VERSION' ), }) /** `POST /account/me/changepassword` form body. */ export const ChangePasswordRequest = z.object({ newPassword: z.string().describe('Required; empty is rejected'), oldPassword: z .string() .optional() .describe('Must match when the account already has a password; empty when first setting it'), }) /** `POST /account/me/changepassword` response body. */ export const ChangePasswordResponse = z.object({ success: z.boolean(), error: z.string().optional(), }) /** * Spec for the `/role/:role/:id` lookups, which are identical apart from the role. * Both return a BARE JSON boolean rather than an object — the client reads the whole * body as a bool — and 404 an unknown player, mirroring the reference API. */ /** * The report category a moderation restriction was issued under, by value. Recorded in * full from the client's enum so a restriction this server starts issuing can name the * right one; nothing here reads it back. * * -1 Moderator · 0 Unknown · 1 DEPRECATED_MicrophoneAbuse · 2 Harassment · 3 Cheating · * 4 DEPRECATED_ImmatureBehavior · 5 AFK · 6 Misc · 7 Underage · 10 VoteKick · * 11 MisleadingPurchases · 100 CoC_Underage · 101 CoC_Sexual · 102 CoC_Discrimination · * 103 CoC_Trolling · 104 CoC_NameOrProfile · 200 InappropriateClothing · * 1000 IssuingInaccurateReports · 1100 RoomInventoryItems · 1101 InappropriateRooms · * 1102 InappropriateInventions · 1103 RoomOffers · 1200 Spam */ export const ReportCategory = z .int() .describe( 'ReportCategory: -1 Moderator · 0 Unknown · 1 DEPRECATED_MicrophoneAbuse · 2 Harassment · 3 Cheating · 4 DEPRECATED_ImmatureBehavior · 5 AFK · 6 Misc · 7 Underage · 10 VoteKick · 11 MisleadingPurchases · 100 CoC_Underage · 101 CoC_Sexual · 102 CoC_Discrimination · 103 CoC_Trolling · 104 CoC_NameOrProfile · 200 InappropriateClothing · 1000 IssuingInaccurateReports · 1100 RoomInventoryItems · 1101 InappropriateRooms · 1102 InappropriateInventions · 1103 RoomOffers · 1200 Spam' ) /** * One moderation restriction on an account — a chat mute, say — as * `GET /privileges/me/restrictions` lists them. * * `Name`, `Description` and `DisplayReason` are free display text: the client clears and * refills its list from these and matches none of them against anything, so the wording is * this server's to choose. What the client acts on is a record being PRESENT, and its * `EndDate` — null for a restriction that never lifts. */ export const RestrictionDto = z.object({ AccountId: z.int().describe('The restricted account'), Name: z.string().describe('Display name of the restriction, e.g. `Chat Mute`. Free text'), Description: z.string().describe('What the player may no longer do. Free text'), EndDate: z .string() .nullable() .describe('When it lifts (ISO 8601 UTC); null for one that never does'), AssociatedAccountId: z.int().nullable().describe('The other account involved, when there is one'), AssociatedAccountUsername: z.string().nullable(), ReportCategory: ReportCategory.nullable().describe('The category it was issued under'), DisplayReason: z.string().nullable().describe('Reason shown to the player. Free text'), }) export function roleLookup(role: 'developer' | 'moderator') { return { tags: ['Roles'], summary: `Whether a player has the ${role} role`, description: `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` + `granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` + `in the access token's \`role\` claim, so the client rarely needs this route.`, parameters: [ { name: 'id', in: 'path' as const, required: true, description: 'Account id. A non-numeric value is treated as unknown (404).', schema: { type: 'string' as const }, }, ], responses: { 200: json(z.boolean(), `\`true\` if the player has the ${role} role`), 404: { description: 'No such player (empty body)' }, }, } } /** Bulk cached-login lookup form body: repeated `id=` fields. */ export const PlatformIdsRequest = z.object({ id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields'), })