mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
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:
@@ -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 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 })
|
||||
}
|
||||
)
|
||||
|
||||
// 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 caller’s 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 — don’t 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 .NET’s `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 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'],
|
||||
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 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 worker’s `POST /connect/token`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 caller’s 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'),
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user