support for 202507 endpoints (#37)

* [auth][api] accept the 20250424.01 client

* [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
This commit is contained in:
devin
2026-08-18 23:07:24 -04:00
committed by Devin Zuczek
parent 66c09806f9
commit 178d3b5b0e
162 changed files with 114930 additions and 469 deletions
+412
View File
@@ -0,0 +1,412 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
AUTHED,
boolQuery,
GameAiAccessDenied,
GameAiSpendSummaryDenied,
HealthResponse,
idParam,
intQuery,
json,
jsonBody,
MakerAiAccessResponse,
MakerAiBalances,
RealtimeSessionCreateBody,
RealtimeSessionDenied,
RoomieAiAccess,
RoomieUserFacts,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono'
import type { App } from './context'
/**
* AI Worker. Serves the access checks and budget reads the client makes before offering
* its AI features. Nothing here runs a model, so every answer is static — but not
* uniformly a refusal, because the features fail differently:
*
* - Game AI is a SERVER-side feature. This server cannot provide it, so both its reads are
* refused and the client hides the feature.
* - Roomie runs on the CLIENT and only asks this service what it may spend, so the budget
* reads are granted in full. The session that would actually reach a model
* (`/realtime-session/create`) is where it stops.
* - Maker AI meters model usage in dollars. Nothing here bills, so every figure is zero.
*/
/**
* `int.MaxValue` — the client's energy fields are signed 32-bit ints, so this is the
* largest budget it can hold. Anything larger (an int64 max, say) overflows on the way in
* and lands as a negative number, i.e. no energy at all.
*/
const INT32_MAX = 2_147_483_647
/**
* The reason both Game AI reads refuse with. `AI.RoomDoesNotSupportGameAI` is the id the
* client renders a message for; the room it names makes no difference, there being no Game
* AI backend behind any of them.
*/
const GAME_AI_UNSUPPORTED = {
success: false,
error_id: 'AI.RoomDoesNotSupportGameAI',
error: 'This room does not support Rec Room Game AI',
} as const
/**
* Resolve the account id from a Bearer token (the route is auth-gated).
* Returns `null` when the header is missing, the token is invalid, or the `sub` claim
* isn't an integer.
*/
async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
const app = new Hono<App>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
.onError(withOnError())
.notFound(withNotFound())
// Root health check.
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description: 'Liveness probe for the ai worker. No auth.',
responses: { 200: json(HealthResponse, 'Service is up') },
}),
(c) => c.json({ service: 'ai', status: 'ok' })
)
// Whether the caller may use Game AI in a room. Always refused: no Game AI backend
// exists here, so the honest answer for every room is that it doesn't support it.
.get(
'/gameai/user/access',
describeRoute({
tags: ['Game AI'],
summary: 'May the caller use Game AI here?',
description: [
'Asked before the client offers any Game AI feature in a room. This server hosts no',
'Game AI, so it always refuses — with a 200 carrying `success: false`, NOT an HTTP',
'error: the client branches on the body, and an error status would read as a failed',
'request rather than the “not available here” state this is. `AI.RoomDoesNotSupportGameAI`',
'is the reason the client renders.',
'',
'`roomId` is accepted and ignored — the answer is the same for every room, and the',
'refusal is per-room by nature, so the client asks again for the next one. The token',
'is still validated first, as the reference does.',
].join(' '),
security: AUTHED,
parameters: [intQuery('roomId', 'The room the client is asking about. Ignored.')],
responses: {
200: json(GameAiAccessDenied, 'Always a refusal'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(GAME_AI_UNSUPPORTED)
}
)
// What a room has spent on Game AI. Refused for the same reason as the access check
// above, but note the body differs: this one carries an explicit `value: null`.
.get(
'/gameai/room/:roomId{[0-9]+}/spendsummary',
describeRoute({
tags: ['Game AI'],
summary: 'A rooms Game AI spend summary',
description: [
'What a room has spent of its Game AI budget. Refused with the same 200-plus-',
'`success: false` body as the access check, since a room that cannot use Game AI has',
'no spend to summarise.',
'',
'The body is NOT identical to the access checks: it carries `value: null` where that',
'one omits the key entirely. The access check answers a yes/no and has nothing to',
'carry; this endpoints payload slot exists and is empty. Reproduced as the reference',
'server sends it — dont unify the two.',
].join(' '),
security: AUTHED,
parameters: [idParam('roomId', 'The room being asked about. Ignored.')],
responses: {
200: json(GameAiSpendSummaryDenied, 'Always a refusal'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ ...GAME_AI_UNSUPPORTED, value: null })
}
)
// Roomie AI's energy budget. Granted, unlike Game AI above: Roomie runs on the client
// and only asks this service how much energy it has, so the honest answer for a server
// that meters nothing is "as much as you can count".
.get(
'/roomieai/user/access',
describeRoute({
tags: ['Roomie AI'],
summary: 'The callers Roomie AI energy budget',
description: [
'What Roomie may spend: an energy ceiling, what is left of it, and when it next',
'refills. Nothing here meters energy, so the budget is `int.MaxValue` and never',
'depletes — which is why `NextSubscriptionEnergyRechargeAt` is null, there being no',
'spend to recharge from.',
'',
'The envelope is `{ success, error_id, error, value }`, NOT the flat body the Game AI',
'check answers with. The two are different shapes on purpose — dont unify them.',
].join(' '),
security: AUTHED,
responses: {
200: json(RoomieAiAccess, 'The energy budget — always granted, always full'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
success: true,
error_id: null,
error: null,
value: {
MaxEnergyFromSubscriptions: INT32_MAX,
EnergyLeft: INT32_MAX,
NextSubscriptionEnergyRechargeAt: null,
OutputAudioEnabled: true,
},
})
}
)
// What Roomie has been told about the caller. Nothing observes players here, so it has
// been told nothing.
.get(
'/roomieai/user/facts',
describeRoute({
tags: ['Roomie AI'],
summary: 'What Roomie knows about the caller',
description: [
'The memory Roomie is primed with: `UserContext`, a prose profile written from past',
'conversations, and `UserFacts`, the discrete `(Predicate, Object)` claims behind it —',
'live, these are things the player told Roomie about themselves.',
'',
'Both are empty here. Nothing on this server observes a conversation, so there is',
'nothing to remember, and Roomie starts every session knowing nothing about who it is',
'talking to. A flat body, like the Maker AI balances and unlike the access check',
'above.',
].join(' '),
security: AUTHED,
responses: {
200: json(RoomieUserFacts, 'An empty profile — always'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ UserContext: '', UserFacts: [] })
}
)
// Whether the caller may use Maker AI at all. Always false: no model runs behind this
// worker, so the honest answer is that the feature isn't available — and false is what
// leaves the creation UI in its normal state rather than offering a tool that can't
// work. (The balances below are still served: the client reads its usage meter
// separately, and a server that bills nothing has spent nothing.)
//
// The body is a BARE JSON `false` — not an envelope, unlike the Game AI refusal and the
// Roomie access check on either side of it. `econ`'s
// `/api/makerai/checkfreetrialeligibility` answers the same bare shape.
.get(
'/makerai/user/access',
describeRoute({
tags: ['Maker AI'],
summary: 'May the caller use Maker AI?',
description: [
'Asked before the client offers Maker AI. Always `false` — no model runs behind this',
'worker. The body is a bare JSON boolean, not the `{ success, error, value }` envelope',
'the neighbouring checks answer with.',
'',
'`roomInstanceSpecificCheck` (the client sends .NETs `False`) is accepted and ignored:',
'it asks whether the check is about the instance the player is standing in rather than',
'the account, and the answer is the same either way. The token is still validated first.',
].join(' '),
security: AUTHED,
parameters: [
boolQuery(
'roomInstanceSpecificCheck',
'Whether to check the current room instance rather than the account. Ignored.'
),
],
responses: {
200: json(MakerAiAccessResponse, 'Always `false`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(false)
}
)
// Maker AI's dollar balances. Zeroed rather than refused: the client reads these to
// render a usage meter, and a server that bills nothing has spent nothing.
.get(
'/makerai/user/balances',
describeRoute({
tags: ['Maker AI'],
summary: 'The callers Maker AI usage balances',
description: [
'What Maker AI has cost the caller. Live, these meter model usage in DOLLARS against',
'a per-user ceiling and a separate RR+ allowance, and the client renders them as a',
'usage bar with a status word.',
'',
'Nothing here bills for model usage, so every figure is zero and both usage buckets',
'report `Good` — an untouched allowance, not an exhausted one. The time bucket is',
'`Empty` with `TimeExpiresAt` at `DateTime.MinValue`, this server selling no timed',
'access for it to hold.',
'',
'A flat body — no `{ success, error, value }` envelope, unlike the Roomie access check.',
].join(' '),
security: AUTHED,
responses: {
200: json(MakerAiBalances, 'All zero — nothing is metered here'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
UsageDollars: 0,
UsersMaxUsageDollars: 0,
RRPlusUsageDollars: 0,
UsersMaxRRPlusUsageDollars: 0,
TimeBalanceStatus: 'Empty',
TimeExpiresAt: '0001-01-01T00:00:00',
UsageBalanceStatus: 'Good',
UsagePercent: 0,
RRPlusUsageBalanceStatus: 'Good',
RRPlusUsagePercent: 0,
})
}
)
// Opening a live voice session with an assistant — the one call here that would reach a
// real model, and so the one that cannot be answered statically. Refused.
.post(
'/realtime-session/create',
describeRoute({
tags: ['Roomie AI'],
summary: 'Open a realtime AI session',
description: [
'Posted when the player actually pulls out an assistant. Live, this mints a short-',
'lived credential the CLIENT then uses to talk to the model provider directly, and',
'answers with `{ SessionId, ClientSecret }` in `value`.',
'',
'Refused here. This is the one endpoint on the worker whose answer is a working key',
'rather than a description of one, so there is nothing static to serve — which is why',
'the budget reads above grant everything and the refusal lands at this point instead:',
'the client offers the feature, and the session it opens is what fails.',
'',
'The refusal is still a 200 with `success: false`, and `error_id` is an EMPTY STRING',
'rather than a code — the reference server sends no id for this one. `value` is null.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(
RealtimeSessionCreateBody,
'Which assistant is being opened. Read for the log only — the answer is the same either way.'
),
responses: {
200: json(RealtimeSessionDenied, 'Always a refusal — no session is created'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
success: false,
error: 'Realtime AI sessions are not available on this server',
error_id: '',
value: null,
})
}
)
// 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 }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare ai',
version: '1.0.0',
description: [
'The AI service for recflare, a private-server reimplementation of the Rec Room',
'backend. The client checks here before offering any of its AI features: Game AI in a',
'room, the Roomie assistant, and Maker AIs usage meter.',
'',
'No model runs behind this worker, so every answer is static — but they are not all',
'refusals, because the features fail at different points. Game AI is a server-side',
'feature this server cannot provide, so both its reads refuse. Roomie and Maker AI',
'only ask what the caller may SPEND, which nothing here meters, so those reads are',
'granted in full; the refusal lands instead on `POST /realtime-session/create`, the',
'one call whose real answer is a working credential rather than a description of one.',
'',
'The refusals are 200s carrying `success: false`, which is the shape the client',
'branches on — the worker exists so the client gets a definite answer on the host its',
'endpoints document names, instead of a failed request.',
].join('\n'),
},
servers: [{ url: 'https://ai.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+19
View File
@@ -0,0 +1,19 @@
import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & {
/**
* Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value with
* `await env.JWT_SECRET.get()`; every worker binds the same store, so tokens signed by
* `auth` verify here.
*/
JWT_SECRET: SecretsStoreSecret
}
/** Variables can be extended */
export type Variables = SharedHonoVariables
export interface App extends HonoApp {
Bindings: Env
Variables: Variables
}
+179
View File
@@ -0,0 +1,179 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the ai 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/econ/match/playersettings 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) } } }
}
/** 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 optional integer query parameter. */
export function intQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'integer' } }
}
/**
* An optional boolean query parameter. The client spells these .NET-style (`False`, not
* `false`), which is worth recording even where the value is ignored.
*/
export function boolQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'query', required: false, description, schema: { type: 'boolean' } }
}
/** 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' } }
}
/**
* An `application/json` request body.
*
* The schema is emitted directly rather than through `resolver()` — zod's `$schema` key
* and `additionalProperties: false` are dropped, since the handler reads the fields it
* knows and ignores the rest, so a closed object would misreport it as stricter than it is.
*/
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return {
description,
content: { 'application/json': { schema: jsonSchema as OpenAPIV3_1.SchemaObject } },
}
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the root health check. */
export const HealthResponse = z.object({
service: z.literal('ai'),
status: z.literal('ok'),
})
/**
* The Roomie AI access envelope — the `{ success, error_id, error, value }` shape, unlike
* the flat Game AI refusal above. Roomie is granted here, with its energy budget pinned at
* the maximum a signed 32-bit int holds (see INT32_MAX).
*/
export const RoomieAiAccess = z.object({
success: z.literal(true),
error_id: z.null(),
error: z.null(),
value: z.object({
MaxEnergyFromSubscriptions: z
.int()
.describe('The energy ceiling a subscription buys — pinned to int32 max'),
EnergyLeft: z.int().describe('Energy remaining. Never spent here, so also int32 max'),
NextSubscriptionEnergyRechargeAt: z
.string()
.nullable()
.describe('When the budget refills. Null — nothing depletes, so nothing recharges'),
OutputAudioEnabled: z.boolean().describe('Whether Roomie may speak its replies'),
}),
})
/**
* The refusal every Game AI read answers with. It is a 200 carrying `success: false`, not
* an HTTP error — the client branches on the body, and an error status would surface as a
* failed request rather than the "not available here" state it is meant to show.
*/
export const GameAiAccessDenied = z.object({
success: z.literal(false),
error_id: z.string().describe('Machine-readable reason, e.g. `AI.RoomDoesNotSupportGameAI`'),
error: z.string().describe('The message shown to the player'),
})
/**
* The same refusal, plus an explicit `value: null`. The spend summary carries the key
* where the access check omits it — the access check answers a yes/no and has nothing to
* carry, while this one's payload slot exists and is simply empty. Reproduced as the
* reference server sends it; don't unify the two.
*/
export const GameAiSpendSummaryDenied = GameAiAccessDenied.extend({
value: z.null().describe('The spend summary. Null — there is no Game AI spend to report'),
})
/**
* `GET /makerai/user/access` — a BARE JSON boolean, not an envelope and not a `{ value }`
* wrapper. The whole body is the answer.
*/
export const MakerAiAccessResponse = z
.boolean()
.describe('Whether the caller may use Maker AI; always false — no model runs here')
/**
* Maker AI's dollar balances. A FLAT body — no `{ success, error, value }` envelope — and
* every figure zero, since nothing here bills for model usage.
*/
export const MakerAiBalances = z.object({
UsageDollars: z.number().describe('Dollars of model usage spent this period. Always 0'),
UsersMaxUsageDollars: z.number().describe('The callers usage ceiling. Always 0'),
RRPlusUsageDollars: z.number().describe('Usage spent against the RR+ allowance. Always 0'),
UsersMaxRRPlusUsageDollars: z.number().describe('The RR+ allowance ceiling. Always 0'),
TimeBalanceStatus: z.string().describe('Time-balance bucket state, e.g. `Empty`'),
TimeExpiresAt: z
.string()
.describe('When the time balance lapses. `DateTime.MinValue` — there is none'),
UsageBalanceStatus: z.string().describe('Usage-balance bucket state, e.g. `Good`'),
UsagePercent: z.number().describe('Share of the usage ceiling consumed. Always 0'),
RRPlusUsageBalanceStatus: z.string().describe('RR+ usage bucket state, e.g. `Good`'),
RRPlusUsagePercent: z.number().describe('Share of the RR+ allowance consumed. Always 0'),
})
/** The body the client posts to open a realtime session. Read for documentation only. */
export const RealtimeSessionCreateBody = z.object({
AIType: z
.string()
.optional()
.describe('Which assistant the client is opening a session for, e.g. `Roomie`'),
})
/**
* The realtime-session refusal. `{ success, error, error_id, value }` — note `error_id` is
* an empty string rather than a machine-readable code, and `value` (which would carry the
* session id and its client secret) is null.
*/
export const RealtimeSessionDenied = z.object({
success: z.literal(false),
error: z.string().describe('The message shown to the player'),
error_id: z.string().describe('Empty — the reference server sends no code for this refusal'),
value: z.null().describe('The session credentials. Null: no session is created'),
})
/**
* What Roomie knows about the caller: a prose profile it is primed with, and the discrete
* facts behind it. Both empty here — nothing observes the player to build them.
*/
export const RoomieUserFacts = z.object({
UserContext: z.string().describe('A prose profile Roomie is primed with. Empty'),
UserFacts: z
.array(
z.object({
Id: z.string().describe('GUID identifying the fact'),
CreatedAt: z.string().describe('When the fact was recorded'),
Emotion: z.string().describe('Sentiment attached to the fact, e.g. `neutral`'),
Predicate: z.string().describe('The relation, e.g. `identifies as`'),
Object: z.string().describe('The value the predicate points at'),
})
)
.describe('The recorded facts. Always empty — nothing here observes the player'),
})
+319
View File
@@ -0,0 +1,319 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, describe, expect, it } from 'vitest'
import '../../ai.app'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded
// into the JWT_SECRET store.
const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
)}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput))
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
}
const REFUSAL = {
success: false,
error_id: 'AI.RoomDoesNotSupportGameAI',
error: 'This room does not support Rec Room Game AI',
}
describe('ai endpoints', () => {
it('GET / reports service status', async () => {
const res = await SELF.fetch(`${ORIGIN}/`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ service: 'ai', status: 'ok' })
})
})
describe('GET /gameai/user/access', () => {
// A refusal, not an HTTP error: the client branches on the body, so a 4xx here would
// read as a failed request rather than "Game AI isn't available in this room".
it('refuses with a 200 body', async () => {
const res = await SELF.fetch(`${ORIGIN}/gameai/user/access?roomId=1234`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual(REFUSAL)
})
// `roomId` is optional in the reference signature and ignored here, so both forms and
// any room answer identically.
it.each(['', '?roomId=1', '?roomId=18446744073709551615'])(
'answers the same for %s',
async (query) => {
const res = await SELF.fetch(`${ORIGIN}/gameai/user/access${query}`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual(REFUSAL)
}
)
it('401s without a bearer token', async () => {
const res = await SELF.fetch(`${ORIGIN}/gameai/user/access?roomId=1234`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
it('401s with a garbage token', async () => {
const res = await SELF.fetch(`${ORIGIN}/gameai/user/access`, {
headers: { Authorization: 'Bearer not-a-real-token' },
})
expect(res.status).toBe(401)
})
})
describe('GET /roomieai/user/access', () => {
// Granted, unlike the Game AI check: Roomie runs on the client and only asks for its
// energy budget, which nothing here meters.
it('grants an int32-max energy budget', async () => {
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, { headers: await bearer() })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
success: true,
error_id: null,
error: null,
value: {
// int.MaxValue — the client's field is a signed 32-bit int, so a larger number
// overflows on the way in and reads as negative, i.e. no energy at all.
MaxEnergyFromSubscriptions: 2147483647,
EnergyLeft: 2147483647,
NextSubscriptionEnergyRechargeAt: null,
OutputAudioEnabled: true,
},
})
})
it('401s without a bearer token', async () => {
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
it('401s with a garbage token', async () => {
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, {
headers: { Authorization: 'Bearer not-a-real-token' },
})
expect(res.status).toBe(401)
})
})
describe('GET /gameai/room/:roomId/spendsummary', () => {
// Same refusal as the access check but with an explicit `value: null` — the access
// check omits the key. toEqual pins that difference: don't unify the two shapes.
it('refuses with a 200 body carrying a null value', async () => {
const res = await SELF.fetch(`${ORIGIN}/gameai/room/1234/spendsummary`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ ...REFUSAL, value: null })
})
// Room ids are ulong on the wire, so the largest one has 20 digits and exceeds what a
// JS number holds exactly. It's never parsed here, only matched by the route pattern.
it.each(['1', '18446744073709551615'])('answers the same for room %s', async (roomId) => {
const res = await SELF.fetch(`${ORIGIN}/gameai/room/${roomId}/spendsummary`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ ...REFUSAL, value: null })
})
it('401s without a bearer token', async () => {
const res = await SELF.fetch(`${ORIGIN}/gameai/room/1234/spendsummary`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
})
describe('GET /roomieai/user/facts', () => {
// Nothing observes players here, so Roomie's memory of the caller is empty — and empty
// in both halves: no prose profile, no facts behind one.
it('reports an empty profile', async () => {
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/facts`, { headers: await bearer() })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ UserContext: '', UserFacts: [] })
})
it('401s without a bearer token', async () => {
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/facts`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
})
describe('GET /makerai/user/access', () => {
// Always false — nothing here runs a model. The body is the boolean itself, not an
// envelope, matching econ's `/api/makerai/checkfreetrialeligibility`.
it('refuses access with a bare false', async () => {
const res = await SELF.fetch(`${ORIGIN}/makerai/user/access?roomInstanceSpecificCheck=False`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.text()).toBe('false')
})
it('answers the same without the query param', async () => {
// `roomInstanceSpecificCheck` is ignored, so its presence, absence and value change
// nothing.
const res = await SELF.fetch(`${ORIGIN}/makerai/user/access`, { headers: await bearer() })
expect(await res.text()).toBe('false')
const trueCheck = await SELF.fetch(
`${ORIGIN}/makerai/user/access?roomInstanceSpecificCheck=True`,
{
headers: await bearer(),
}
)
expect(await trueCheck.text()).toBe('false')
})
it('401s without a bearer token', async () => {
const res = await SELF.fetch(`${ORIGIN}/makerai/user/access`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
})
describe('GET /makerai/user/balances', () => {
// Zeroed rather than refused: the client renders a usage meter from these, and a server
// that bills nothing has spent nothing. A flat body — no envelope.
it('reports zeroed balances', async () => {
const res = await SELF.fetch(`${ORIGIN}/makerai/user/balances`, { headers: await bearer() })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
UsageDollars: 0,
UsersMaxUsageDollars: 0,
RRPlusUsageDollars: 0,
UsersMaxRRPlusUsageDollars: 0,
// An untouched allowance reports `Good`; the time bucket is `Empty` at
// DateTime.MinValue, this server selling no timed access to hold there.
TimeBalanceStatus: 'Empty',
TimeExpiresAt: '0001-01-01T00:00:00',
UsageBalanceStatus: 'Good',
UsagePercent: 0,
RRPlusUsageBalanceStatus: 'Good',
RRPlusUsagePercent: 0,
})
})
it('401s without a bearer token', async () => {
const res = await SELF.fetch(`${ORIGIN}/makerai/user/balances`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
})
describe('POST /realtime-session/create', () => {
// The one call whose real answer is a working credential, so the one that can't be
// served statically. Note `error_id` is an empty string, not a code, and `value` null.
it('refuses to open a session', async () => {
const res = await SELF.fetch(`${ORIGIN}/realtime-session/create`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
body: JSON.stringify({ AIType: 'Roomie' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
success: false,
error: 'Realtime AI sessions are not available on this server',
error_id: '',
value: null,
})
})
// The body is never read, so a missing or malformed one must not 500 — the answer is
// the same refusal either way.
it.each([
['no body', undefined],
['an empty body', '{}'],
['a malformed body', 'not json'],
])('refuses with %s', async (_label, body) => {
const res = await SELF.fetch(`${ORIGIN}/realtime-session/create`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
body,
})
expect(res.status).toBe(200)
expect((await res.json()) as { success: boolean }).toMatchObject({ success: false })
})
it('401s without a bearer token', async () => {
const res = await SELF.fetch(`${ORIGIN}/realtime-session/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ AIType: 'Roomie' }),
})
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
})
})
describe('GET /openapi.json', () => {
it('documents every route, with no dangling $refs', async () => {
const res = await SELF.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; everything else is described. Adding a route without
// a describeRoute() block fails here rather than shipping an incomplete spec.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'GET /',
'GET /gameai/room/{roomId}/spendsummary',
'GET /gameai/user/access',
'GET /makerai/user/access',
'GET /makerai/user/balances',
'GET /roomieai/user/access',
'GET /roomieai/user/facts',
'POST /realtime-session/create',
])
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Schemas must inline: a `$ref` here is a dangling reference (see openapi.ts).
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})