mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
433 lines
16 KiB
TypeScript
433 lines
16 KiB
TypeScript
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, validateAndGetBuild } 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's native 2026 companion gets zero energy and no generated output audio;
|
||
* older clients keep the full budget. 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', '2025'],
|
||
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', '2025'],
|
||
summary: 'A room’s 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 check’s: it carries `value: null` where that',
|
||
'one omits the key entirely. The access check answers a yes/no and has nothing to',
|
||
'carry; this endpoint’s payload slot exists and is empty. Reproduced as the reference',
|
||
'server sends it — don’t 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 })
|
||
}
|
||
)
|
||
|
||
// The 20260323 build (20260323.14) has native low-energy and no-energy companion support. Advertising
|
||
// full energy selects a conversation backend we do not provide. Grant access with
|
||
// zero energy and the client's recognized no-credit result instead. A successful
|
||
// access response still opens a realtime session even when its energy is zero.
|
||
// Preserve the older client's budget and recharge-field contract.
|
||
.get(
|
||
'/roomieai/user/access',
|
||
describeRoute({
|
||
tags: ['Roomie AI', '2025'],
|
||
summary: 'The caller’s Roomie AI energy budget',
|
||
description: [
|
||
'The 20260323 build receives the recognized no-credit result and zero energy for its native',
|
||
'companion mode. Its DTO has no recharge field. Older clients retain the full',
|
||
'int32-max budget and null recharge time. No model session is created.',
|
||
'',
|
||
'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 — don’t unify them.',
|
||
].join(' '),
|
||
security: AUTHED,
|
||
responses: {
|
||
200: json(RoomieAiAccess, 'Build-specific conversation access and companion fallback'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
|
||
// The build, not the version: every point release of 20260323 has the companion mode.
|
||
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||
if (build === '20260323') {
|
||
return c.json({
|
||
success: false,
|
||
error_id: 'AI.InsufficientRoomieAICreditNoSub',
|
||
error: 'AI conversation is disabled. Roomie companion mode is available.',
|
||
value: {
|
||
MaxEnergyFromSubscriptions: 0,
|
||
EnergyLeft: 0,
|
||
OutputAudioEnabled: false,
|
||
},
|
||
})
|
||
}
|
||
|
||
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', '2025'],
|
||
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. Granted, like the Roomie budget reads and
|
||
// unlike the Game AI checks: this is a gate, not a model call, and refusing it hides the
|
||
// feature outright. (The balances below are still zeroed: the client reads its usage
|
||
// meter separately, and a server that bills nothing has spent nothing.)
|
||
//
|
||
// The envelope is its own shape again — PascalCase `Success`/`Error` beside a snake_case
|
||
// `error_id`, which is neither the Game AI refusal's all-lowercase body nor the Roomie
|
||
// check's `{ success, error_id, error, value }`. Reproduced as the reference sends it;
|
||
// the mixed casing is not a typo to tidy up.
|
||
.get(
|
||
'/makerai/user/access',
|
||
describeRoute({
|
||
tags: ['Maker AI', '2025'],
|
||
summary: 'May the caller use Maker AI?',
|
||
description: [
|
||
'Asked before the client offers Maker AI. Always granted — the gate is about',
|
||
'entitlement, not capacity, and nothing here meters what Maker AI would cost.',
|
||
'',
|
||
'The envelope carries PascalCase `Success`/`Error` next to a snake_case `error_id`,',
|
||
'which matches neither neighbour on this worker. That mix is what the reference sends;',
|
||
'it is not an inconsistency to clean up.',
|
||
'',
|
||
'`roomInstanceSpecificCheck` (the client sends .NET’s `True`/`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 granted'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
|
||
return c.json({ Success: true, Error: null, error_id: null })
|
||
}
|
||
)
|
||
|
||
// 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', '2025'],
|
||
summary: 'The caller’s 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', '2025'],
|
||
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 AI’s 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 with a build-specific budget; conversation refuses 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 worker’s `POST /connect/token`.',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
)
|
||
)
|
||
|
||
export default app
|