updating api docs

This commit is contained in:
Devin Zuczek
2026-07-22 11:43:30 -04:00
parent 68b98665b2
commit 23b78104e8
28 changed files with 3358 additions and 780 deletions
+22 -21
View File
@@ -179,9 +179,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Self'], tags: ['Self'],
summary: 'The callers own account', summary: 'The callers own account',
description: description: [
'The private self DTO, including owner-only fields (email, remaining username ' + 'The private self DTO, including owner-only fields (email, remaining username',
'changes). An account with no stored row falls back to a synthesized default.', 'changes). An account with no stored row falls back to a synthesized default.',
].join(' '),
security: AUTHED, security: AUTHED,
responses: { responses: {
200: json(SelfAccountDto, 'The callers account'), 200: json(SelfAccountDto, 'The callers account'),
@@ -232,9 +233,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Lookup'], tags: ['Lookup'],
summary: 'Look up many accounts by id', summary: 'Look up many accounts by id',
description: description: [
'Accepts repeated `id` query params and/or comma-separated lists. Every requested ' + 'Accepts repeated `id` query params and/or comma-separated lists. Every requested',
'id appears in the response — ids with no stored row get a synthesized default.', 'id appears in the response — ids with no stored row get a synthesized default.',
].join(' '),
parameters: [ parameters: [
{ {
name: 'id', name: 'id',
@@ -325,9 +327,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Self'], tags: ['Self'],
summary: 'Create an account', summary: 'Create an account',
description: description: [
'Mints a new account with an auto-assigned random username (players dont choose ' + 'Mints a new account with an auto-assigned random username (players dont choose',
'one initially). Not auth-gated. `platformId` is parsed but not yet persisted.', 'one initially). Not auth-gated. `platformId` is parsed but not yet persisted.',
].join(' '),
requestBody: form(CreateAccountRequest, 'Platform fields'), requestBody: form(CreateAccountRequest, 'Platform fields'),
responses: { 200: json(CreateAccountResult, 'The created account, in a result envelope') }, responses: { 200: json(CreateAccountResult, 'The created account, in a result envelope') },
}), }),
@@ -375,9 +378,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Lookup'], tags: ['Lookup'],
summary: 'An accounts privacy settings', summary: 'An accounts privacy settings',
description: description: [
'Nothing stores per-player privacy yet; the id is echoed and recent history is ' + 'Nothing stores per-player privacy yet; the id is echoed and recent history is',
'reported visible (a bare `{}` fails the clients deserializer).', 'reported visible (a bare `{}` fails the clients deserializer).',
].join(' '),
parameters: [ parameters: [
{ {
name: 'id', name: 'id',
@@ -431,10 +435,11 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Profile'], tags: ['Profile'],
summary: 'Change username', summary: 'Change username',
description: description: [
'Rejects a name taken by another account and requires a remaining change; on ' + 'Rejects a name taken by another account and requires a remaining change; on',
'success the name is persisted and the counter decremented. Always HTTP 200 — ' + 'success the name is persisted and the counter decremented. Always HTTP 200 —',
'failures carry a message in `error` (see the UsernameResult envelope).', 'failures carry a message in `error` (see the UsernameResult envelope).',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(UsernameRequest, 'The desired username'), requestBody: form(UsernameRequest, 'The desired username'),
responses: { responses: {
@@ -529,9 +534,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Profile'], tags: ['Profile'],
summary: 'Set identity flags', summary: 'Set identity flags',
description: description: [
'`identityFlags` bitmask. In the public DTO, so the update is broadcast via ' + '`identityFlags` bitmask. In the public DTO, so the update is broadcast via',
'AccountUpdate.', 'AccountUpdate.',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(IdentityFlagsRequest, 'The identityFlags bitmask'), requestBody: form(IdentityFlagsRequest, 'The identityFlags bitmask'),
responses: { responses: {
@@ -561,9 +567,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Profile'], tags: ['Profile'],
summary: 'Set personal pronouns', summary: 'Set personal pronouns',
description: description: [
'Posted as `pronounFlags`. The response carries no account, so the client learns ' + 'Posted as `pronounFlags`. The response carries no account, so the client learns',
'the new value only from the broadcast AccountUpdate.', 'the new value only from the broadcast AccountUpdate.',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(PronounsRequest, 'The pronounFlags bitmask'), requestBody: form(PronounsRequest, 'The pronounFlags bitmask'),
responses: { responses: {
@@ -648,12 +655,6 @@ app.get(
'Account reads, profile mutations and lookups for recflare, a private-server', 'Account reads, profile mutations and lookups for recflare, a private-server',
'reimplementation of the Rec Room backend. Accounts live in the shared `recflare`', 'reimplementation of the Rec Room backend. Accounts live in the shared `recflare`',
'D1 database, whose `account` schema is owned by the `auth` worker.', 'D1 database, whose `account` schema is owned by the `auth` worker.',
'',
'The shapes here are **reverse-engineered from the game client**, which is the only',
'real consumer. They record observed behaviour, not a designed contract; the handlers',
'are lenient and reads fall back to a synthesized default account rather than 404.',
'Nothing in this spec is enforced at runtime — treat a field marked required as "the',
'client always sends it", not "the server rejects it if absent".',
].join('\n'), ].join('\n'),
}, },
servers: [{ url: 'https://accounts.recflare.net', description: 'Production' }], servers: [{ url: 'https://accounts.recflare.net', description: 'Production' }],
-6
View File
@@ -75,12 +75,6 @@ app.get(
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,', 'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
'equipment, consumables and objectives on `econ`) are already served there — the', 'equipment, consumables and objectives on `econ`) are already served there — the',
'client calls that host and the copy here is a stub, which each route says.', 'client calls that host and the copy here is a stub, which each route says.',
'',
'The shapes are **reverse-engineered from the game client**, which is the only real',
'consumer. They record observed behaviour, not a designed contract; the handlers are',
'lenient and parse bodies defensively. Nothing in this spec is enforced at runtime —',
'treat a field marked required as "the client always sends it", not "the server',
'rejects it if absent".',
].join('\n'), ].join('\n'),
}, },
servers: [{ url: 'https://api.recflare.net', description: 'Production' }], servers: [{ url: 'https://api.recflare.net', description: 'Production' }],
+20 -22
View File
@@ -247,13 +247,14 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Cached login'], tags: ['Cached login'],
summary: 'Accounts linked to a platform id', summary: 'Accounts linked to a platform id',
description: description: [
'Accounts the client may offer on its login screen for this platform identity. ' + 'Accounts the client may offer on its login screen for this platform identity.',
'Filtered to those a `cached_login` grant would actually accept, so an entry here ' + 'Filtered to those a `cached_login` grant would actually accept, so an entry here',
'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls ' + 'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls',
'back to a fresh login or create_account. ' + 'back to a fresh login or create_account.',
'EXCEPT platform 1 (Oculus), which is stubbed: it ignores the id and returns one ' + 'EXCEPT platform 1 (Oculus), which is stubbed: it ignores the id and returns one',
'canned, non-redeemable entry with `requirePassword: true`.', 'canned, non-redeemable entry with `requirePassword: true`.',
].join(' '),
parameters: [ parameters: [
{ {
name: 'platform', name: 'platform',
@@ -306,11 +307,12 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Cached login'], tags: ['Cached login'],
summary: 'Bulk cached-login lookup (friends resolution)', summary: 'Bulk cached-login lookup (friends resolution)',
description: description: [
'Resolves many platform ids at once. Results are flattened across all ids, so the ' + 'Resolves many platform ids at once. Results are flattened across all ids, so the',
'response cannot be mapped back to a specific input id — the client uses each ' + 'response cannot be mapped back to a specific input id — the client uses each',
"entry's own `platformId`. Unlike the single-id route, results are NOT filtered to " + 'entrys own `platformId`. Unlike the single-id route, results are NOT filtered to',
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.', 'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
].join(' '),
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'), requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') }, responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
}), }),
@@ -373,8 +375,10 @@ const app = new Hono<App>()
200: json(TokenResponse, 'Access token, refresh token and granted scopes'), 200: json(TokenResponse, 'Access token, refresh token and granted scopes'),
400: json( 400: json(
OAuthError, OAuthError,
'Unusable grant: bad credentials, an unverifiable or non-Steam platform, an ' + [
'invalid/expired refresh token, a missing account identifier, or a signup cap reached' 'Unusable grant: bad credentials, an unverifiable or non-Steam platform, an',
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
].join(' ')
), ),
500: json( 500: json(
OAuthError, OAuthError,
@@ -654,10 +658,11 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Account'], tags: ['Account'],
summary: "Change the caller's password", summary: "Change the caller's password",
description: description: [
'Stores a PBKDF2 hash on the account row; the raw password is never persisted. ' + 'Stores a PBKDF2 hash on the account row; the raw password is never persisted.',
'When the account already has a password, `oldPassword` must match. The first time ' + 'When the account already has a password, `oldPassword` must match. The first time',
'a password is set, `oldPassword` is empty — which is what the client sends.', 'a password is set, `oldPassword` is empty — which is what the client sends.',
].join(' '),
security: [{ bearerAuth: [] }], security: [{ bearerAuth: [] }],
requestBody: form(ChangePasswordRequest, 'New password, plus the old one when one is set'), requestBody: form(ChangePasswordRequest, 'New password, plus the old one when one is set'),
responses: { responses: {
@@ -729,13 +734,6 @@ app.get(
description: [ description: [
'Authentication and token issuance for recflare, a private-server reimplementation', 'Authentication and token issuance for recflare, a private-server reimplementation',
'of the Rec Room backend.', 'of the Rec Room backend.',
'',
'The shapes here are **reverse-engineered from the game client**, which is the only',
'real consumer. They record observed behaviour rather than a designed contract, and',
'the handlers are deliberately lenient: missing or malformed fields generally fall',
'through to a graceful path instead of erroring. Nothing in this spec is enforced at',
'runtime, so treat a field marked required as "the client always sends it", not "the',
'server rejects it if absent".',
].join('\n'), ].join('\n'),
}, },
servers: [{ url: 'https://auth.recflare.net', description: 'Production' }], servers: [{ url: 'https://auth.recflare.net', description: 'Production' }],
+6 -1
View File
@@ -18,8 +18,13 @@
"dependencies": { "dependencies": {
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.12.27",
"workers-tagged-logger": "1.0.1" "hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20", "@cloudflare/vitest-pool-workers": "0.16.20",
+393 -26
View File
@@ -1,11 +1,34 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers' import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
import { NotificationType } from '../../notify/src/notification-types' import { NotificationType } from '../../notify/src/notification-types'
import { getThreadMessages } from './message-db' import { getThreadMessages } from './message-db'
import {
AUTHED,
ChatMessageDto,
ChatResult,
ChatThreadDto,
ChatThreadWithMessagesDto,
CreateThreadRequest,
CreateThreadResponse,
FavoriteThreadRequest,
form,
json,
messageCountParam,
NOT_A_MEMBER_RESPONSE,
RenameThreadRequest,
SendMessageRequest,
SendMessageResponse,
ServiceStatus,
SnoozeThreadRequest,
THREAD_ID_PARAM,
UNAUTHORIZED_RESPONSE,
WithMembersRequest,
} from './openapi'
import { import {
addThreadMember, addThreadMember,
getOrCreateThreadWithMembers, getOrCreateThreadWithMembers,
@@ -218,6 +241,74 @@ async function formField(c: Context<App>, name: string): Promise<string | undefi
return typeof value === 'string' ? value : c.req.query(name) return typeof value === 'string' ? value : c.req.query(name)
} }
/**
* A concise `describeRoute` spec for one of the thread-scoped actions that answers the
* bare ChatResult integer rather than an HTTP status — rename, leave, snooze, favorite,
* add-member and the read-pointer moves. They share the auth gate, the `:id` path param,
* and the "3 when the caller isn't on the thread" behaviour.
*/
function chatResultRoute(
summary: string,
description: string,
extra: {
requestBody?: ReturnType<typeof form>
parameters?: unknown[]
successDescription?: string
/** Set for the read-pointer routes, which 404 a non-member instead of answering 3. */
notFound?: boolean
} = {}
) {
return describeRoute({
tags: ['Chat'],
summary,
description,
security: AUTHED,
parameters: [THREAD_ID_PARAM, ...((extra.parameters ?? []) as never[])],
...(extra.requestBody === undefined ? {} : { requestBody: extra.requestBody }),
responses: {
200: json(
ChatResult,
extra.successDescription ??
'The ChatResult (0 on success, 3 when the caller isnt on the thread)'
),
401: UNAUTHORIZED_RESPONSE,
...(extra.notFound === true ? { 404: NOT_A_MEMBER_RESPONSE } : {}),
},
})
}
/**
* The `describeRoute` spec shared by the two spellings of "send to an existing thread".
* `/thread/{id}` is what the client posts; `/thread/{id}/message` is the same call under
* the reference's other spelling, and both land in `sendToThread`.
*/
function sendToThreadRoute(spelling: string) {
return describeRoute({
tags: ['Messages'],
summary: `Send a message to an existing thread (${spelling})`,
description: [
'Every message after the one that opened the conversation. Answers',
'`{ chatResult, chatThread }` — the WHOLE thread with its messages, not just the message',
'that was sent, so the client re-renders the conversation from one response. Blank or',
'missing `messageContents` stores nothing and reports invalid-arguments (1), still with',
'the thread attached, rather than an error status. Sending is reading: the senders own',
'`lastReadMessageId` comes back already at the message just posted. Pushes',
'ChatMessageReceived to every member, the sender included — the client doesnt fold the',
'HTTP response into its local cache, so without a self-targeted push its own outgoing',
'message doesnt appear until the thread is refetched. Note the hub frames `Id` is a',
'STRING: the client dispatches on it and silently drops a numeric one.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM],
requestBody: form(SendMessageRequest, 'The message envelope'),
responses: {
200: json(SendMessageResponse, 'The ChatResult plus the whole thread with its messages'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
})
}
const app = new Hono<App>() const app = new Hono<App>()
.use( .use(
'*', '*',
@@ -232,17 +323,45 @@ const app = new Hono<App>()
.onError(withOnError()) .onError(withOnError())
.notFound(withNotFound()) .notFound(withNotFound())
.get('/', (c) => c.json({ service: 'chat', status: 'ok' })) .get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Service liveness',
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
responses: { 200: json(ServiceStatus, 'Always `{ service: "chat", status: "ok" }`') },
}),
(c) => c.json({ service: 'chat', status: 'ok' })
)
// The player's own thread list, newest conversation first — each thread carrying its // The player's own thread list, newest conversation first — each thread carrying its
// latest message and the caller's own read/snooze/favorite state. `MessageCount` is // latest message and the caller's own read/snooze/favorite state. `MessageCount` is
// the page size (of threads, despite the name). Membership scopes the query, so a // the page size (of threads, despite the name). Membership scopes the query, so a
// player only ever sees their own threads. // player only ever sees their own threads.
.get('/thread', async (c) => { .get(
'/thread',
describeRoute({
tags: ['Threads'],
summary: 'The callers thread list',
description: [
'Every thread the caller is a member of, newest conversation first — each carrying its',
'`latestMessage` and the callers own read/snooze/favorite state. `MessageCount` is the',
'page size (of THREADS, despite the name). Membership scopes the query, so a player',
'only ever sees their own threads.',
].join(' '),
security: AUTHED,
parameters: [messageCountParam(DEFAULT_MESSAGE_COUNT)],
responses: {
200: json(ChatThreadDto.array(), 'The callers threads, newest first (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
return c.json(await getThreadsForPlayer(c.env.DB, id, { limit: messageCount(c) })) return c.json(await getThreadsForPlayer(c.env.DB, id, { limit: messageCount(c) }))
}) }
)
// Send to a set of players (`ids=155&ids=2&messageContents=…`) — the client's // Send to a set of players (`ids=155&ids=2&messageContents=…`) — the client's
// create-thread-and-post-first-message call, in one. Resolves to the thread those // create-thread-and-post-first-message call, in one. Resolves to the thread those
@@ -252,7 +371,33 @@ const app = new Hono<App>()
// (`{"Type":0,"Version":1,"Data":"…"}`) and is stored verbatim, unparsed. The client // (`{"Type":0,"Version":1,"Data":"…"}`) and is stored verbatim, unparsed. The client
// also sends it blank, right after /thread/withmembers: that opens the thread without // also sends it blank, right after /thread/withmembers: that opens the thread without
// posting an empty message, and reports invalid-arguments the way the reference does. // posting an empty message, and reports invalid-arguments the way the reference does.
.post('/thread', async (c) => { .post(
'/thread',
describeRoute({
tags: ['Threads'],
summary: 'Open a thread with a set of players and post the first message',
description: [
'The clients create-thread-and-post-first-message call, in one. Resolves to the thread',
'those players already share rather than opening a second one. `messageContents` is the',
'same envelope a message carries and is stored verbatim, unparsed; the client also sends',
'it blank right after `/thread/withmembers`, which opens the thread without posting and',
'reports invalid-arguments. Answers a `{ chatThread, chatResult }` wrapper, not a bare',
'thread. Pushes ChatMessageReceived to every member (including the sender).',
].join(' '),
security: AUTHED,
requestBody: form(CreateThreadRequest, 'The member ids and the first message'),
responses: {
200: json(CreateThreadResponse, 'The thread plus the result of the first message'),
400: {
description: [
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
'(empty body)',
].join(' '),
},
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -278,7 +423,8 @@ const app = new Hono<App>()
chatThread: thread, chatThread: thread,
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS, chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
}) })
}) }
)
// "Open the chat with these people" — the client's GetChatBetweenPlayers. Fetch or // "Open the chat with these people" — the client's GetChatBetweenPlayers. Fetch or
// create: the thread whose membership is exactly `ids` plus the caller, opened only // create: the thread whose membership is exactly `ids` plus the caller, opened only
@@ -288,7 +434,33 @@ const app = new Hono<App>()
// Answers the thread with a `messages` array (what `messageCount` sizes) rather than // Answers the thread with a `messages` array (what `messageCount` sizes) rather than
// the list's single `latestMessage`, so the client can open straight into the // the list's single `latestMessage`, so the client can open straight into the
// conversation. The array is always present, empty for a brand-new thread. // conversation. The array is always present, empty for a brand-new thread.
.post('/thread/withmembers', async (c) => { .post(
'/thread/withmembers',
describeRoute({
tags: ['Threads'],
summary: 'Fetch or open the thread with exactly these members',
description: [
'The clients GetChatBetweenPlayers. Fetch-or-create: the thread whose membership is',
'exactly `ids` plus the caller, opened only if they dont already share one (returning a',
'fresh empty thread each call would bury the real conversation). Answers the thread with',
'a `messages` array — what `messageCount` sizes — rather than the lists single',
'`latestMessage`, so the client can open straight into the conversation. The array is',
'always present, empty for a brand-new thread.',
].join(' '),
security: AUTHED,
requestBody: form(WithMembersRequest, 'The member ids and the page size'),
responses: {
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
400: {
description: [
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
'(empty body)',
].join(' '),
},
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -302,7 +474,8 @@ const app = new Hono<App>()
const thread = await threadWithMessages(c, chatThreadId, id, limit) const thread = await threadWithMessages(c, chatThreadId, id, limit)
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`) if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
return c.json(thread) return c.json(thread)
}) }
)
// A page of one thread's messages, newest first — a bare array, not a thread object. // A page of one thread's messages, newest first — a bare array, not a thread object.
// The client reads a conversation through either spelling: `/thread/2?messageCount=50` // The client reads a conversation through either spelling: `/thread/2?messageCount=50`
@@ -320,7 +493,27 @@ const app = new Hono<App>()
// //
// 404s only for a thread the caller isn't in, not for one that's simply empty: a // 404s only for a thread the caller isn't in, not for one that's simply empty: a
// thread just opened with someone has no messages yet and still has to open. // thread just opened with someone has no messages yet and still has to open.
.get('/thread/:id{[0-9]+}', async (c) => { .get(
'/thread/:id{[0-9]+}',
describeRoute({
tags: ['Threads'],
summary: 'One thread with its recent messages',
description: [
'What the client opens a conversation with (`/thread/13?messageCount=50`). An OBJECT —',
'the same shape `/thread/withmembers` answers: the client parses this one as a thread',
"and rejects a bare array (\"expected '{', actual '['\"). Only `/thread/{id}/message`",
'serves an array. 404s only for a thread the caller isnt in, not for one thats simply',
'empty — a thread just opened with someone has no messages yet and still has to open.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_THREAD_MESSAGE_COUNT)],
responses: {
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -328,7 +521,8 @@ const app = new Hono<App>()
const limit = messageCount(c, DEFAULT_THREAD_MESSAGE_COUNT) const limit = messageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
const thread = await threadWithMessages(c, chatThreadId, id, limit) const thread = await threadWithMessages(c, chatThreadId, id, limit)
return thread === null ? c.notFound() : c.json(thread) return thread === null ? c.notFound() : c.json(thread)
}) }
)
// Send a message to a thread that already exists — every message after the one that // Send a message to a thread that already exists — every message after the one that
// opened the conversation. `/thread/18` is what the client posts; `/thread/18/message` // opened the conversation. `/thread/18` is what the client posts; `/thread/18/message`
@@ -337,13 +531,27 @@ const app = new Hono<App>()
// Answers the SendMessageResponse wrapper (`{chatMessage, chatResult}`), not a bare // Answers the SendMessageResponse wrapper (`{chatMessage, chatResult}`), not a bare
// message. Blank or missing contents is invalid-arguments with no message attached, // message. Blank or missing contents is invalid-arguments with no message attached,
// rather than an error status. // rather than an error status.
.post('/thread/:id{[0-9]+}', (c) => sendToThread(c)) .post('/thread/:id{[0-9]+}', sendToThreadRoute('`/thread/{id}`'), (c) => sendToThread(c))
.post('/thread/:id{[0-9]+}/message', (c) => sendToThread(c)) .post('/thread/:id{[0-9]+}/message', sendToThreadRoute('`/thread/{id}/message`'), (c) =>
sendToThread(c)
)
// Rename a thread (`name=my chat`). Any member may rename — there's no owner — and an // Rename a thread (`name=my chat`). Any member may rename — there's no owner — and an
// empty name clears it back to unnamed, which renders as the member list. Answers a // empty name clears it back to unnamed, which renders as the member list. Answers a
// bare ChatResult: 3 when the caller isn't on the thread, 0 on success. // bare ChatResult: 3 when the caller isn't on the thread, 0 on success.
.on(['POST', 'PUT'], '/thread/:id{[0-9]+}/rename', async (c) => { .on(
['POST', 'PUT'],
'/thread/:id{[0-9]+}/rename',
chatResultRoute(
'Rename a thread',
[
'Any member may rename — there is no owner — and an empty name clears it back to unnamed,',
'which renders as the member list. The name is truncated to 128 characters rather than',
'rejected. Answers a bare ChatResult: 3 when the caller isnt on the thread, 0 on success.',
].join(' '),
{ requestBody: form(RenameThreadRequest, 'The new name') }
),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -355,7 +563,8 @@ const app = new Hono<App>()
const name = ((await formField(c, 'name')) ?? '').trim().slice(0, MAX_THREAD_NAME_LENGTH) const name = ((await formField(c, 'name')) ?? '').trim().slice(0, MAX_THREAD_NAME_LENGTH)
await setThreadName(c.env.DB, chatThreadId, name) await setThreadName(c.env.DB, chatThreadId, name)
return c.json(CHAT_SUCCESS) return c.json(CHAT_SUCCESS)
}) }
)
// Leave a thread. The thread and its history survive — only the caller's membership // Leave a thread. The thread and its history survive — only the caller's membership
// goes, so they stop seeing it and the remaining members keep the conversation. // goes, so they stop seeing it and the remaining members keep the conversation.
@@ -363,7 +572,20 @@ const app = new Hono<App>()
// A "Player <@U…> left" notice is posted first, so the others see why the roster // A "Player <@U…> left" notice is posted first, so the others see why the roster
// changed; the leaver is still a member at that moment and gets the push too, which // changed; the leaver is still a member at that moment and gets the push too, which
// is what tells their client the thread is gone. // is what tells their client the thread is gone.
.on(['POST', 'DELETE'], '/thread/:id{[0-9]+}/leave', async (c) => { .on(
['POST', 'DELETE'],
'/thread/:id{[0-9]+}/leave',
chatResultRoute(
'Leave a thread',
[
'The thread and its history survive — only the callers membership goes, so they stop',
'seeing it and the remaining members keep the conversation. A "Player <@U…> left" system',
'notice is posted first so the others see why the roster changed; the leaver is still a',
'member at that moment and gets the push too, which is what tells their client the thread',
'is gone.',
].join(' ')
),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -381,7 +603,8 @@ const app = new Hono<App>()
await removeThreadMember(c.env.DB, chatThreadId, id) await removeThreadMember(c.env.DB, chatThreadId, id)
return c.json(CHAT_SUCCESS) return c.json(CHAT_SUCCESS)
}) }
)
// Snooze or unsnooze a thread (`snooze=True`), for the caller alone — snoozing is a // Snooze or unsnooze a thread (`snooze=True`), for the caller alone — snoozing is a
// per-member setting, so it never affects what anyone else sees. // per-member setting, so it never affects what anyone else sees.
@@ -390,7 +613,20 @@ const app = new Hono<App>()
// `True` is therefore stored as a far-future instant meaning "muted indefinitely", and // `True` is therefore stored as a far-future instant meaning "muted indefinitely", and
// `False` clears it. If the real server instead snoozes for a fixed window, this is // `False` clears it. If the real server instead snoozes for a fixed window, this is
// the one line to change. // the one line to change.
.on(['POST', 'PUT'], '/thread/:id{[0-9]+}/snooze', async (c) => { .on(
['POST', 'PUT'],
'/thread/:id{[0-9]+}/snooze',
chatResultRoute(
'Snooze or unsnooze a thread',
[
'Per-member, for the caller alone — it never affects what anyone else sees. The client',
'sends a boolean while the field it reads back (`snoozedUntil`) is a time, so `True` is',
'stored as a far-future instant (9999-12-31T23:59:59Z) meaning "muted indefinitely" and',
'`False` clears it.',
].join(' '),
{ requestBody: form(SnoozeThreadRequest, 'The snooze flag') }
),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -402,11 +638,23 @@ const app = new Hono<App>()
const on = await formBool(c, 'snooze') const on = await formBool(c, 'snooze')
await setThreadSnoozed(c.env.DB, chatThreadId, id, on ? SNOOZED_INDEFINITELY : null) await setThreadSnoozed(c.env.DB, chatThreadId, id, on ? SNOOZED_INDEFINITELY : null)
return c.json(CHAT_SUCCESS) return c.json(CHAT_SUCCESS)
}) }
)
// Favorite or unfavorite a thread (`favorite=True`), for the caller alone — like // Favorite or unfavorite a thread (`favorite=True`), for the caller alone — like
// snoozing, it's a per-member flag that pins the thread in their own inbox. // snoozing, it's a per-member flag that pins the thread in their own inbox.
.on(['PUT', 'POST'], '/thread/:id{[0-9]+}/favorite', async (c) => { .on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/favorite',
chatResultRoute(
'Favorite or unfavorite a thread',
[
'Like snoozing, a per-member flag that pins the thread in the callers own inbox and',
'leaves everyone elses untouched.',
].join(' '),
{ requestBody: form(FavoriteThreadRequest, 'The favorite flag') }
),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -417,7 +665,8 @@ const app = new Hono<App>()
await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite')) await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite'))
return c.json(CHAT_SUCCESS) return c.json(CHAT_SUCCESS)
}) }
)
// Add a player to a thread (`/thread/20/member/2`). Gated on the caller already being // Add a player to a thread (`/thread/20/member/2`). Gated on the caller already being
// in it — you can only pull someone into a conversation you're part of. // in it — you can only pull someone into a conversation you're part of.
@@ -426,7 +675,31 @@ const app = new Hono<App>()
// the caller isn't a member (which doubles as "no such thread", keeping a thread's // the caller isn't a member (which doubles as "no such thread", keeping a thread's
// existence private), 4 when the target is already on it, 0 on success. Idempotent — // existence private), 4 when the target is already on it, 0 on success. Idempotent —
// re-adding an existing member changes nothing. // re-adding an existing member changes nothing.
.post('/thread/:id{[0-9]+}/member/:playerId{[0-9]+}', async (c) => { .post(
'/thread/:id{[0-9]+}/member/:playerId{[0-9]+}',
chatResultRoute(
'Add a player to a thread',
[
'Gated on the caller already being in it — you can only pull someone into a conversation',
'youre part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
'does: 3 when the caller isnt a member (which doubles as "no such thread", keeping a',
'threads existence private), 4 when the target is already on it, 0 on success.',
'Idempotent — re-adding an existing member changes nothing.',
].join(' '),
{
parameters: [
{
name: 'playerId',
in: 'path',
required: true,
description: 'The account id to add (digits only)',
schema: { type: 'string' },
},
],
successDescription: '0 success · 3 caller not a member · 4 target already on the thread',
}
),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -442,7 +715,8 @@ const app = new Hono<App>()
await addThreadMember(c.env.DB, chatThreadId, playerId) await addThreadMember(c.env.DB, chatThreadId, playerId)
return c.json(CHAT_SUCCESS) return c.json(CHAT_SUCCESS)
}) }
)
// Move the caller's read pointer — `/thread/15/read` for the whole thread, or // Move the caller's read pointer — `/thread/15/read` for the whole thread, or
// `/thread/15/message/:messageId/read` for a specific message, which the client uses // `/thread/15/message/:messageId/read` for a specific message, which the client uses
@@ -452,16 +726,71 @@ const app = new Hono<App>()
// The pointer only moves forward, and never past the thread's real latest message: an // The pointer only moves forward, and never past the thread's real latest message: an
// id the client made up (or one it read from a synthetic message) can't strand the // id the client made up (or one it read from a synthetic message) can't strand the
// thread as permanently read. // thread as permanently read.
.on(['PUT', 'POST'], '/thread/:id{[0-9]+}/read', (c) => markRead(c)) .on(
.on(['PUT', 'POST'], '/thread/:id{[0-9]+}/message/:messageId{[0-9]+}/read', (c) => ['PUT', 'POST'],
markRead(c, Number.parseInt(c.req.param('messageId'), 10)) '/thread/:id{[0-9]+}/read',
chatResultRoute(
'Mark a whole thread read',
[
'Moves the callers read pointer to the threads latest message. The pointer only moves',
'forward and never past the threads real latest message, so an id the client made up',
'cant strand the thread as permanently read. 404s for a thread the caller isnt on.',
].join(' '),
{ successDescription: 'Always 0 (success)', notFound: true }
),
(c) => markRead(c)
)
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/message/:messageId{[0-9]+}/read',
chatResultRoute(
'Mark read up to a specific message',
[
'What the client sends when the view sits on a message rather than the bottom. Same',
'forward-only, clamped pointer as the whole-thread form. 404s for a thread the caller',
'isnt on.',
].join(' '),
{
parameters: [
{
name: 'messageId',
in: 'path',
required: true,
description: 'The message to read up to (digits only)',
schema: { type: 'string' },
},
],
successDescription: 'Always 0 (success)',
notFound: true,
}
),
(c) => markRead(c, Number.parseInt(c.req.param('messageId'), 10))
) )
// A page of one thread's messages, newest first — a bare array, unlike /thread/:id. // A page of one thread's messages, newest first — a bare array, unlike /thread/:id.
// `MessageCount` is the page size. 404 rather than 403 for a thread the caller isn't // `MessageCount` is the page size. 404 rather than 403 for a thread the caller isn't
// in: whether a thread exists is itself private, so a non-member gets the same answer // in: whether a thread exists is itself private, so a non-member gets the same answer
// as for a thread that's gone. // as for a thread that's gone.
.get('/thread/:id{[0-9]+}/message', async (c) => { .get(
'/thread/:id{[0-9]+}/message',
describeRoute({
tags: ['Messages'],
summary: 'A page of one threads messages',
description: [
'Newest first — a bare ARRAY, unlike `/thread/{id}`, which serves the thread object.',
'`MessageCount` is the page size. 404 rather than 403 for a thread the caller isnt in:',
'whether a thread exists is itself private, so a non-member gets the same answer as for a',
'thread thats gone. An empty thread is still a 200 with `[]`.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_MESSAGE_COUNT)],
responses: {
200: json(ChatMessageDto.array(), 'The page of messages, newest first (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -469,6 +798,44 @@ const app = new Hono<App>()
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound() if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
return c.json(await getThreadMessages(c.env.DB, chatThreadId, { limit: messageCount(c) })) return c.json(await getThreadMessages(c.env.DB, chatThreadId, { limit: messageCount(c) }))
}
)
// 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 chat',
version: '1.0.0',
description: [
'Chat threads and messages for recflare, a private-server reimplementation of the Rec',
'Room backend. A thread is a conversation — a DM pair, a named group, or a system',
'thread — and membership is both the authorization gate and the `playerIds` the client',
'renders. Threads, membership and messages are D1-backed; every message also fans out',
'over the `notify` hub Durable Object as a ChatMessageReceived frame, so a conversation',
'updates live instead of on the next poll. (The hub frame carries a STRING `Id` — the',
'client dispatches on it and silently drops a numeric one.)',
].join('\n'),
},
servers: [{ url: 'https://chat.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 export default app
+234
View File
@@ -0,0 +1,234 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the chat 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 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) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(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: [] }]
/**
* The 404 a thread-scoped route answers when the caller isn't a member. Deliberately
* indistinguishable from "no such thread" — whether a thread exists is itself private.
*/
export const NOT_A_MEMBER_RESPONSE = {
description: 'Not a member of the thread (or no such thread) — the two are indistinguishable',
}
// ---- Response schemas ------------------------------------------------------
/**
* A chat message as stored and served (see message-db.ts). `contents` is the client's own
* envelope (`{"Type":0,"Version":1,"Data":"hello"}`) — stored verbatim and served back
* untouched, so new message types need no schema change. A `senderPlayerId` of -5 is the
* system pseudo-player the "started a chat" / "left" notices are posted as.
*/
export const ChatMessageDto = z.object({
chatMessageId: z.int().describe('Server-assigned, unique across all threads'),
chatThreadId: z.int(),
senderPlayerId: z.int().describe('-5 is the system sender (join/leave notices)'),
timeSent: z.string().describe('ISO-8601 UTC instant, as .NET serializes DateTime'),
contents: z.string().describe('The raw client envelope, e.g. {"Type":0,"Version":1,"Data":"hi"}'),
moderationState: z.int().describe('0 None, 1 Flagged, 2 Hidden'),
})
/** The per-viewer fields every rendered thread carries, plus the thread's own. */
const threadBase = {
chatThreadId: z.int(),
playerIds: z.array(z.int()).describe('The threads members, ordered by id'),
lastReadMessageId: z
.int()
.describe('0 when never read — never null (the client deserializes a non-nullable int)'),
chatThreadName: z
.string()
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
chatThreadType: z.int().describe('Always 0 — the only type the reference serves'),
snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
isFavorited: z.boolean(),
}
/**
* A thread as it appears in the thread LIST: the thread, its members, the caller's own
* read/snooze/favorite state, and its single most recent message.
*/
export const ChatThreadDto = z.object({
latestMessage: ChatMessageDto.nullable().describe('Null only for a thread with no messages yet'),
...threadBase,
})
/**
* A thread as it appears when a conversation is OPENED: the same fields, but with a page
* of `messages` (newest first) in place of `latestMessage`. The client is sent one or the
* other, never both; `messages` is always present, empty for a brand-new thread.
*/
export const ChatThreadWithMessagesDto = z.object({
...threadBase,
messages: z.array(ChatMessageDto).describe('Newest first; empty for a thread with nothing in it'),
})
/**
* The bare ChatResult integer several actions answer with (HTTP 200 either way): 0
* success, 1 invalid arguments, 3 membership not found (which doubles as "no such
* thread"), 4 player already on the thread.
*/
export const ChatResult = z
.int()
.describe('0 success · 1 invalid arguments · 3 membership not found · 4 already on thread')
/**
* `POST /thread` — the reference's wrapper: the created (or resolved) thread plus the
* result of the first message. Blank `messageContents` opens the thread without posting
* and reports invalid-arguments (1), still with the thread attached.
*/
export const CreateThreadResponse = z.object({
chatThread: ChatThreadDto,
chatResult: ChatResult,
})
/**
* `POST /thread/:id` and `/thread/:id/message` — the whole thread with its messages, not
* just the message that was sent, so the client re-renders the conversation from one
* response.
*/
export const SendMessageResponse = z.object({
chatResult: ChatResult,
chatThread: ChatThreadWithMessagesDto.nullable(),
})
/** `GET /` — the liveness probe. */
export const ServiceStatus = z.object({
service: z.literal('chat'),
status: z.literal('ok'),
})
// ---- Request schemas -------------------------------------------------------
/**
* `POST /thread` form body. `ids` is repeated (`ids=2&ids=155`) and names the OTHER
* members; the caller is always added. Values that aren't integers are dropped. The
* fields are also read from the query string, since the same call is easy to hand-write
* that way.
*/
export const CreateThreadRequest = z.object({
ids: z.array(z.int()).describe('Repeated: ids=2&ids=155. The caller is added automatically'),
messageContents: z
.string()
.optional()
.describe(
[
'The client envelope, stored verbatim and unparsed. Blank/absent opens the thread',
'without posting a message and reports chatResult 1',
].join(' ')
),
})
/**
* `POST /thread/withmembers` form body — the client's GetChatBetweenPlayers. Same
* repeated `ids`, plus the page size for the returned `messages`.
*/
export const WithMembersRequest = z.object({
ids: z.array(z.int()).describe('Repeated: ids=2&ids=155. The caller is added automatically'),
messageCount: z
.int()
.optional()
.describe('Page size for `messages`; defaults to 50, capped at 100'),
})
/** `POST /thread/:id` (and `/thread/:id/message`) form body. */
export const SendMessageRequest = z.object({
messageContents: z
.string()
.describe(
[
'The client envelope (Type/Version/Data), stored verbatim. Blank or missing stores',
'nothing and reports chatResult 1, still with the thread attached',
].join(' ')
),
messageCount: z.int().optional().describe('Page size for the returned threads `messages`'),
})
/** `POST|PUT /thread/:id/rename` form body. Any member may rename; there is no owner. */
export const RenameThreadRequest = z.object({
name: z
.string()
.describe('Truncated to 128 chars, not rejected. Empty clears it back to unnamed'),
})
/** `POST|PUT /thread/:id/snooze` form body. */
export const SnoozeThreadRequest = z.object({
snooze: z
.string()
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
})
/** `PUT|POST /thread/:id/favorite` form body. */
export const FavoriteThreadRequest = z.object({
favorite: z
.string()
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
})
// ---- Shared parameters -----------------------------------------------------
/** The numeric `:id` path segment naming a thread (constrained to digits by the route). */
export const THREAD_ID_PARAM = {
name: 'id',
in: 'path',
required: true,
description: 'Chat thread id (digits only — a non-numeric path matches no route)',
schema: { type: 'string' },
} as const
/** The `MessageCount` / `messageCount` query param the GET routes accept. */
export function messageCountParam(fallback: number) {
return {
name: 'MessageCount',
in: 'query',
required: false,
description: `Page size; defaults to ${fallback}, capped at 100. \`messageCount\` is accepted too. Anything unparseable or out of range falls back rather than 400ing`,
schema: { type: 'integer' },
} as const
}
@@ -1265,3 +1265,61 @@ describe('PUT /thread/:id/favorite', () => {
expect(res.status).toBe(401) expect(res.status).toBe(401)
}) })
}) })
describe('openapi', () => {
it('GET /openapi.json documents every route', 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.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every schema inlines — a `$ref` here means a schema picked up a `.meta({ id })`
// and emitted a reference the framework didn't hoist into components.schemas.
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
// `.on([...], …)` routes contribute every method they were registered for.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'DELETE /thread/{id}/leave',
'GET /',
'GET /thread',
'GET /thread/{id}',
'GET /thread/{id}/message',
'POST /thread',
'POST /thread/withmembers',
'POST /thread/{id}',
'POST /thread/{id}/favorite',
'POST /thread/{id}/leave',
'POST /thread/{id}/member/{playerId}',
'POST /thread/{id}/message',
'POST /thread/{id}/message/{messageId}/read',
'POST /thread/{id}/read',
'POST /thread/{id}/rename',
'POST /thread/{id}/snooze',
'PUT /thread/{id}/favorite',
'PUT /thread/{id}/message/{messageId}/read',
'PUT /thread/{id}/read',
'PUT /thread/{id}/rename',
'PUT /thread/{id}/snooze',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
})
+6 -1
View File
@@ -19,8 +19,13 @@
"@repo/domain": "workspace:*", "@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.12.27",
"workers-tagged-logger": "1.0.1" "hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20", "@cloudflare/vitest-pool-workers": "0.16.20",
+757 -57
View File
@@ -1,7 +1,8 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
import { import {
@@ -30,10 +31,58 @@ import {
setHomeClub, setHomeClub,
updateClub, updateClub,
} from './clubs-db' } from './clubs-db'
import {
AnnouncementIdEnvelope,
AnnouncementRequest,
AUTHED,
CategoryTags,
ChatDisabledResponse,
ClubAnnouncementsEnvelope,
ClubDetailsDto,
ClubDetailsEnvelope,
ClubDto,
ClubEnvelope,
ClubhouseRequest,
ClubMembersEnvelope,
ClubSearchResponse,
CreateClubRequest,
EmptyObject,
ErrorEnvelope,
form,
HomeClubRequest,
ImageNameRequest,
json,
JsonArray,
MinLevelRequest,
ModifyClubRequest,
NullEnvelope,
SubscriberCountResponse,
SubscriptionDetailsResponse,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { App } from './context' import type { App } from './context'
/**
* Clubs Worker. Hosts the club endpoints the game client calls on the `clubs` host:
* club creation and editing, membership (join / ask-to-join / leave / ban tiers),
* search, announcements, the club gallery, a club's clubhouse room, and each player's
* home club. Everything is D1-backed (the shared `recflare` database); the
* `/subscription/*` routes are stubs, since there are no subscription clubs yet.
*
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
*/
/** The `clubId` path parameter, shared by every per-club route. */
const CLUB_ID_PARAM = {
name: 'clubId',
in: 'path',
required: true,
description: 'The clubs id (digits only — a non-numeric id doesnt match the route)',
schema: { type: 'string' },
} as const
/** /**
* Resolve the account id from a Bearer token. Returns `null` when the header is * Resolve the account id from a Bearer token. Returns `null` when the header is
* missing, the token is invalid, or the `sub` claim isn't an integer. * missing, the token is invalid, or the `sub` claim isn't an integer.
@@ -54,7 +103,7 @@ const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
const MAX_CLUB_NAME_LENGTH = 16 const MAX_CLUB_NAME_LENGTH = 16
/** The punctuation a club name may use, on top of letters and digits. */ /** The punctuation a club name may use, on top of letters and digits. */
const ALLOWED_NAME_PUNCTUATION = new Set([...` .,'!?-_&()#@:+`]) const ALLOWED_NAME_PUNCTUATION = new Set(` .,'!?-_&()#@:+`)
/** /**
* Club names are letters (any Latin script), digits, and basic punctuation — the * Club names are letters (any Latin script), digits, and basic punctuation — the
@@ -142,16 +191,55 @@ const app = new Hono<App>()
// account. Auth-gated. 404 when they have no home club, the club is gone, or it has // account. Auth-gated. 404 when they have no home club, the club is gone, or it has
// no clubhouse room: the client expects a 404 for "no home club" and errors on an // no clubhouse room: the client expects a 404 for "no home club" and errors on an
// empty object. Returns the bare club (not the envelope), as the reference does. // empty object. Returns the bare club (not the envelope), as the reference does.
.get('/club/home/me', async (c) => { .get(
'/club/home/me',
describeRoute({
tags: ['Home club'],
summary: 'The players home club',
description: [
'The club whose clubhouse the player spawns into (a field on their account row).',
'404 when they have no home club, the club is gone, or it has no clubhouse room —',
'the client expects a 404 for “no home club” and errors on an empty object. Returns',
'the bare club, not the envelope, as the reference does.',
].join(' '),
security: AUTHED,
responses: {
200: json(ClubDto, 'The players home club'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No home club, or it has no clubhouse room' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
const club = await getHomeClub(c.env.DB, id) const club = await getHomeClub(c.env.DB, id)
return club === null ? c.notFound() : c.json(club) return club === null ? c.notFound() : c.json(club)
}) }
)
// Set the player's home club (`clubId` form field). They must be a member of it — // Set the player's home club (`clubId` form field). They must be a member of it —
// you can't make a club you don't belong to your home. Answers the envelope. // you can't make a club you don't belong to your home. Answers the envelope.
.put('/club/home/me', async (c) => { .put(
'/club/home/me',
describeRoute({
tags: ['Home club'],
summary: 'Set the players home club',
description: [
'Points the players home club at the posted `clubId`. They must already be a member',
'of it — you cant make a club you dont belong to your home. Answers the envelope',
'carrying the bare club.',
].join(' '),
security: AUTHED,
requestBody: form(HomeClubRequest, 'The club to make home'),
responses: {
200: json(ClubEnvelope, 'The envelope carrying the new home club'),
400: json(ErrorEnvelope, 'Missing, non-numeric or zero clubId'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The caller isnt a member of that club'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -176,27 +264,77 @@ const app = new Hono<App>()
await setHomeClub(c.env.DB, id, clubId) await setHomeClub(c.env.DB, id, clubId)
return c.json({ error: '', success: true, value: club }) return c.json({ error: '', success: true, value: club })
}) }
)
// Clear the player's home club — they spawn into the default hub again instead of a // Clear the player's home club — they spawn into the default hub again instead of a
// clubhouse. No body, idempotent (clearing when there's none set is a no-op, not a // clubhouse. No body, idempotent (clearing when there's none set is a no-op, not a
// 404), and it doesn't touch their membership of the club. The envelope's value is // 404), and it doesn't touch their membership of the club. The envelope's value is
// null because there's no home club left to describe; GET goes back to 404ing. // null because there's no home club left to describe; GET goes back to 404ing.
.delete('/club/home/me', async (c) => { .delete(
'/club/home/me',
describeRoute({
tags: ['Home club'],
summary: 'Clear the players home club',
description: [
'The player spawns into the default hub again instead of a clubhouse. No body,',
'idempotent (clearing when none is set is a no-op, not a 404), and it doesnt touch',
'their membership of the club. The envelopes `value` is null because theres no home',
'club left to describe; GET goes back to 404ing.',
].join(' '),
security: AUTHED,
responses: {
200: json(NullEnvelope, 'Cleared (value null)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
await clearHomeClub(c.env.DB, id) await clearHomeClub(c.env.DB, id)
return c.json({ error: '', success: true, value: null }) return c.json({ error: '', success: true, value: null })
}) }
)
// A real Rec Room client endpoint with no backing implementation yet. The // A real Rec Room client endpoint with no backing implementation yet. The
// client calls it on the clubs host at /subscription/mine/member (no /club // client calls it on the clubs host at /subscription/mine/member (no /club
// prefix) and sends no auth header, so it isn't gated. Returns an empty // prefix) and sends no auth header, so it isn't gated. Returns an empty
// array = no club subscription memberships (the client chokes on null). // array = no club subscription memberships (the client chokes on null).
.get('/subscription/mine/member', (c) => c.json([])) .get(
'/subscription/mine/member',
describeRoute({
tags: ['Subscriptions'],
summary: 'The callers club-subscription memberships',
description: [
'A real client endpoint with no backing implementation yet. The client calls it on',
'the clubs host at `/subscription/mine/member` (no `/club` prefix) and sends no auth',
'header, so it isnt gated. Always `[]` — no subscription memberships (the client',
'chokes on null).',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty for now') },
}),
(c) => c.json([])
)
// Subscription details for an account (numeric id) — simulated: no club, no subs. // Subscription details for an account (numeric id) — simulated: no club, no subs.
.get('/subscription/details/:accountId{[0-9]+}', (c) => .get(
'/subscription/details/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscription details for an account',
description: 'Simulated — no subscription club, no subscribers.',
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'Account id (digits only)',
schema: { type: 'string' },
},
],
responses: { 200: json(SubscriptionDetailsResponse, 'Zeroed subscription details') },
}),
(c) =>
c.json({ c.json({
accountId: Number.parseInt(c.req.param('accountId'), 10), accountId: Number.parseInt(c.req.param('accountId'), 10),
clubId: 0, clubId: 0,
@@ -206,19 +344,84 @@ const app = new Hono<App>()
// Details for a named subscription (e.g. `rrplus`). The client deserializes this // Details for a named subscription (e.g. `rrplus`). The client deserializes this
// into an object, so it must return `{}` (not `[]`). // into an object, so it must return `{}` (not `[]`).
.get('/subscription/details/:subscription', (c) => c.json({})) .get(
'/subscription/details/:subscription',
describeRoute({
tags: ['Subscriptions'],
summary: 'Details for a named subscription',
description: [
'A named subscription (e.g. `rrplus`). The client deserializes this into an object,',
'so it must return `{}` — not `[]`.',
].join(' '),
parameters: [
{
name: 'subscription',
in: 'path',
required: true,
description: 'The subscription name, e.g. `rrplus`',
schema: { type: 'string' },
},
],
responses: { 200: json(EmptyObject, 'Always an empty object') },
}),
(c) => c.json({})
)
// Subscriber count for an account. No club subscriptions yet → 0. // Subscriber count for an account. No club subscriptions yet → 0.
.get('/subscription/subscriberCount/:accountId{[0-9]+}', (c) => c.json(0)) .get(
'/subscription/subscriberCount/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscriber count for an account',
description: 'No club subscriptions yet, so this is always 0. A bare JSON integer.',
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'Account id (digits only)',
schema: { type: 'string' },
},
],
responses: { 200: json(SubscriberCountResponse, 'Always 0') },
}),
(c) => c.json(0)
)
// The player's clubs that have unread announcements (MyClubsWithUnread- // The player's clubs that have unread announcements (MyClubsWithUnread-
// Announcements). Nothing tracks what a player has read yet → nothing is unread. // Announcements). Nothing tracks what a player has read yet → nothing is unread.
.get('/announcements/v2/mine/unread', (c) => c.json([])) .get(
'/announcements/v2/mine/unread',
describeRoute({
tags: ['Announcements'],
summary: 'The players clubs with unread announcements',
description: [
'MyClubsWithUnreadAnnouncements. Nothing tracks what a player has read yet, so',
'nothing is unread → always `[]`.',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty for now') },
}),
(c) => c.json([])
)
// A club's announcements — its noticeboard, newest first. Public. Answers the // A club's announcements — its noticeboard, newest first. Public. Answers the
// envelope, with `LastAnnouncementId` the newest one (null when there are none) // envelope, with `LastAnnouncementId` the newest one (null when there are none)
// and `LastReadAnnouncementId` 0: nothing tracks read state yet. // and `LastReadAnnouncementId` 0: nothing tracks read state yet.
.get('/announcements/club/:clubId{[0-9]+}', async (c) => { .get(
'/announcements/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Announcements'],
summary: 'A clubs announcements',
description: [
'The clubs noticeboard, newest first. Public. Answers the envelope, with',
'`LastAnnouncementId` the newest one (null when there are none) and',
'`LastReadAnnouncementId` 0 — nothing tracks read state yet. An unknown club simply',
'has no announcements.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: { 200: json(ClubAnnouncementsEnvelope, 'The clubs noticeboard') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10) const clubId = Number.parseInt(c.req.param('clubId'), 10)
const announcements = await getClubAnnouncements(c.env.DB, clubId) const announcements = await getClubAnnouncements(c.env.DB, clubId)
return c.json({ return c.json({
@@ -231,11 +434,28 @@ const app = new Hono<App>()
LastReadAnnouncementId: 0, LastReadAnnouncementId: 0,
}, },
}) })
}) }
)
// Post an announcement to a club. Co-owner or above only. The envelope's value is // Post an announcement to a club. Co-owner or above only. The envelope's value is
// the new announcement's id. // the new announcement's id.
.post('/announcements/club/:clubId{[0-9]+}', async (c) => { .post(
'/announcements/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Announcements'],
summary: 'Post an announcement to a club',
description: 'Co-owner or above only. The envelopes `value` is the new announcements id.',
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(AnnouncementRequest, 'The announcement fields'),
responses: {
200: json(AnnouncementIdEnvelope, 'The new announcements id'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -262,31 +482,98 @@ const app = new Hono<App>()
meta: field('meta'), meta: field('meta'),
}) })
return c.json({ error: '', success: true, value: announcementId }) return c.json({ error: '', success: true, value: announcementId })
}) }
)
// The clubs the player is a member of (GetMyMembershipClubs). Reads the caller's // The clubs the player is a member of (GetMyMembershipClubs). Reads the caller's
// memberships from `club_member`. A caller with no valid token has no clubs, so // memberships from `club_member`. A caller with no valid token has no clubs, so
// this answers an empty list rather than 401ing — the client shows the "my clubs" // this answers an empty list rather than 401ing — the client shows the "my clubs"
// shelf either way, and an error there breaks the screen. // shelf either way, and an error there breaks the screen.
.get('/club/mine/member', async (c) => { .get(
'/club/mine/member',
describeRoute({
tags: ['Clubs'],
summary: 'The clubs the player is a member of',
description: [
'GetMyMembershipClubs — the callers memberships from `club_member`, oldest club',
'first (pending/denied/banned rows excluded). A caller with no valid token has no',
'clubs, so this answers `[]` rather than 401ing: the client shows the “my clubs”',
'shelf either way, and an error there breaks the screen.',
].join(' '),
security: AUTHED,
responses: { 200: json(ClubDto.array(), 'The callers clubs (empty when signed out)') },
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.json([]) if (id === null) return c.json([])
return c.json(await getClubsByMember(c.env.DB, id)) return c.json(await getClubsByMember(c.env.DB, id))
}) }
)
// The clubs the player created (GetMyCreatedClubs). Empty list when signed out, // The clubs the player created (GetMyCreatedClubs). Empty list when signed out,
// like mine/member. // like mine/member.
.get('/club/mine/created', async (c) => { .get(
'/club/mine/created',
describeRoute({
tags: ['Clubs'],
summary: 'The clubs the player created',
description: 'GetMyCreatedClubs, oldest first. Empty list when signed out, like mine/member.',
security: AUTHED,
responses: { 200: json(ClubDto.array(), 'The clubs the caller created') },
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.json([]) if (id === null) return c.json([])
return c.json(await getClubsByCreator(c.env.DB, id)) return c.json(await getClubsByCreator(c.env.DB, id))
}) }
)
// Club search / browse. Public, non-subscription clubs; `category` filters to that // Club search / browse. Public, non-subscription clubs; `category` filters to that
// category, `query` matches the name or description, `sort` picks the order (1 = // category, `query` matches the name or description, `sort` picks the order (1 =
// newest, 2 = by name, default = most members first), and `count` caps the page // newest, 2 = by name, default = most members first), and `count` caps the page
// (out of range → 30). Public. Answers `{ Clubs, ContinuationToken, TotalClubs }`. // (out of range → 30). Public. Answers `{ Clubs, ContinuationToken, TotalClubs }`.
.get('/club/search', async (c) => { .get(
'/club/search',
describeRoute({
tags: ['Clubs'],
summary: 'Club search / browse',
description: [
'Public, non-subscription clubs. Public (no auth). `TotalClubs` is the full match',
'count, not the page size.',
].join(' '),
parameters: [
{
name: 'category',
in: 'query',
required: false,
description: 'Filter to one category (exact, case-insensitive)',
schema: { type: 'string' },
},
{
name: 'query',
in: 'query',
required: false,
description: 'Substring of the club name or description',
schema: { type: 'string' },
},
{
name: 'sort',
in: 'query',
required: false,
description: '1 = newest first, 2 = by name, anything else = most members first',
schema: { type: 'string' },
},
{
name: 'count',
in: 'query',
required: false,
description: 'Page size; out of range (or absent) falls back to 30',
schema: { type: 'string' },
},
],
responses: { 200: json(ClubSearchResponse, 'The matching page of clubs') },
}),
async (c) => {
const count = Number.parseInt(c.req.query('count') ?? '', 10) const count = Number.parseInt(c.req.query('count') ?? '', 10)
return c.json( return c.json(
await searchClubs( await searchClubs(
@@ -297,17 +584,50 @@ const app = new Hono<App>()
Number.isNaN(count) || count <= 0 || count > 100 ? 30 : count Number.isNaN(count) || count <= 0 || count > 100 ? 30 : count
) )
) )
}) }
)
// The set of club category tags a club can be filed under — a fixed list. // The set of club category tags a club can be filed under — a fixed list.
.get('/club/categoryTags', (c) => .get(
c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment']) '/club/categoryTags',
describeRoute({
tags: ['Clubs'],
summary: 'Club category tags',
description: 'The fixed set of categories a club can be filed under.',
responses: { 200: json(CategoryTags, 'The category list') },
}),
(c) => c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment'])
) )
// Create a club. The client posts a form to `/club/create` with lowercase fields // Create a club. The client posts a form to `/club/create` with lowercase fields
// (`name`, `description`, `category`). Auth-gated. Answers the `{ error, success, // (`name`, `description`, `category`). Auth-gated. Answers the `{ error, success,
// value }` envelope carrying the new club's details — not a bare club. // value }` envelope carrying the new club's details — not a bare club.
.post('/club/create', async (c) => { .post(
'/club/create',
describeRoute({
tags: ['Clubs'],
summary: 'Create a club',
description: [
'The client posts a form with lowercase fields (`name`, `description`, `category`);',
'either casing is accepted. Enums arrive by name (`visibility=Public`,',
'`joinability=Open`). `ClubType` is never taken from the client — a player-created',
'club is always a regular one, since letting the client pick would let it mint a',
'subscription club (type 1), which is excluded from every listing. The caller becomes',
'the clubs Creator. Answers the `{ error, success, value }` envelope carrying the new',
'clubs full details — not a bare club.',
].join(' '),
security: AUTHED,
requestBody: form(CreateClubRequest, 'The new clubs fields'),
responses: {
200: json(ClubDetailsEnvelope, 'The new clubs details'),
400: json(
ErrorEnvelope,
'Missing/invalid/too-long name, or the per-account club limit is reached'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -359,7 +679,8 @@ const app = new Hono<App>()
success: true, success: true,
value: await getClubDetails(c.env.DB, club, id), value: await getClubDetails(c.env.DB, club, id),
}) })
}) }
)
// Edit a club's details. The client PUTs a form of the fields it's changing — // Edit a club's details. The client PUTs a form of the fields it's changing —
// enums by name (`visibility=Public`, `joinability=Open`, `allowJuniors=True`) — // enums by name (`visibility=Public`, `joinability=Open`, `allowJuniors=True`) —
@@ -369,7 +690,33 @@ const app = new Hono<App>()
// //
// `/modify` is the same endpoint under the shorter name the client also PUTs to // `/modify` is the same endpoint under the shorter name the client also PUTs to
// (`name=…&description=…&category=…`); one handler, so the two can't drift. // (`name=…&description=…&category=…`); one handler, so the two can't drift.
.on('PUT', ['/club/:clubId{[0-9]+}/modifydetails', '/club/:clubId{[0-9]+}/modify'], async (c) => { .on(
'PUT',
['/club/:clubId{[0-9]+}/modifydetails', '/club/:clubId{[0-9]+}/modify'],
describeRoute({
tags: ['Clubs'],
summary: 'Edit a clubs details',
description: [
'The client PUTs a form of just the fields its changing — enums by name',
'(`visibility=Public`, `joinability=Open`, `allowJuniors=True`) — and absent fields',
'keep their stored value (an empty `name`/`description` means “unchanged”, not',
'“clear it”). `customTags` may repeat; when present it replaces the clubs tag set',
'wholesale. Co-owner or above only. `/modify` is the same endpoint under the shorter',
'name the client also PUTs to — one handler, so the two cant drift. Answers the same',
'details envelope create does, since the client re-renders the club screen from it.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ModifyClubRequest, 'The fields to change'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Invalid or too-long name'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -384,7 +731,10 @@ const app = new Hono<App>()
} }
// `all: true` so a repeated `customTags` field arrives as a list. // `all: true` so a repeated `customTags` field arrives as a list.
const body = (await c.req.parseBody({ all: true }).catch(() => ({}))) as Record<string, unknown> const body = (await c.req.parseBody({ all: true }).catch(() => ({}))) as Record<
string,
unknown
>
const field = (name: string): string | undefined => { const field = (name: string): string | undefined => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key] const v = key === undefined ? undefined : body[key]
@@ -433,32 +783,95 @@ const app = new Hono<App>()
success: true, success: true,
value: await getClubDetails(c.env.DB, updated, id), value: await getClubDetails(c.env.DB, updated, id),
}) })
}) }
)
// A club's full details — the club plus its tags, the per-tier permissions, and the // A club's full details — the club plus its tags, the per-tier permissions, and the
// caller's own membership. Public (a signed-out viewer just gets MyMembershipType // caller's own membership. Public (a signed-out viewer just gets MyMembershipType
// 0). Unlike create/modifydetails this one is *not* enveloped: the reference writes // 0). Unlike create/modifydetails this one is *not* enveloped: the reference writes
// the details object straight out. // the details object straight out.
.get('/club/:clubId{[0-9]+}/details', async (c) => { .get(
'/club/:clubId{[0-9]+}/details',
describeRoute({
tags: ['Clubs'],
summary: 'A clubs full details',
description: [
'The club plus its custom tags, the per-tier permissions, its gallery, and the',
'callers own membership. Public — a signed-out viewer just gets `MyMembershipType` 0.',
'Unlike create/modifydetails this one is NOT enveloped: the details object is written',
'straight out, as the reference does.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsDto, 'The clubs details (not enveloped)'),
404: { description: 'No such club' },
},
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10) const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId) const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound() if (club === null) return c.notFound()
const id = await authedId(c) const id = await authedId(c)
return c.json(await getClubDetails(c.env.DB, club, id)) return c.json(await getClubDetails(c.env.DB, club, id))
}) }
)
// Whether a club has turned its club chat off. Nothing can disable club chat yet // Whether a club has turned its club chat off. Nothing can disable club chat yet
// (no setting, no storage), so chat is always on → `false`. A bare JSON boolean, // (no setting, no storage), so chat is always on → `false`. A bare JSON boolean,
// like the other `is…`/`has…` gates the client polls; not in the reference, so if // like the other `is…`/`has…` gates the client polls; not in the reference, so if
// the client chokes on this it likely wants the `{ error, success, value }` // the client chokes on this it likely wants the `{ error, success, value }`
// envelope the other club endpoints use. // envelope the other club endpoints use.
.get('/club/:clubId{[0-9]+}/hasDisabledClubChat', (c) => c.json(false)) .get(
'/club/:clubId{[0-9]+}/hasDisabledClubChat',
describeRoute({
tags: ['Clubs'],
summary: 'Whether the club has turned club chat off',
description: [
'Nothing can disable club chat yet (no setting, no storage), so chat is always on →',
'`false`. A bare JSON boolean, like the other `is…`/`has…` gates the client polls; not',
'in the reference, so if the client chokes on this it likely wants the',
'`{ error, success, value }` envelope the other club endpoints use.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: { 200: json(ChatDisabledResponse, 'Always false') },
}),
(c) => c.json(false)
)
// A club's members. `membershipType` filters to exactly that tier (an exact match, // A club's members. `membershipType` filters to exactly that tier (an exact match,
// not a threshold — `30` lists co-owners only, not the creator above them), and // not a threshold — `30` lists co-owners only, not the creator above them), and
// `sortBy` picks the order (1 = account id, 2 = oldest first, default = highest // `sortBy` picks the order (1 = account id, 2 = oldest first, default = highest
// tier first). Public, and an unknown club is an empty list. Answers the envelope. // tier first). Public, and an unknown club is an empty list. Answers the envelope.
.get('/club/:clubId{[0-9]+}/members', async (c) => { .get(
'/club/:clubId{[0-9]+}/members',
describeRoute({
tags: ['Membership'],
summary: 'A clubs members',
description:
'Public, and an unknown club is an empty list rather than a 404. Answers the envelope.',
parameters: [
CLUB_ID_PARAM,
{
name: 'membershipType',
in: 'query',
required: false,
description: [
'Filter to exactly that tier — an exact match, not a threshold, so `30` lists',
'co-owners only, not the creator above them',
].join(' '),
schema: { type: 'string' },
},
{
name: 'sortBy',
in: 'query',
required: false,
description: '1 = account id, 2 = oldest first, anything else = highest tier first',
schema: { type: 'string' },
},
],
responses: { 200: json(ClubMembersEnvelope, 'The clubs membership rows') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10) const clubId = Number.parseInt(c.req.param('clubId'), 10)
const raw = c.req.query('membershipType') const raw = c.req.query('membershipType')
const membershipType = raw === undefined ? Number.NaN : Number.parseInt(raw, 10) const membershipType = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
@@ -470,12 +883,34 @@ const app = new Hono<App>()
c.req.query('sortBy') c.req.query('sortBy')
) )
return c.json({ error: '', success: true, value: members }) return c.json({ error: '', success: true, value: members })
}) }
)
// Set the minimum player level required to join the club. The reference has no such // Set the minimum player level required to join the club. The reference has no such
// route (it only takes `minLevel` on modifydetails), but the client PUTs it here. // route (it only takes `minLevel` on modifydetails), but the client PUTs it here.
// Same rules as the other club edits: co-owner or above, and the details envelope. // Same rules as the other club edits: co-owner or above, and the details envelope.
.put('/club/:clubId{[0-9]+}/minlevel', async (c) => { .put(
'/club/:clubId{[0-9]+}/minlevel',
describeRoute({
tags: ['Clubs'],
summary: 'Set the clubs minimum join level',
description: [
'The reference has no such route (it only takes `minLevel` on modifydetails), but the',
'client PUTs it here. Same rules as the other club edits: co-owner or above, and the',
'details envelope back.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(MinLevelRequest, 'The new minimum level'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Missing, non-numeric or negative minLevel'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -503,7 +938,8 @@ const app = new Hono<App>()
success: true, success: true,
value: await getClubDetails(c.env.DB, updated, id), value: await getClubDetails(c.env.DB, updated, id),
}) })
}) }
)
// Set (or clear) the club's clubhouse room — the room players spawn into when the // Set (or clear) the club's clubhouse room — the room players spawn into when the
// club is their home. `roomId` sets it; omitting it clears the clubhouse. Co-owner // club is their home. `roomId` sets it; omitting it clears the clubhouse. Co-owner
@@ -514,7 +950,33 @@ const app = new Hono<App>()
// DELETE is the same thing with the clearing spelled out — it ignores any body and // DELETE is the same thing with the clearing spelled out — it ignores any body and
// always unsets the room, so "remove the clubhouse" doesn't depend on the client // always unsets the room, so "remove the clubhouse" doesn't depend on the client
// remembering to send an empty PUT. // remembering to send an empty PUT.
.on(['PUT', 'DELETE'], '/club/:clubId{[0-9]+}/clubhouse', async (c) => { .on(
['PUT', 'DELETE'],
'/club/:clubId{[0-9]+}/clubhouse',
describeRoute({
tags: ['Clubs'],
summary: 'Set or clear the clubs clubhouse room',
description: [
'The clubhouse is the room players spawn into when the club is their home. PUT with',
'`roomId` sets it; omitting `roomId` clears it. DELETE is the same thing with the',
'clearing spelled out — it ignores any body and always unsets the room, so “remove the',
'clubhouse” doesnt depend on the client remembering to send an empty PUT. Co-owner or',
'above only. Answers the full details envelope: the reference returns a null value',
'here, but the client re-renders from the response and leaves the old clubhouse on',
'screen unless it gets the updated club back.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ClubhouseRequest, 'The clubhouse room (PUT only; DELETE ignores the body)'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Non-numeric roomId'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -545,14 +1007,31 @@ const app = new Hono<App>()
success: true, success: true,
value: await getClubDetails(c.env.DB, updated, id), value: await getClubDetails(c.env.DB, updated, id),
}) })
}) }
)
// The club's main image. PUT sets it from an uploaded image's `imageName` (the // The club's main image. PUT sets it from an uploaded image's `imageName` (the
// name the `storage` worker handed back); co-owner or above only. GET reads it — // name the `storage` worker handed back); co-owner or above only. GET reads it —
// the reference has no GET here (it 404s), but the client asks for it, so this // the reference has no GET here (it 404s), but the client asks for it, so this
// answers the same details envelope rather than erroring; the image name is on // answers the same details envelope rather than erroring; the image name is on
// `value.Club.MainImageName`. // `value.Club.MainImageName`.
.get('/club/:clubId{[0-9]+}/mainimage', async (c) => { .get(
'/club/:clubId{[0-9]+}/mainimage',
describeRoute({
tags: ['Images'],
summary: 'Read the clubs main image',
description: [
'The reference has no GET here (it 404s), but the client asks for it, so this answers',
'the same details envelope rather than erroring; the image name is on',
'`value.Club.MainImageName`. Public.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, carrying MainImageName'),
404: { description: 'No such club' },
},
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10) const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClub(c.env.DB, clubId) const club = await getClub(c.env.DB, clubId)
if (club === null) return c.notFound() if (club === null) return c.notFound()
@@ -562,8 +1041,29 @@ const app = new Hono<App>()
success: true, success: true,
value: await getClubDetails(c.env.DB, club, id), value: await getClubDetails(c.env.DB, club, id),
}) })
}) }
.put('/club/:clubId{[0-9]+}/mainimage', async (c) => { )
.put(
'/club/:clubId{[0-9]+}/mainimage',
describeRoute({
tags: ['Images'],
summary: 'Set the clubs main image',
description: [
'Sets the main image from an uploaded images `imageName` (the name the `storage`',
'worker handed back). Co-owner or above only. Answers the details envelope.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ImageNameRequest, 'The uploaded images name'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Missing imageName'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -578,7 +1078,8 @@ const app = new Hono<App>()
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown> const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename') const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename')
const imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : '' const imageName =
typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : ''
if (imageName === '') return clubError(c, 'imageName is required.') if (imageName === '') return clubError(c, 'imageName is required.')
const updated = await updateClub(c.env.DB, clubId, { mainImageName: imageName }) const updated = await updateClub(c.env.DB, clubId, { mainImageName: imageName })
@@ -588,7 +1089,8 @@ const app = new Hono<App>()
success: true, success: true,
value: await getClubDetails(c.env.DB, updated, id), value: await getClubDetails(c.env.DB, updated, id),
}) })
}) }
)
// One of the club's gallery images, by position (`/additionalimage/{index}`, 0-based // One of the club's gallery images, by position (`/additionalimage/{index}`, 0-based
// — the client PUTs the first image to 0, the second to 1). Takes the same // — the client PUTs the first image to 0, the second to 1). Takes the same
@@ -599,7 +1101,43 @@ const app = new Hono<App>()
// DELETE removes that position's image and shifts the rest up, so there's never a // DELETE removes that position's image and shifts the rest up, so there's never a
// blank slot in the gallery. It ignores any body, so it can't accidentally set an // blank slot in the gallery. It ignores any body, so it can't accidentally set an
// image instead, and deleting a position that holds nothing is a no-op. // image instead, and deleting a position that holds nothing is a no-op.
.on(['PUT', 'DELETE'], '/club/:clubId{[0-9]+}/additionalimage/:index{[0-9]+}', async (c) => { .on(
['PUT', 'DELETE'],
'/club/:clubId{[0-9]+}/additionalimage/:index{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'Set or remove one of the clubs gallery images',
description: [
'One gallery image by position (0-based — the client PUTs the first image to 0, the',
'second to 1), taking the same `imageName` the `storage` worker handed back. Co-owner',
'or above, like the main image. The list is PACKED, never sparse: a PUT past the end',
'appends rather than leaving a gap, and DELETE removes that position and shifts the',
'rest up, so theres never a blank slot. DELETE ignores any body (so it cant',
'accidentally set an image instead) and deleting an empty position is a no-op. The',
'images come back on `value.AdditionalImages` as whole image records, in order — a',
'bare array of names fails the clients parser.',
].join(' '),
security: AUTHED,
parameters: [
CLUB_ID_PARAM,
{
name: 'index',
in: 'path',
required: true,
description: 'The 0-based gallery slot; a club has 3 slots (02)',
schema: { type: 'string' },
},
],
requestBody: form(ImageNameRequest, 'The uploaded images name (PUT only)'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'The index is past the clubs gallery slots'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -631,20 +1169,55 @@ const app = new Hono<App>()
success: true, success: true,
value: await getClubDetails(c.env.DB, updated, id), value: await getClubDetails(c.env.DB, updated, id),
}) })
}) }
)
// A single club by id. 404 when the club isn't in the DB. Public. // A single club by id. 404 when the club isn't in the DB. Public.
.get('/club/:clubId{[0-9]+}', async (c) => { .get(
'/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Clubs'],
summary: 'A single club by id',
description: 'The bare club (not the details view, not enveloped). Public.',
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDto, 'The club'),
404: { description: 'No such club' },
},
}),
async (c) => {
const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10)) const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10))
return club ? c.json(club) : c.notFound() return club ? c.json(club) : c.notFound()
}) }
)
// Delete a club, along with its memberships and announcements. The creator only — // Delete a club, along with its memberships and announcements. The creator only —
// not co-owners, who can edit a club but can't destroy one — which is also the way // not co-owners, who can edit a club but can't destroy one — which is also the way
// out for a creator, since they aren't allowed to leave (see /members/leave). // out for a creator, since they aren't allowed to leave (see /members/leave).
// Answers the envelope with a null value; the club is gone, so there are no details // Answers the envelope with a null value; the club is gone, so there are no details
// left to return. // left to return.
.delete('/club/:clubId{[0-9]+}', async (c) => { .delete(
'/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Clubs'],
summary: 'Delete a club',
description: [
'Deletes the club along with its memberships and announcements, and clears it from the',
'home club of anyone whod set it. The creator only — not co-owners, who can edit a',
'club but cant destroy one — which is also the way out for a creator, since they',
'arent allowed to leave. The envelopes `value` is null: the club is gone, so there',
'are no details left to return.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(NullEnvelope, 'Deleted (value null)'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Not the clubs creator'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -659,7 +1232,8 @@ const app = new Hono<App>()
await deleteClub(c.env.DB, clubId) await deleteClub(c.env.DB, clubId)
return c.json({ error: '', success: true, value: null }) return c.json({ error: '', success: true, value: null })
}) }
)
// Ask to join a club. No body — the club id and the Bearer token are the whole // Ask to join a club. No body — the club id and the Bearer token are the whole
// request. What it does depends on the club's Joinability: an Open club takes the // request. What it does depends on the club's Joinability: an Open club takes the
@@ -667,7 +1241,30 @@ const app = new Hono<App>()
// for a co-owner to approve, and an InviteOnly club refuses (you can only get in // for a co-owner to approve, and an InviteOnly club refuses (you can only get in
// through an invite). Repeats are idempotent; a banned account stays out. Answers // through an invite). Repeats are idempotent; a banned account stays out. Answers
// the details envelope so the client can read its new `MyMembershipType`. // the details envelope so the client can read its new `MyMembershipType`.
.put('/club/:clubId{[0-9]+}/members/requesttojoin', async (c) => { .put(
'/club/:clubId{[0-9]+}/members/requesttojoin',
describeRoute({
tags: ['Membership'],
summary: 'Ask to join a club',
description: [
'No body — the club id and the Bearer token are the whole request. What it does',
'depends on the clubs Joinability: an Open club takes the caller straight in as a',
'Member, an AskToJoin club records a PendingRequested row for a co-owner to approve,',
'and an InviteOnly club refuses (you can only get in through an invite). Repeats are',
'idempotent; a banned account stays out. Answers the details envelope so the client',
'can read its new `MyMembershipType`.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, with the callers new membership'),
400: json(ErrorEnvelope, 'The club is invite only'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The caller is banned from the club'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -687,7 +1284,8 @@ const app = new Hono<App>()
success: true, success: true,
value: await getClubDetails(c.env.DB, outcome.club, id), value: await getClubDetails(c.env.DB, outcome.club, id),
}) })
}) }
)
// Leave a club. No body, like requesttojoin — the club id and the Bearer token are // Leave a club. No body, like requesttojoin — the club id and the Bearer token are
// the whole request. Idempotent (leaving a club you're not in is a no-op), and it // the whole request. Idempotent (leaving a club you're not in is a no-op), and it
@@ -695,7 +1293,28 @@ const app = new Hono<App>()
// by leaving. The creator is refused — they'd leave the club ownerless, so they // by leaving. The creator is refused — they'd leave the club ownerless, so they
// have to delete it instead. Answers the details envelope so the client sees // have to delete it instead. Answers the details envelope so the client sees
// `MyMembershipType` drop to 0 (or stay at -1 for a banned account). // `MyMembershipType` drop to 0 (or stay at -1 for a banned account).
.post('/club/:clubId{[0-9]+}/members/leave', async (c) => { .post(
'/club/:clubId{[0-9]+}/members/leave',
describeRoute({
tags: ['Membership'],
summary: 'Leave a club',
description: [
'No body, like requesttojoin. Idempotent (leaving a club youre not in is a no-op),',
'and it also withdraws a pending request; a ban is preserved, since you cant clear',
'one by leaving. The creator is refused — theyd leave the club ownerless, so they',
'have to delete it instead. Answers the details envelope so the client sees',
'`MyMembershipType` drop to 0 (or stay at -1 for a banned account).',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, with the callers membership gone'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The creator cant leave — delete the club instead'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -718,19 +1337,59 @@ const app = new Hono<App>()
success: true, success: true,
value: await getClubDetails(c.env.DB, outcome.club, id), value: await getClubDetails(c.env.DB, outcome.club, id),
}) })
}) }
)
// Join / leave a club (auth-gated, idempotent). Both return the club with its // Join / leave a club (auth-gated, idempotent). Both return the club with its
// refreshed MemberCount; 404 when the club doesn't exist. // refreshed MemberCount; 404 when the club doesn't exist.
.post('/club/:clubId{[0-9]+}/join', async (c) => { .post(
'/club/:clubId{[0-9]+}/join',
describeRoute({
tags: ['Membership'],
summary: 'Join a club',
description: [
'Auth-gated and idempotent. On an Open club the caller becomes a Member immediately;',
'on an InviteOnly/AskToJoin club the join is recorded as PendingRequested, and a ban',
'cant be shed by re-joining. Returns the bare club with its refreshed MemberCount',
'(not the details envelope — see members/requesttojoin for that).',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id) const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
return club ? c.json(club) : c.notFound() return club ? c.json(club) : c.notFound()
}) }
)
// Leaving is refused for the creator here too (see /members/leave), so the two // Leaving is refused for the creator here too (see /members/leave), so the two
// routes can't disagree about who's still in the club. // routes can't disagree about who's still in the club.
.post('/club/:clubId{[0-9]+}/leave', async (c) => { .post(
'/club/:clubId{[0-9]+}/leave',
describeRoute({
tags: ['Membership'],
summary: 'Leave a club (bare-club form)',
description: [
'The counterpart to `/join`: returns the bare club with its refreshed MemberCount',
'rather than the details envelope. Leaving is refused for the creator here too (see',
'`/members/leave`), so the two routes cant disagree about whos still in the club.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The creator cant leave — delete the club instead'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
const outcome = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id) const outcome = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
@@ -746,6 +1405,47 @@ const app = new Hono<App>()
) )
} }
return c.json(outcome.club) return c.json(outcome.club)
}
)
// 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 clubs',
version: '1.0.0',
description: [
'Club endpoints for recflare, a private-server reimplementation of the Rec Room',
'backend. The client calls these on the `clubs` host: club creation and editing,',
'membership (join / ask-to-join / leave, with the ban and pending tiers),',
'search, announcements, the club gallery and clubhouse room, and each players home',
'club. Everything is D1-backed on the shared `recflare` database; the',
'`/subscription/*` routes are stubs, since there are no subscription clubs yet.',
'',
'Most writes answer the `{ error, success, value }` envelope with HTTP 200, and the',
'ones the client re-renders a club screen from carry the clubs FULL details as',
'`value` rather than null.',
].join('\n'),
},
servers: [{ url: 'https://clubs.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 export default app
+338
View File
@@ -0,0 +1,338 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the clubs 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 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) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(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 opaque JSON array — the empty-list stubs (`[]`) the client still expects. */
export const JsonArray = z.array(z.unknown())
/** An empty JSON object — the stub shape the client deserializes into an object. */
export const EmptyObject = z.object({})
// ---- Core entities ---------------------------------------------------------
/**
* The client-facing club DTO (mirror of the Go `Club` JSON tags). The stored blob also
* carries `CreatedAt`, `CustomTags` and `AdditionalImages`, none of which are on this
* object — the tags and gallery are served on the details view instead.
*/
export const ClubDto = z.object({
ClubId: z.int(),
Name: z.string().describe('At most 16 characters; letters, digits and basic punctuation'),
Description: z.string(),
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
Joinability: z.int().describe('ClubJoinability: 0 = Open, 1 = InviteOnly, 2 = AskToJoin'),
AllowJuniors: z.boolean(),
MainImageName: z.string().describe('An image name from the storage worker; DefaultImgPurple'),
ClubType: z.int().describe('0 = a regular club; 1 = a subscription club (never listed)'),
ClubhouseRoomId: z.int().nullable().describe('The room a home-club member spawns into'),
CreatorAccountId: z.int(),
IsRRO: z.boolean(),
MinLevel: z.int(),
State: z.int(),
MemberCount: z.int().describe('Derived from the club_member rows at/above Member (10)'),
})
/**
* An image record as every image on the site is served (`SavedImage`). A club's gallery
* serves these whole — see AdditionalImages on the details view.
*/
export const SavedImageDto = z.object({
Id: z.int(),
Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
Description: z.string().nullable(),
PlayerId: z.int(),
TaggedPlayerIds: z.array(z.int()),
RoomId: z.int().nullable(),
PlayerEventId: z.int().nullable(),
CreatedAt: z.string(),
CheerCount: z.int(),
CommentCount: z.int(),
})
/**
* What a membership tier may do in a club. These are the defaults every club gets
* (co-owners everything, moderators approve/ban, members none); nothing edits them yet,
* so they're derived per club rather than stored.
*/
export const ClubPermissionDto = z.object({
ClubId: z.int(),
Type: z.int().describe('The ClubMembershipType tier these permissions describe'),
ApproveMember: z.boolean(),
BanUnban: z.boolean(),
CreateEvent: z.boolean(),
EditDetails: z.boolean(),
EditPermissionSettings: z.boolean(),
PostAnnouncement: z.boolean(),
})
/**
* The club-details payload the client renders a club screen from: the club, its tags,
* the per-tier permissions, its gallery, and the caller's own membership.
*/
export const ClubDetailsDto = z.object({
AdditionalImages: z
.array(SavedImageDto)
.describe(
[
'The clubs gallery as WHOLE image records, not image names — the client',
'deserializes each entry into an object, so a bare array of names fails its parser',
'("expected \'{\'"). The list is packed and in order: removing an image shifts the',
'rest up, never leaving a blank slot.',
].join(' ')
),
Club: ClubDto,
ClubId: z.int(),
CoownerPermissions: ClubPermissionDto,
CustomTags: z.array(z.string()).describe('Set wholesale by modifydetails repeated customTags'),
MemberPermissions: ClubPermissionDto,
ModeratorPermissions: ClubPermissionDto,
MyMembershipType: z
.int()
.describe(
[
'The callers own ClubMembershipType: -1 banned, 0 none (also a signed-out viewer),',
'1 pending request, 2 pending invite, 3 denied, 10 member, 20 moderator, 30 co-owner,',
'100 creator',
].join(' ')
),
})
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
export const ClubMemberDto = z.object({
ClubMemberId: z.int(),
ClubId: z.int(),
AccountId: z.int(),
MembershipType: z.int().describe('See MyMembershipType for the tiers'),
CreatedAt: z.string().nullable().describe('When the membership row was first written'),
})
/** One entry on a club's noticeboard (mirror of the Go `ClubAnnouncement`). */
export const ClubAnnouncementDto = z.object({
AnnouncementId: z.int(),
ClubId: z.int(),
AccountId: z.int().describe('Who posted it'),
Title: z.string(),
Body: z.string(),
ImageName: z.string(),
Meta: z.string(),
CreatedAt: z.string().nullable(),
})
// ---- Envelopes -------------------------------------------------------------
//
// Most club writes answer the `{ error, success, value }` envelope with HTTP 200 (or
// 400/403 carrying the same shape with `success: false`). The envelope's `value` is the
// entity the client re-renders from, so routes that change a club return the FULL
// details view rather than a null value — `PUT /club/:id/clubhouse` left the old
// clubhouse on screen until it answered the details envelope.
/** The success envelope carrying a club's full details. */
export const ClubDetailsEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: ClubDetailsDto,
})
/** The success envelope carrying a bare club (`PUT /club/home/me`). */
export const ClubEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: ClubDto,
})
/**
* The envelope with nothing left to describe — clearing the home club, deleting a club.
* Only used where the entity is genuinely gone; anything the client re-renders from
* returns the details envelope instead.
*/
export const NullEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.null(),
})
/** A rejected action: the same envelope, carrying the message the client shows. */
export const ErrorEnvelope = z.object({
error: z.string().describe('The message shown to the player'),
success: z.boolean().describe('Always false'),
value: z.null(),
})
/** The envelope carrying a club's members (`GET /club/:clubId/members`). */
export const ClubMembersEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.array(ClubMemberDto),
})
/** The envelope carrying a club's noticeboard (`GET /announcements/club/:clubId`). */
export const ClubAnnouncementsEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.object({
Announcements: z.array(ClubAnnouncementDto).describe('Newest first'),
ClubId: z.int(),
LastAnnouncementId: z.int().nullable().describe('The newest one; null when there are none'),
LastReadAnnouncementId: z.int().describe('Always 0 — nothing tracks read state yet'),
}),
})
/** The envelope carrying a new announcement's id (`POST /announcements/club/:clubId`). */
export const AnnouncementIdEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.int().describe('The new announcements id'),
})
// ---- Other response shapes -------------------------------------------------
/** `GET /club/search` — a page of clubs plus the full match count. */
export const ClubSearchResponse = z.object({
Clubs: z.array(ClubDto),
ContinuationToken: z.null().describe('Always null — the whole page is served at once'),
TotalClubs: z.int().describe('How many clubs matched, not the page size'),
})
/** `GET /subscription/details/:accountId` — simulated: no club, no subscribers. */
export const SubscriptionDetailsResponse = z.object({
accountId: z.int(),
clubId: z.int().describe('Always 0 — no subscription clubs yet'),
subscriberCount: z.int().describe('Always 0'),
})
/** The set of category tags a club can be filed under — a fixed list. */
export const CategoryTags = z.array(z.string())
/** `GET /subscription/subscriberCount/:accountId` — a bare JSON integer. */
export const SubscriberCountResponse = z
.int()
.describe('Always 0 — there are no club subscriptions yet')
/**
* `GET /club/:clubId/hasDisabledClubChat` — a bare JSON boolean, like the other
* `is…`/`has…` gates the client polls. Nothing can turn club chat off yet, so it's
* always false; not in the reference, so if the client chokes on this it likely wants
* the `{ error, success, value }` envelope the other club endpoints use.
*/
export const ChatDisabledResponse = z.boolean()
// ---- Request schemas -------------------------------------------------------
//
// Every write takes a form body (urlencoded or multipart — the client posts both) with
// lowercase field names; the handlers match field names case-insensitively.
/** `POST /club/create` form body. */
export const CreateClubRequest = z.object({
name: z
.string()
.describe('Required; at most 16 characters, letters/digits/basic punctuation only'),
description: z.string().optional(),
category: z.string().optional().describe('Defaults to Social when unset'),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
.string()
.optional()
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
mainImageName: z.string().optional(),
minLevel: z.string().optional(),
})
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
export const ModifyClubRequest = z.object({
name: z.string().optional().describe('Empty means unchanged, not "clear it"'),
description: z.string().optional().describe('Empty means unchanged'),
category: z.string().optional(),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
.string()
.optional()
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
mainImageName: z.string().optional(),
minLevel: z.string().optional(),
customTags: z
.array(z.string())
.optional()
.describe('May repeat; when present it replaces the clubs tag set wholesale'),
})
/** `PUT /club/home/me` form body. */
export const HomeClubRequest = z.object({
clubId: z.string().describe('The club to make home; the caller must be a member of it'),
})
/** `PUT /club/:clubId/minlevel` form body. */
export const MinLevelRequest = z.object({
minLevel: z.string().describe('The minimum player level to join; negative/NaN is 400'),
})
/** `PUT /club/:clubId/clubhouse` form body. */
export const ClubhouseRequest = z.object({
roomId: z.string().optional().describe('The clubhouse room; omitting it clears the clubhouse'),
})
/** `PUT /club/:clubId/mainimage` and `/additionalimage/:index` form body. */
export const ImageNameRequest = z.object({
imageName: z.string().describe('The image name the `storage` worker handed back'),
})
/** `POST /announcements/club/:clubId` form body. */
export const AnnouncementRequest = z.object({
title: z.string().optional(),
body: z.string().optional(),
imageName: z.string().optional(),
meta: z.string().optional(),
})
@@ -1306,4 +1306,73 @@ describe('clubs endpoints', () => {
// Deleting twice 404s rather than reporting success. // Deleting twice 404s rather than reporting success.
expect((await del(clubId, '860')).status).toBe(404) expect((await del(clubId, '860')).status).toBe(404)
}) })
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.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.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every schema inlines: a `.meta({ id })` on any of them would emit a $ref this
// setup doesn't always hoist into components.schemas, leaving it dangling.
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax (regex constraints and all) becomes
// OpenAPI's `{param}`; the `.on([...])` clubhouse and additionalimage routes
// contribute both their methods, and modifydetails/modify both their paths.
const documented = new Set(
Object.entries(spec.paths).flatMap(([path, ops]) =>
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
)
)
expect([...documented].sort()).toEqual([
'DELETE /club/home/me',
'DELETE /club/{clubId}',
'DELETE /club/{clubId}/additionalimage/{index}',
'DELETE /club/{clubId}/clubhouse',
'GET /announcements/club/{clubId}',
'GET /announcements/v2/mine/unread',
'GET /club/categoryTags',
'GET /club/home/me',
'GET /club/mine/created',
'GET /club/mine/member',
'GET /club/search',
'GET /club/{clubId}',
'GET /club/{clubId}/details',
'GET /club/{clubId}/hasDisabledClubChat',
'GET /club/{clubId}/mainimage',
'GET /club/{clubId}/members',
'GET /subscription/details/{accountId}',
'GET /subscription/details/{subscription}',
'GET /subscription/mine/member',
'GET /subscription/subscriberCount/{accountId}',
'POST /announcements/club/{clubId}',
'POST /club/create',
'POST /club/{clubId}/join',
'POST /club/{clubId}/leave',
'POST /club/{clubId}/members/leave',
'PUT /club/home/me',
'PUT /club/{clubId}/additionalimage/{index}',
'PUT /club/{clubId}/clubhouse',
'PUT /club/{clubId}/mainimage',
'PUT /club/{clubId}/members/requesttojoin',
'PUT /club/{clubId}/minlevel',
'PUT /club/{clubId}/modify',
'PUT /club/{clubId}/modifydetails',
])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
})
}) })
+53 -46
View File
@@ -382,9 +382,10 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Avatar'], tags: ['Avatar'],
summary: 'The players avatar items', summary: 'The players avatar items',
description: description: [
'The items the player has bought (from buyItem, in the inventory table) prepended ' + 'The items the player has bought (from buyItem, in the inventory table) prepended',
'to the default catalog. A player who has bought nothing gets just the catalog.', 'to the default catalog. A player who has bought nothing gets just the catalog.',
].join(' '),
security: AUTHED, security: AUTHED,
responses: { responses: {
200: json(JsonArray, 'Owned items followed by the default catalog'), 200: json(JsonArray, 'Owned items followed by the default catalog'),
@@ -407,10 +408,11 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Avatar'], tags: ['Avatar'],
summary: 'Owned custom avatar items', summary: 'Owned custom avatar items',
description: description: [
'Paginated owned custom items. Empty stub for now. The client requests this when ' + 'Paginated owned custom items. Empty stub for now. The client requests this when',
'custom-item creation is allowed; a 404 shows as “Failed to download unlocked ' + 'custom-item creation is allowed; a 404 shows as “Failed to download unlocked',
'avatar items”.', 'avatar items”.',
].join(' '),
security: AUTHED, security: AUTHED,
responses: { responses: {
200: json(CustomAvatarItemsResponse, 'Paginated results (empty for now)'), 200: json(CustomAvatarItemsResponse, 'Paginated results (empty for now)'),
@@ -462,9 +464,10 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Avatar'], tags: ['Avatar'],
summary: 'The players own avatar', summary: 'The players own avatar',
description: description: [
'The avatar JSON blob stored on the account row, or the default outfit when none is ' + 'The avatar JSON blob stored on the account row, or the default outfit when none is',
'saved (the client NREs on an empty OutfitSelections).', 'saved (the client NREs on an empty OutfitSelections).',
].join(' '),
security: AUTHED, security: AUTHED,
responses: { responses: {
200: json(JsonObject, 'The stored avatar blob (or the default)'), 200: json(JsonObject, 'The stored avatar blob (or the default)'),
@@ -565,11 +568,12 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Avatar'], tags: ['Avatar'],
summary: 'Save an outfit into a slot', summary: 'Save an outfit into a slot',
description: description: [
'Writes the posted outfit into the given `Slot` (overwriting it) and echoes it back. ' + 'Writes the posted outfit into the given `Slot` (overwriting it) and echoes it back.',
'The payload is stored verbatim — its inner fields are JSON-in-a-string from the ' + 'The payload is stored verbatim — its inner fields are JSON-in-a-string from the',
'clients own serializer. A missing/non-integer `Slot` is a 400 (guessing would ' + 'clients own serializer. A missing/non-integer `Slot` is a 400 (guessing would',
'silently overwrite another outfit).', 'silently overwrite another outfit).',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'), requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'),
responses: { responses: {
@@ -601,9 +605,10 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Gifts'], tags: ['Gifts'],
summary: 'Pending gift boxes', summary: 'Pending gift boxes',
description: description: [
'The players unopened gift boxes from their purchases (and, later, from other ' + 'The players unopened gift boxes from their purchases (and, later, from other',
'players). The item was already granted at purchase, so an unopened box is cosmetic.', 'players). The item was already granted at purchase, so an unopened box is cosmetic.',
].join(' '),
security: AUTHED, security: AUTHED,
responses: { responses: {
200: json(JsonArray, 'Unopened gift boxes (empty when none)'), 200: json(JsonArray, 'Unopened gift boxes (empty when none)'),
@@ -635,12 +640,13 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Gifts'], tags: ['Gifts'],
summary: 'Open (consume) a gift box', summary: 'Open (consume) a gift box',
description: description: [
'Deletes the box (the item was already granted at purchase). Always answers the ' + 'Deletes the box (the item was already granted at purchase). Always answers the',
'`{ error, success, value }` envelope with HTTP 200 — even with no token, a zero id, ' + '`{ error, success, value }` envelope with HTTP 200 — even with no token, a zero id,',
'or a box already gone — because the client parses it to finish opening the box. The ' + 'or a box already gone — because the client parses it to finish opening the box. The',
'delete is scoped to the caller; opening someone elses box is 403. Also served by ' + 'delete is scoped to the caller; opening someone elses box is 403. Also served by',
'the `api` worker.', 'the `api` worker.',
].join(' '),
requestBody: form(ConsumeGiftRequest, 'The gift-box id'), requestBody: form(ConsumeGiftRequest, 'The gift-box id'),
responses: { responses: {
200: json(ConsumeEnvelope, 'Success envelope'), 200: json(ConsumeEnvelope, 'Success envelope'),
@@ -680,9 +686,10 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Avatar'], tags: ['Avatar'],
summary: 'Another players avatar (render subset)', summary: 'Another players avatar (render subset)',
description: description: [
'The public render subset used to draw another players avatar. No auth. Falls back ' + 'The public render subset used to draw another players avatar. No auth. Falls back',
'to the default outfit when the player hasnt saved one.', 'to the default outfit when the player hasnt saved one.',
].join(' '),
parameters: [ parameters: [
{ {
name: 'id', name: 'id',
@@ -726,10 +733,11 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Equipment'], tags: ['Equipment'],
summary: 'Update owned equipment', summary: 'Update owned equipment',
description: description: [
'Applies the posted `Favorited` flags to the callers owned equipment, matched by ' + 'Applies the posted `Favorited` flags to the callers owned equipment, matched by',
'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller ' + '`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller',
'doesnt own is silently skipped. Empty body on success.', 'doesnt own is silently skipped. Empty body on success.',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'), requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'),
responses: { responses: {
@@ -782,10 +790,11 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Consumables'], tags: ['Consumables'],
summary: 'Unlocked consumables', summary: 'Unlocked consumables',
description: description: [
'The consumables the player has bought (from buyItem, in the consumable table), ' + 'The consumables the player has bought (from buyItem, in the consumable table),',
'grouped by item into the unlocked-consumable DTO (Ids/CreatedAts per instance, ' + 'grouped by item into the unlocked-consumable DTO (Ids/CreatedAts per instance,',
'Count their sum). [] when theyve bought none.', 'Count their sum). [] when theyve bought none.',
].join(' '),
security: AUTHED, security: AUTHED,
responses: { responses: {
200: json(JsonArray, 'Grouped unlocked consumables (empty when none)'), 200: json(JsonArray, 'Grouped unlocked consumables (empty when none)'),
@@ -808,10 +817,11 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Consumables'], tags: ['Consumables'],
summary: 'Consume a quantity of an owned consumable', summary: 'Consume a quantity of an owned consumable',
description: description: [
'Reduces the given consumable instances count by `DeltaCount` (default 1), deleting ' + 'Reduces the given consumable instances count by `DeltaCount` (default 1), deleting',
'the row at zero. Scoped to the caller. Pushes a ConsumableMappingRemoved socket ' + 'the row at zero. Scoped to the caller. Pushes a ConsumableMappingRemoved socket',
'notification. Envelope mirrors the gift-consume ack.', 'notification. Envelope mirrors the gift-consume ack.',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: jsonBody(ConsumeConsumableRequest, 'The consumable id and delta'), requestBody: jsonBody(ConsumeConsumableRequest, 'The consumable id and delta'),
responses: { responses: {
@@ -848,10 +858,11 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Storefront'], tags: ['Storefront'],
summary: 'Currency balance', summary: 'Currency balance',
description: description: [
'The players balance in a CurrencyType (the client fetches `/balance/2`, ' + 'The players balance in a CurrencyType (the client fetches `/balance/2`,',
'RecCenterTokens, on load). A first read seeds their starting balance. An unknown or ' + 'RecCenterTokens, on load). A first read seeds their starting balance. An unknown or',
'non-account currency returns a 0 balance rather than 404.', 'non-account currency returns a 0 balance rather than 404.',
].join(' '),
security: AUTHED, security: AUTHED,
parameters: [ parameters: [
{ {
@@ -932,12 +943,13 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Storefront'], tags: ['Storefront'],
summary: 'Buy a storefront item', summary: 'Buy a storefront item',
description: description: [
'Looks the item up in its storefront catalog, confirms the clients `RequestedPrice` ' + 'Looks the item up in its storefront catalog, confirms the clients `RequestedPrice`',
'still matches, debits the buyer atomically, grants the item (into the inventory or ' + 'still matches, debits the buyer atomically, grants the item (into the inventory or',
'consumable table), and returns a gift box. A `Gift` block routes the item to another ' + 'consumable table), and returns a gift box. A `Gift` block routes the item to another',
'player, but the caller always pays. `Balance` in the response is the CHANGE (negated ' + 'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
'price), not the new total. Pushes a StorefrontBalanceUpdate socket notification.', 'price), not the new total. Pushes a StorefrontBalanceUpdate socket notification.',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'), requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
responses: { responses: {
@@ -1136,10 +1148,11 @@ const app = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Econ'], tags: ['Econ'],
summary: 'Report weekly-challenge progress', summary: 'Report weekly-challenge progress',
description: description: [
'Stubbed: with no challenge-progress store we persist nothing and never mark a ' + 'Stubbed: with no challenge-progress store we persist nothing and never mark a',
'challenge complete. Echoes the identifying fields back with `Complete: false` so the ' + 'challenge complete. Echoes the identifying fields back with `Complete: false` so the',
'client gets a well-formed body.', 'client gets a well-formed body.',
].join(' '),
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'), requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
responses: { 200: json(ChallengeProgressResponse, 'Echoed fields, Complete false') }, responses: { 200: json(ChallengeProgressResponse, 'Echoed fields, Complete false') },
}), }),
@@ -1211,12 +1224,6 @@ app.get(
'Rec Room backend. The client calls these on the `econ` host; many are also served by', 'Rec Room backend. The client calls these on the `econ` host; many are also served by',
'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,', 'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,',
'inventory, consumables, saved outfits and gift boxes are D1-backed.', 'inventory, consumables, saved outfits and gift boxes are D1-backed.',
'',
'The shapes here are **reverse-engineered from the game client**, which is the only',
'real consumer. They record observed behaviour, not a designed contract; the handlers',
'are lenient and parse bodies defensively. Nothing in this spec is enforced at',
'runtime — treat a field marked required as "the client always sends it", not "the',
'server rejects it if absent".',
].join('\n'), ].join('\n'),
}, },
servers: [{ url: 'https://econ.recflare.net', description: 'Production' }], servers: [{ url: 'https://econ.recflare.net', description: 'Production' }],
+6 -1
View File
@@ -18,8 +18,13 @@
"dependencies": { "dependencies": {
"@cf-wasm/photon": "^0.3.6", "@cf-wasm/photon": "^0.3.6",
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.12.27",
"workers-tagged-logger": "1.0.1" "hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20", "@cloudflare/vitest-pool-workers": "0.16.20",
+136 -4
View File
@@ -1,8 +1,11 @@
import { crop, PhotonImage, resize, SamplingFilter } from '@cf-wasm/photon' import { crop, PhotonImage, resize, SamplingFilter } from '@cf-wasm/photon'
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers' import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { imageBytes, json, ServiceStatus } from './openapi'
import type { App, Env } from './context' import type { App, Env } from './context'
@@ -197,7 +200,53 @@ const app = new Hono<App>()
.onError(withOnError()) .onError(withOnError())
.notFound(withNotFound()) .notFound(withNotFound())
.get('/', (c) => c.json({ service: 'img', status: 'ok' })) .get(
'/',
describeRoute({
tags: ['Images'],
summary: 'Service status',
description: 'Liveness probe. Always `{ service: "img", status: "ok" }`.',
responses: { 200: json(ServiceStatus, 'The worker is up') },
}),
(c) => c.json({ service: 'img', status: 'ok' })
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
//
// Registered BEFORE the `/:key{.+}` catch-all below: that route matches every path and
// always returns a Response (the DefaultProfileImage.jpg fallback when nothing is
// stored), so anything declared after it is unreachable. The spec is still complete —
// `openAPIRouteHandler` walks `app.routes` at request time, after the catch-all has
// been registered.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare img',
version: '1.0.0',
description: [
'Image hosting for recflare, a private-server reimplementation of the Rec Room',
'backend. Serves every image the client renders — profile photos, room thumbnails,',
'club banners and the photo feed — from an R2 bucket, with bundled static assets',
'(`static/`) taking precedence over the bucket and `DefaultProfileImage.jpg` served',
'as the fallback when a key is missing. Optional on-the-fly center-crop and resize',
'run through the Photon WASM codec; `?sig=p1` adds the RSA-SHA1 `Content-Signature`',
'header the client verifies against `KEY:RSA:p1.rec.net`.',
'',
'Note that this worker only serves bytes: the image metadata the client lists (the',
'`SavedImage` records behind `/api/images/...`) lives in the `api` worker, which',
'points at keys here.',
].join('\n'),
},
servers: [{ url: 'https://img.recflare.net', description: 'Production' }],
},
})
)
)
// Stream an image straight from the R2 bucket by key, e.g. // Stream an image straight from the R2 bucket by key, e.g.
// `GET /DefaultProfileImage.jpg`. The key may contain slashes for nested // `GET /DefaultProfileImage.jpg`. The key may contain slashes for nested
@@ -206,7 +255,89 @@ const app = new Hono<App>()
// When the client appends `?sig=p1`, the response body is RSA-SHA1 signed and // When the client appends `?sig=p1`, the response body is RSA-SHA1 signed and
// the signature returned in a `Content-Signature` header. Signing requires the // the signature returned in a `Content-Signature` header. Signing requires the
// full body, so the object is buffered. // full body, so the object is buffered.
.get('/:key{.+}', async (c) => { app.get(
'/:key{.+}',
describeRoute({
tags: ['Images'],
summary: 'Serve an image by key',
description: [
'Serves the image stored under `key`, which may contain slashes for nested objects',
'(e.g. `Base/Clearcut.jpg`). A bundled static asset always wins over an R2 object of',
'the same key; when neither exists the bundled `DefaultProfileImage.jpg` is served',
'with a 200 rather than a 404, so the client never renders a broken image.',
'',
'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded',
'image is never rewritten in place, a new image gets a new key.',
'',
'`?width`/`?height`/`?cropSquare=1` run the body through the Photon codec and always',
'return JPEG with no `ETag` (the source etag no longer describes the body), and the',
'`If-None-Match` precondition is skipped. An out-of-range or non-integer dimension is',
'ignored and the original is served — never an error.',
].join('\n'),
parameters: [
{
name: 'key',
in: 'path',
required: true,
description: 'Object key; may contain slashes. A key containing `..` is rejected (400).',
schema: { type: 'string' },
},
{
name: 'width',
in: 'query',
required: false,
description: [
'Output width. Only 128, 256, 512 or 1024 are honoured — any other value is',
'ignored and the source served untouched. Given alone, height follows the aspect ratio.',
].join(' '),
schema: { type: 'integer', enum: [128, 256, 512, 1024], example: 512 },
},
{
name: 'height',
in: 'query',
required: false,
description:
'Output height, same allowed set as `width`. Given alone, width follows the aspect ratio.',
schema: { type: 'integer', enum: [128, 256, 512, 1024], example: 512 },
},
{
name: 'cropSquare',
in: 'query',
required: false,
description: [
'`1` center-crops the source to a square before any resize. Used for the square',
'profile/thumbnail slots. Any other value is ignored.',
].join(' '),
schema: { type: 'string', enum: ['1'] },
},
{
name: 'sig',
in: 'query',
required: false,
description: [
'`p1` RSA-SHA1 signs the response body and returns it as',
'`Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>`. Signed over the',
'bytes actually returned, i.e. the resized body when a transform applies. Omitted',
'when the worker has no `IMG_SIGNING_KEY`.',
].join(' '),
schema: { type: 'string', enum: ['p1'] },
},
{
name: 'If-None-Match',
in: 'header',
required: false,
description:
'Conditional request against the R2 object etag. Ignored when a transform is requested.',
schema: { type: 'string' },
},
],
responses: {
200: imageBytes('The image bytes (or the DefaultProfileImage.jpg fallback)'),
304: { description: 'If-None-Match matched the stored object etag; no body' },
400: { description: 'The key contained `..`; no body' },
},
}),
async (c) => {
const key = c.req.param('key') const key = c.req.param('key')
if (key.includes('..')) return c.body(null, 400) if (key.includes('..')) return c.body(null, 400)
@@ -255,6 +386,7 @@ const app = new Hono<App>()
} }
return new Response(object.body, { headers }) return new Response(object.body, { headers })
}) }
)
export default app export default app
+50
View File
@@ -0,0 +1,50 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the img 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 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.
*
* Most of this worker's surface is image BYTES, not JSON, so those responses are
* described with a binary content type rather than a zod schema.
*/
/** 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) } } }
}
/**
* An image-bytes response. The stored object's own content type is served verbatim
* (usually `image/jpeg`, occasionally `image/png`); any response that went through the
* Photon resize/crop path is re-encoded and is always `image/jpeg`.
*/
export function imageBytes(description: string): OpenAPIV3_1.ResponseObject {
const schema: OpenAPIV3_1.SchemaObject = { type: 'string', format: 'binary' }
return {
description,
content: {
'image/jpeg': { schema },
'image/png': { schema },
},
}
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the liveness probe body. */
export const ServiceStatus = z.object({
service: z.literal('img'),
status: z.literal('ok'),
})
+32
View File
@@ -226,4 +226,36 @@ describe('img endpoints', () => {
const ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, signature, body) const ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, signature, body)
expect(ok).toBe(true) expect(ok).toBe(true)
}) })
it('GET /openapi.json documents every route', 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.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently shipping
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
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 /{key}'])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
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)
})
}) })
+53 -44
View File
@@ -424,9 +424,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Presence'], tags: ['Presence'],
summary: 'Login ack (no-op)', summary: 'Login ack (no-op)',
description: description: [
'A no-op ack. Must NOT touch presence — the client fires this going online, and ' + 'A no-op ack. Must NOT touch presence — the client fires this going online, and',
'clearing presence here would bounce the player to the dorm.', 'clearing presence here would bounce the player to the dorm.',
].join(' '),
responses: { 200: EMPTY_OK }, responses: { 200: EMPTY_OK },
}), }),
(c) => c.body(null, 200) (c) => c.body(null, 200)
@@ -456,11 +457,12 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Presence'], tags: ['Presence'],
summary: 'Clear presence on logout', summary: 'Clear presence on logout',
description: description: [
'Clears the players presence so they read offline immediately and the instance ' + 'Clears the players presence so they read offline immediately and the instance',
'they were in frees up. EXCEPTION: a logout whose presence still points at the ' + 'they were in frees up. EXCEPTION: a logout whose presence still points at the',
'Orientation seed (instance -2) is left as a no-op, so the account-creation ' + 'Orientation seed (instance -2) is left as a no-op, so the account-creation',
'bootstrap isnt wiped. An unauthenticated logout is also a no-op.', 'bootstrap isnt wiped. An unauthenticated logout is also a no-op.',
].join(' '),
responses: { 200: EMPTY_OK }, responses: { 200: EMPTY_OK },
}), }),
async (c) => { async (c) => {
@@ -487,9 +489,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Presence'], tags: ['Presence'],
summary: 'Disconnect notification (no-op ack)', summary: 'Disconnect notification (no-op ack)',
description: description: [
'Posted when the client drops a room. Not acted on — presence is cleared by logout ' + 'Posted when the client drops a room. Not acted on — presence is cleared by logout',
'and otherwise expires on its TTL.', 'and otherwise expires on its TTL.',
].join(' '),
responses: { 200: EMPTY_OK }, responses: { 200: EMPTY_OK },
}), }),
(c) => c.body(null, 200) (c) => c.body(null, 200)
@@ -500,9 +503,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Presence'], tags: ['Presence'],
summary: 'Batch player presence lookup', summary: 'Batch player presence lookup',
description: description: [
'Returns each requested players presence. `id` is repeatable and each value may ' + 'Returns each requested players presence. `id` is repeatable and each value may',
'be a comma-separated list. With no ids, serves a single default (online) player.', 'be a comma-separated list. With no ids, serves a single default (online) player.',
].join(' '),
parameters: [ parameters: [
{ {
name: 'id', name: 'id',
@@ -536,11 +540,12 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Presence'], tags: ['Presence'],
summary: 'Presence heartbeat', summary: 'Presence heartbeat',
description: description: [
'Merges the posted status fields into stored presence and echoes back the player ' + 'Merges the posted status fields into stored presence and echoes back the player',
'payload. Re-writes the row (refreshing its TTL) only when something changed or the ' + 'payload. Re-writes the row (refreshing its TTL) only when something changed or the',
'TTL is close to lapsing, so a still player isnt written on every beat. With no ' + 'TTL is close to lapsing, so a still player isnt written on every beat. With no',
'stored presence the player isnt in a room yet (roomInstance null, isOnline false).', 'stored presence the player isnt in a room yet (roomInstance null, isOnline false).',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: jsonBody( requestBody: jsonBody(
HeartbeatRequestSchema, HeartbeatRequestSchema,
@@ -618,9 +623,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Presence'], tags: ['Presence'],
summary: 'Set status visibility', summary: 'Set status visibility',
description: description: [
'Updates the stored presences status visibility. No-op when the player has no live ' + 'Updates the stored presences status visibility. No-op when the player has no live',
'presence or an unauthenticated/invalid token — always acks 200.', 'presence or an unauthenticated/invalid token — always acks 200.',
].join(' '),
requestBody: form(StatusVisibilityRequest, 'The statusVisibility value'), requestBody: form(StatusVisibilityRequest, 'The statusVisibility value'),
responses: { 200: EMPTY_OK }, responses: { 200: EMPTY_OK },
}), }),
@@ -650,10 +656,11 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Navigation'], tags: ['Navigation'],
summary: 'Go to a room', summary: 'Go to a room',
description: description: [
'Resolves the room (numeric id or name; `dormroom` → the players personal dorm), ' + 'Resolves the room (numeric id or name; `dormroom` → the players personal dorm),',
'finds or creates an instance, and stores it as presence. `JoinMode=2` requests a ' + 'finds or creates an instance, and stores it as presence. `JoinMode=2` requests a',
'private instance.', 'private instance.',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'), requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [ parameters: [
@@ -693,10 +700,11 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Navigation'], tags: ['Navigation'],
summary: 'Matchmake with no target (preserve or dorm)', summary: 'Matchmake with no target (preserve or dorm)',
description: description: [
'Returns the players current instance if they have one (so Orientation isnt warped ' + 'Returns the players current instance if they have one (so Orientation isnt warped',
'away), else their personal dorm when authed, or the shared offline dorm when not. ' + 'away), else their personal dorm when authed, or the shared offline dorm when not.',
'Not auth-gated.', 'Not auth-gated.',
].join(' '),
responses: { responses: {
200: json(MatchmakeResponse, 'Current, personal-dorm, or offline-dorm instance'), 200: json(MatchmakeResponse, 'Current, personal-dorm, or offline-dorm instance'),
}, },
@@ -729,10 +737,11 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Navigation'], tags: ['Navigation'],
summary: 'Matchmake into a clubs clubhouse', summary: 'Matchmake into a clubs clubhouse',
description: description: [
'Looks the club up, checks the caller is a member of it, and places them into an ' + 'Looks the club up, checks the caller is a member of it, and places them into an',
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the ' + 'instance of its clubhouse room. Returns errorCode 20 with a null instance when the',
'club is unknown, has no clubhouse set, or the caller isnt a member.', 'club is unknown, has no clubhouse set, or the caller isnt a member.',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'), requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [ parameters: [
@@ -788,9 +797,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Navigation'], tags: ['Navigation'],
summary: 'Matchmake into a specific subroom', summary: 'Matchmake into a specific subroom',
description: description: [
'Enters a specific subroom (scene) of a room. The subroom decides the scene loaded ' + 'Enters a specific subroom (scene) of a room. The subroom decides the scene loaded',
'and which instances are joinable; an unknown subroom falls back to the rooms first.', 'and which instances are joinable; an unknown subroom falls back to the rooms first.',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'), requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [ parameters: [
@@ -833,9 +843,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Navigation'], tags: ['Navigation'],
summary: 'Matchmake into a room (default subroom)', summary: 'Matchmake into a room (default subroom)',
description: description: [
'The 2023 clients two-segment matchmake. Resolves the room from D1 so the instance ' + 'The 2023 clients two-segment matchmake. Resolves the room from D1 so the instance',
'carries its real scene, and stores it as presence.', 'carries its real scene, and stores it as presence.',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'), requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }], parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
@@ -859,10 +870,11 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Navigation'], tags: ['Navigation'],
summary: 'Matchmake into a room by id or name', summary: 'Matchmake into a room by id or name',
description: description: [
'Single-segment matchmake. `dorm` → the players personal dorm; otherwise resolves ' + 'Single-segment matchmake. `dorm` → the players personal dorm; otherwise resolves',
'the room by id or name. (Note: this dorm keyword is `dorm`, while goto/room uses ' + 'the room by id or name. (Note: this dorm keyword is `dorm`, while goto/room uses',
'`dormroom`.)', '`dormroom`.)',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'), requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [ parameters: [
@@ -902,9 +914,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Navigation'], tags: ['Navigation'],
summary: 'Go to the dorm', summary: 'Go to the dorm',
description: description: [
'Authed → the players personal dorm (persisted as presence); unauthenticated → the ' + 'Authed → the players personal dorm (persisted as presence); unauthenticated → the',
'shared offline dorm. Unlike matchmake/none, this always goes to the dorm.', 'shared offline dorm. Unlike matchmake/none, this always goes to the dorm.',
].join(' '),
responses: { 200: json(MatchmakeResponse, 'The dorm instance') }, responses: { 200: json(MatchmakeResponse, 'The dorm instance') },
}), }),
async (c) => { async (c) => {
@@ -958,9 +971,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Room instance'], tags: ['Room instance'],
summary: 'Set instance in-progress flag', summary: 'Set instance in-progress flag',
description: description: [
'The room owner flips the instances in-progress flag when a session starts (e.g. a ' + 'The room owner flips the instances in-progress flag when a session starts (e.g. a',
'round begins). Body is `inProgress=True|False`.', 'round begins). Body is `inProgress=True|False`.',
].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(InProgressRequest, 'The inProgress flag'), requestBody: form(InProgressRequest, 'The inProgress flag'),
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
@@ -996,9 +1010,10 @@ const app = new Hono<App>()
describeRoute({ describeRoute({
tags: ['Room instance'], tags: ['Room instance'],
summary: 'A rooms live instances', summary: 'A rooms live instances',
description: description: [
'The owners view of active sessions of their room. Auth-gated and gated to the ' + 'The owners view of active sessions of their room. Auth-gated and gated to the',
'rooms creator or a co-owner (403 otherwise). Unknown room → 404.', 'rooms creator or a co-owner (403 otherwise). Unknown room → 404.',
].join(' '),
security: AUTHED, security: AUTHED,
parameters: [ parameters: [
{ {
@@ -1096,12 +1111,6 @@ app.get(
'`room_instance` per session); presence — the instance each player is currently in —', '`room_instance` per session); presence — the instance each player is currently in —',
'lives in the shared `presence` table and expires on a TTL. A cron sweep clears', 'lives in the shared `presence` table and expires on a TTL. A cron sweep clears',
'expired presence and frees up instances a crashed player never left.', 'expired presence and frees up instances a crashed player never left.',
'',
'The shapes here are **reverse-engineered from the game client**, which is the only',
'real consumer. They record observed behaviour, not a designed contract; the handlers',
'are lenient and parse bodies defensively. Nothing in this spec is enforced at',
'runtime — treat a field marked required as "the client always sends it", not "the',
'server rejects it if absent".',
].join('\n'), ].join('\n'),
}, },
servers: [{ url: 'https://match.recflare.net', description: 'Production' }], servers: [{ url: 'https://match.recflare.net', description: 'Production' }],
+6 -1
View File
@@ -17,8 +17,13 @@
"dependencies": { "dependencies": {
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.12.27",
"workers-tagged-logger": "1.0.1" "hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20", "@cloudflare/vitest-pool-workers": "0.16.20",
+105
View File
@@ -0,0 +1,105 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the playersettings 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 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) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/**
* A request body the handler accepts in either encoding. The single write route parses
* form-urlencoded/multipart (`key`/`value`, which is what the client posts) and JSON
* (one object or an array of them), so both are documented on the one body.
*/
export function formOrJson(
formSchema: z.ZodType,
jsonSchema: z.ZodType,
description: string
): OpenAPIV3_1.RequestBodyObject {
const f = toOpenApiSchema(formSchema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: f },
'multipart/form-data': { schema: f },
'application/json': { schema: toOpenApiSchema(jsonSchema) },
},
}
}
/** 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: [] }]
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the root health check. */
export const HealthResponse = z.object({
service: z.literal('playersettings'),
status: z.literal('ok'),
})
/**
* One stored setting as the client reads it (`GET /playersettings`). `Value` is always a
* string the client stores numbers/bools stringified.
*/
export const PlayerSettingEntry = z.object({
PlayerId: z.int().describe('The authenticated player the setting belongs to'),
Key: z.string(),
Value: z.string(),
})
// ---- Request schemas -------------------------------------------------------
/**
* The form-encoded write the client actually sends: a single `key`/`value` pair (e.g.
* `key=PlayerSessionCount&value=1`). An empty `key` is dropped.
*/
export const SettingFormWrite = z.object({
key: z.string().describe('The setting name; an empty key is ignored'),
value: z.string().describe('The setting value, as a string'),
})
/**
* The JSON form of the same write. Accepted as one object or an array of them, and both
* `key`/`value` and `Key`/`Value` casings are read; numbers and booleans are stringified.
*/
export const SettingJsonWrite = z.union([
z.object({
key: z.string().optional(),
Key: z.string().optional(),
value: z.union([z.string(), z.number(), z.boolean()]).optional(),
Value: z.union([z.string(), z.number(), z.boolean()]).optional(),
}),
z.array(
z.object({
key: z.string().optional(),
Key: z.string().optional(),
value: z.union([z.string(), z.number(), z.boolean()]).optional(),
Value: z.union([z.string(), z.number(), z.boolean()]).optional(),
})
),
])
+111 -6
View File
@@ -1,14 +1,34 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers' import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
import { DEFAULT_SETTINGS } from './default-settings' import { DEFAULT_SETTINGS } from './default-settings'
import {
AUTHED,
formOrJson,
HealthResponse,
json,
PlayerSettingEntry,
SettingFormWrite,
SettingJsonWrite,
UNAUTHORIZED_RESPONSE,
} from './openapi'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { App } from './context' import type { App } from './context'
/**
* Player Settings Worker. Serves the small key/value settings bag the game client reads
* on load and writes back as the player toggles options. Backed by a per-player KV map
* (`player:{id}`); a player with nothing stored is seeded with the reference defaults on
* their first read.
*
* Both routes are auth-gated on the Bearer JWT issued by the `auth` worker.
*/
/** /**
* Resolve the account id from a Bearer token (the route is auth-gated). * 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` * Returns `null` when the header is missing, the token is invalid, or the `sub`
@@ -72,11 +92,38 @@ const app = new Hono<App>()
.onError(withOnError()) .onError(withOnError())
.notFound(withNotFound()) .notFound(withNotFound())
.get('/', (c) => c.json({ service: 'playersettings', status: 'ok' })) // Root health check.
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Health check',
description: 'Liveness probe for the playersettings worker. No auth.',
responses: { 200: json(HealthResponse, 'Service is up') },
}),
(c) => c.json({ service: 'playersettings', status: 'ok' })
)
// The authenticated player's settings as `{ PlayerId, Key, Value }`. Reads // The authenticated player's settings as `{ PlayerId, Key, Value }`. Reads
// the per-player KV map; seeds (and persists) the defaults on first read. // the per-player KV map; seeds (and persists) the defaults on first read.
.get('/playersettings', async (c) => { .get(
'/playersettings',
describeRoute({
tags: ['Player Settings'],
summary: 'The players settings',
description: [
'The authenticated players settings as `{ PlayerId, Key, Value }` entries, read from',
'their KV map. A player with nothing stored is seeded with the reference defaults',
'(Recroom.OOBE, TUTORIAL_COMPLETE_MASK, FIRST_TIME_IN_FLAGS), which are persisted on',
'that first read.',
].join(' '),
security: AUTHED,
responses: {
200: json(PlayerSettingEntry.array(), 'The players settings (defaults on first read)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
@@ -88,12 +135,32 @@ const app = new Hono<App>()
} }
return c.json(Object.entries(stored).map(([Key, Value]) => ({ PlayerId: id, Key, Value }))) return c.json(Object.entries(stored).map(([Key, Value]) => ({ PlayerId: id, Key, Value })))
}) }
)
// Upsert player settings into KV, keyed by the authenticated player id. // Upsert player settings into KV, keyed by the authenticated player id.
// A full replace would overwrite the player's entire set; we merge so individual key PUTs // A full replace would overwrite the player's entire set; we merge so individual key PUTs
// (e.g. `key=PlayerSessionCount&value=1`) don't wipe the rest. // (e.g. `key=PlayerSessionCount&value=1`) don't wipe the rest.
.put('/playersettings', async (c) => { .put(
'/playersettings',
describeRoute({
tags: ['Player Settings'],
summary: 'Write the players settings',
description: [
'Upserts the posted setting(s) into the callers KV map. The write MERGES: a single',
'key PUT (`key=PlayerSessionCount&value=1`, which is what the client sends) leaves the',
'players other settings alone. A JSON body is also accepted, as one object or an',
'array, in either `key`/`value` or `Key`/`Value` casing; entries with an empty key are',
'dropped. An unparseable or empty body is a no-op 200, not a 400. Empty body on success.',
].join(' '),
security: AUTHED,
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
responses: {
200: { description: 'Applied, or nothing parseable to apply (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
@@ -101,12 +168,50 @@ const app = new Hono<App>()
if (incoming.length === 0) return c.body(null, 200) if (incoming.length === 0) return c.body(null, 200)
const kvKey = `player:${id}` const kvKey = `player:${id}`
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(kvKey, 'json') const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
kvKey,
'json'
)
const merged: Record<string, string> = { ...existing } const merged: Record<string, string> = { ...existing }
for (const { key, value } of incoming) merged[key] = value for (const { key, value } of incoming) merged[key] = value
await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(merged)) await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(merged))
return c.body(null, 200) return c.body(null, 200)
}
)
// 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 playersettings',
version: '1.0.0',
description: [
'The player key/value settings bag for recflare, a private-server reimplementation of',
'the Rec Room backend. The client reads these on load and writes them back as the',
'player toggles options; they are stored in a per-player KV map, seeded with the',
'reference defaults on a players first read.',
].join('\n'),
},
servers: [{ url: 'https://playersettings.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 export default app
@@ -133,4 +133,38 @@ describe('playersettings endpoints', () => {
const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9'))) const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9')))
expect(res.status).toBe(200) expect(res.status).toBe(200)
}) })
it('GET /openapi.json documents every route', 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.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently 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 /playersettings', 'PUT /playersettings'])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d
// schema used in a response emits a $ref this hono-openapi + zod v4 setup does
// not always hoist, leaving a dangling reference.
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
}) })
+6 -1
View File
@@ -17,8 +17,13 @@
"dependencies": { "dependencies": {
"@repo/hono-helpers": "workspace:*", "@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*", "@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27", "hono": "4.12.27",
"workers-tagged-logger": "1.0.1" "hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
"zod": "4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vitest-pool-workers": "0.16.20", "@cloudflare/vitest-pool-workers": "0.16.20",
+121
View File
@@ -0,0 +1,121 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the storage 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/match/econ 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) } } }
}
/** A `text/plain` response body. */
export function text(description: string) {
return { description, content: { 'text/plain': { schema: { type: 'string' as const } } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** 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: [] }]
// ---- Response schemas ------------------------------------------------------
/**
* `POST /upload` success body. `filename` is the `<upload-date>/<random-name>` part of
* the stored key the client keeps it and later references the blob by it, and the
* `cdn` worker reads it back from `<type-subfolder>/<filename>`. On a name-only post it
* is the name that was sent, echoed straight back.
*/
export const UploadResponse = z.object({
filename: z
.string()
.describe('`<YYYY-MM-DD>/<uuid>[.ext]`, or the posted name on a name-only upload'),
})
/** The `{ error }` body the 400s carry. */
export const ErrorResponse = z.object({ error: z.string() })
// ---- Request schemas -------------------------------------------------------
/**
* The text parts of `POST /upload`. Field names are matched case-insensitively, so the
* client's `imageName` / `FileType` casing is only indicative. The binary part is not in
* this schema see `UPLOAD_REQUEST_BODY`.
*/
export const UploadRequest = z.object({
FileType: z
.string()
.describe(
[
'The clients UploadFileType enum as a string: 1 RoomSave, 2 Holotar, 3 Image,',
'4 Video, 5 Invention, 6 RoomMetadata. 0 (Unknown) and unrecognized values have no',
'destination folder and are rejected.',
].join(' ')
),
imageName: z
.string()
.optional()
.describe(
[
'Name-only post: with no binary part, an explicit `imageName` / `filename` / `name`',
'is echoed straight back as `filename`.',
].join(' ')
),
})
/**
* The `POST /upload` request body. The binary part is detected by being a file (it has a
* filename / content-type), not by its field name, so its key is arbitrary the client
* posts it as `File`. zod cannot express a binary part, so it is spliced into the
* generated schema as `{ type: 'string', format: 'binary' }`.
*/
export const UPLOAD_REQUEST_BODY: OpenAPIV3_1.RequestBodyObject = (() => {
const body = form(UploadRequest, 'The FileType and the file to store')
for (const media of Object.values(body.content)) {
const schema = media.schema as OpenAPIV3_1.SchemaObject
schema.properties = {
...schema.properties,
File: {
type: 'string',
format: 'binary',
description: [
'The file to store. Matched by being a file part, not by this field name.',
'Omit it to make a name-only post.',
].join(' '),
},
}
}
return body
})()
+83 -4
View File
@@ -1,9 +1,20 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers' import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
import {
AUTHED,
ErrorResponse,
json,
text,
UNAUTHORIZED_RESPONSE,
UPLOAD_REQUEST_BODY,
UploadResponse,
} from './openapi'
import type { App } from './context' import type { App } from './context'
/** /**
@@ -72,9 +83,18 @@ const app = new Hono<App>()
.onError(withOnError()) .onError(withOnError())
.notFound(withNotFound()) .notFound(withNotFound())
.get('/', async (c) => { .get(
'/',
describeRoute({
tags: ['Meta'],
summary: 'Health check',
description: 'Plain-text liveness probe. No auth.',
responses: { 200: text('Service is up (`hello, world!`)') },
}),
async (c) => {
return c.text('hello, world!') return c.text('hello, world!')
}) }
)
// File upload. Auth-gated — any valid account token is allowed (no role check). // File upload. Auth-gated — any valid account token is allowed (no role check).
// Multipart form with `FileType` (the client's UploadFileType enum) and a binary // Multipart form with `FileType` (the client's UploadFileType enum) and a binary
@@ -83,7 +103,31 @@ const app = new Hono<App>()
// (the `<upload-date>/<random-name>` part the `cdn` worker serves back) the client // (the `<upload-date>/<random-name>` part the `cdn` worker serves back) the client
// references it by. Also accepts a name-only post (no binary) that just echoes // references it by. Also accepts a name-only post (no binary) that just echoes
// back an explicit `name`/`filename`/`imagename`. Mirrors the reference `Upload`. // back an explicit `name`/`filename`/`imagename`. Mirrors the reference `Upload`.
.post('/upload', async (c) => { .post(
'/upload',
describeRoute({
tags: ['Upload'],
summary: 'Upload a file',
description: [
'Stores the posted file in the shared CDN R2 bucket under',
'`<type-subfolder>/<upload-date>/<random-name>` and returns the',
'`<upload-date>/<random-name>` part the client references it by (the same name the',
'`cdn` worker serves back). The subfolder comes from `FileType`; RoomSave (1) lands',
'under `room/` so the cdn workers `GET /room/:dataBlob` finds it, and an Invention',
'(5) keeps a `.inv` extension on both the key and the returned name. Auth-gated —',
'any valid account token is allowed, no role check. A post with no binary part but',
'an explicit `imageName` / `filename` / `name` just echoes that name back. Mirrors',
'the reference servers `Upload`.',
].join(' '),
security: AUTHED,
requestBody: UPLOAD_REQUEST_BODY,
responses: {
200: json(UploadResponse, 'The stored (or echoed) file name'),
400: json(ErrorResponse, 'Unknown/missing FileType, or neither a file nor a name'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -117,6 +161,41 @@ const app = new Hono<App>()
if (explicitName) return c.json({ filename: explicitName }) if (explicitName) return c.json({ filename: explicitName })
return c.json({ error: 'missing filename or valid upload data' }, 400) return c.json({ error: 'missing filename or valid upload data' }, 400)
}
)
// 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 storage',
version: '1.0.0',
description: [
'File uploads for recflare, a private-server reimplementation of the Rec Room',
'backend. The client posts room saves, holotars, images, videos, inventions and',
'room metadata here; each lands in the shared CDN R2 bucket under a folder chosen',
'by its `FileType`, and the `cdn` worker serves them back from the same bucket.',
].join('\n'),
},
servers: [{ url: 'https://storage.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 export default app
@@ -159,3 +159,35 @@ it('POST /upload 400s when there is neither a file nor a name', async () => {
}) })
expect(res.status).toBe(400) expect(res.status).toBe(400)
}) })
it('GET /openapi.json documents every route', 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.
expect(spec.paths['/openapi.json']).toBeUndefined()
// Every route the worker serves is described. This is the drift guard: adding a
// route without a describeRoute() block fails here rather than silently 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 /', 'POST /upload'])
// Every operation carries a summary — a path present but undescribed is not
// documentation.
for (const ops of Object.values(spec.paths)) {
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
}
// Every schema inlines: a `$ref` here would be a dangling reference (see openapi.ts).
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
+12 -3
View File
@@ -21,6 +21,11 @@ export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }>
{ slug: 'accounts', title: 'accounts — profiles & lookups' }, { slug: 'accounts', title: 'accounts — profiles & lookups' },
{ slug: 'match', title: 'match — matchmaking & presence' }, { slug: 'match', title: 'match — matchmaking & presence' },
{ slug: 'econ', title: 'econ — avatar & economy' }, { slug: 'econ', title: 'econ — avatar & economy' },
{ slug: 'clubs', title: 'clubs — clubs & clubhouses' },
{ slug: 'chat', title: 'chat — threads & messages' },
{ slug: 'img', title: 'img — image serving & resizing' },
{ slug: 'storage', title: 'storage — uploads to the CDN bucket' },
{ slug: 'playersettings', title: 'playersettings — per-player settings' },
{ slug: 'api', title: 'api — everything else' }, { slug: 'api', title: 'api — everything else' },
] ]
@@ -46,9 +51,13 @@ function overviewSpec(): Record<string, unknown> {
'---', '---',
'', '',
'These specs are **descriptive, not enforced** — they document a protocol', 'These specs are **descriptive, not enforced** — they document a protocol',
'reverse-engineered from the game client (the only real consumer), so a field', 'reverse-engineered from the game client (the only real consumer). They record',
'marked required means "the client always sends it", not "the server rejects it if', 'observed behaviour, not a designed contract, and the handlers are lenient: they',
'absent". Each service also serves its own spec at `https://<service>.<domain>/openapi.json`.', 'parse bodies defensively rather than rejecting them. So a field marked required',
'means "the client always sends it", not "the server rejects it if absent".',
'',
'This applies to every service below; the individual specs dont repeat it. Each',
'service also serves its own spec at `https://<service>.<domain>/openapi.json`.',
].join('\n') ].join('\n')
return { return {
openapi: '3.1.0', openapi: '3.1.0',
+5 -1
View File
@@ -1,6 +1,8 @@
import { SELF } from 'cloudflare:test' import { SELF } from 'cloudflare:test'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import { DOCUMENTED_SERVICES } from '../../docs'
it('rejects unauthenticated account reads', async () => { it('rejects unauthenticated account reads', async () => {
const res = await SELF.fetch('https://example.com/api/me') const res = await SELF.fetch('https://example.com/api/me')
expect(res.status).toBe(401) expect(res.status).toBe(401)
@@ -54,7 +56,9 @@ it('serves the aggregated docs page with a source per documented service', async
const html = await res.text() const html = await res.text()
// Mounts the self-hosted Scalar bundle (not a CDN) and lists every service's spec. // Mounts the self-hosted Scalar bundle (not a CDN) and lists every service's spec.
expect(html).toContain('/docs/scalar.standalone.js') expect(html).toContain('/docs/scalar.standalone.js')
for (const slug of ['auth', 'accounts', 'match', 'econ']) { // Driven off the constant so adding a service can't leave the page (or this test)
// behind.
for (const { slug } of DOCUMENTED_SERVICES) {
expect(html).toContain(`/docs/openapi/${slug}.json`) expect(html).toContain(`/docs/openapi/${slug}.json`)
} }
}) })
+75
View File
@@ -249,12 +249,27 @@ importers:
'@repo/jwt': '@repo/jwt':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/jwt version: link:../../packages/jwt
'@standard-community/standard-json':
specifier: 0.3.5
version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
'@standard-community/standard-openapi':
specifier: 0.2.9
version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)
hono: hono:
specifier: 4.12.27 specifier: 4.12.27
version: 4.12.27 version: 4.12.27
hono-openapi:
specifier: 1.3.1
version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3)
openapi-types:
specifier: 12.1.3
version: 12.1.3
workers-tagged-logger: workers-tagged-logger:
specifier: 1.0.1 specifier: 1.0.1
version: 1.0.1 version: 1.0.1
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies: devDependencies:
'@cloudflare/vitest-pool-workers': '@cloudflare/vitest-pool-workers':
specifier: 0.16.20 specifier: 0.16.20
@@ -286,12 +301,27 @@ importers:
'@repo/jwt': '@repo/jwt':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/jwt version: link:../../packages/jwt
'@standard-community/standard-json':
specifier: 0.3.5
version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
'@standard-community/standard-openapi':
specifier: 0.2.9
version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)
hono: hono:
specifier: 4.12.27 specifier: 4.12.27
version: 4.12.27 version: 4.12.27
hono-openapi:
specifier: 1.3.1
version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3)
openapi-types:
specifier: 12.1.3
version: 12.1.3
workers-tagged-logger: workers-tagged-logger:
specifier: 1.0.1 specifier: 1.0.1
version: 1.0.1 version: 1.0.1
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies: devDependencies:
'@cloudflare/vitest-pool-workers': '@cloudflare/vitest-pool-workers':
specifier: 0.16.20 specifier: 0.16.20
@@ -403,12 +433,27 @@ importers:
'@repo/hono-helpers': '@repo/hono-helpers':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/hono-helpers version: link:../../packages/hono-helpers
'@standard-community/standard-json':
specifier: 0.3.5
version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
'@standard-community/standard-openapi':
specifier: 0.2.9
version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)
hono: hono:
specifier: 4.12.27 specifier: 4.12.27
version: 4.12.27 version: 4.12.27
hono-openapi:
specifier: 1.3.1
version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3)
openapi-types:
specifier: 12.1.3
version: 12.1.3
workers-tagged-logger: workers-tagged-logger:
specifier: 1.0.1 specifier: 1.0.1
version: 1.0.1 version: 1.0.1
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies: devDependencies:
'@cloudflare/vitest-pool-workers': '@cloudflare/vitest-pool-workers':
specifier: 0.16.20 specifier: 0.16.20
@@ -606,12 +651,27 @@ importers:
'@repo/jwt': '@repo/jwt':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/jwt version: link:../../packages/jwt
'@standard-community/standard-json':
specifier: 0.3.5
version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
'@standard-community/standard-openapi':
specifier: 0.2.9
version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)
hono: hono:
specifier: 4.12.27 specifier: 4.12.27
version: 4.12.27 version: 4.12.27
hono-openapi:
specifier: 1.3.1
version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3)
openapi-types:
specifier: 12.1.3
version: 12.1.3
workers-tagged-logger: workers-tagged-logger:
specifier: 1.0.1 specifier: 1.0.1
version: 1.0.1 version: 1.0.1
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies: devDependencies:
'@cloudflare/vitest-pool-workers': '@cloudflare/vitest-pool-workers':
specifier: 0.16.20 specifier: 0.16.20
@@ -677,12 +737,27 @@ importers:
'@repo/jwt': '@repo/jwt':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/jwt version: link:../../packages/jwt
'@standard-community/standard-json':
specifier: 0.3.5
version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3)
'@standard-community/standard-openapi':
specifier: 0.2.9
version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3)
hono: hono:
specifier: 4.12.27 specifier: 4.12.27
version: 4.12.27 version: 4.12.27
hono-openapi:
specifier: 1.3.1
version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3)
openapi-types:
specifier: 12.1.3
version: 12.1.3
workers-tagged-logger: workers-tagged-logger:
specifier: 1.0.1 specifier: 1.0.1
version: 1.0.1 version: 1.0.1
zod:
specifier: 4.4.3
version: 4.4.3
devDependencies: devDependencies:
'@cloudflare/vitest-pool-workers': '@cloudflare/vitest-pool-workers':
specifier: 0.16.20 specifier: 0.16.20