Files
recflare/apps/ai/src/ai.app.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

413 lines
15 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 { 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