Files
recflare/apps/auth/src/openapi.ts
T
devin 34171417e3 support for 202507 endpoints (#37)
* [auth][api] accept the 20250424.01 client

Version check now answers "current" for a set of builds rather than one:
SUPPORTED_GAME_VERSIONS carries 20230414 and 20250424.01. GAME_VERSION is
unchanged and still what the server reports for itself (presence, rn.ver).

Adds GET /api/versioncheck/islandedversions, always [] — we never island a
build off into its own matchmaking pool.

The 2025 build POSTs /cachedlogin/forplatformid/:platform/:id with a
deviceId/platformAuth/time form body where the 2023 build GETs it, so that
route now takes both methods. The body is accepted and ignored for now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [2025] unstable

* 20250718.0

* correct one this time

* stubs

* more stubs

* more stubs

* [lists] add worker

* [ai] route stubs

* [api] player photo setting

* [econ] add roomEconConfig route

* [infra] update worker generators

* [worker] add cards/moderation/platformnotification workers

* [lists] updates to some endpoints

* [clubs] stub out announcement endpoint, for now

* [econ] stub out season endpoints for now

* [chat] apps/chat stub out party endpoint not sure the shape yet

* [api] stub out statsig and lockeditems

* [doc] new services

* [lists] stub the bulk endpoint

* [datacollection] add placeholder service until we can kill it

* [api] set gifting to lvl5

* update lock

* [cdn] enable cache

* [match] matchmake v2

* [lists] stub some lists

* [ai] stubs

* [rooms] new subroom save endpoint

* [econ] add bulk purchase endpoint

* [discovery] update featured creator to 1 for fun

* [api] add photo settings flag

* [chat] fixup chat permissions (sorta)

* [auth] restrictions endpoint

* [rooms] contributed endpoint

* [api] fix outfit endpoint

* [discovery] attempt to fix store

* [chat] privacy endpoints

* [api] cheered images

* [rooms] add xp endpoint (disbaled)

* [rooms] add xp endpoint (disabled)

* update images-db for cheers

* [rooms] add autocomplete endpoint

* [cdn/img] increase cache ttl for statics

* [api] bulk route for images

* [accounts] add banner image

* [api] add misc missing endpoints

* [discovery] remove AI tab

* [platformnotifications] stub some endpoints

* [lists] add some more lists

* [rooms] additional endpoints

* [chat] stub a few privacy endpoints

* [econ] stub some endpoints

* misc db fixes

* [api] tweak shape for images v6

* [rooms] dont show trending RROs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:07:24 -04:00

272 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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":"<hex>","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 clients build, e.g. `20250718.01`. Stamped into the tokens `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 servers 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'),
})