diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index 4476788..3cb9a41 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -179,9 +179,10 @@ const app = new Hono() describeRoute({ tags: ['Self'], summary: 'The caller’s own account', - description: - 'The private self DTO, including owner-only fields (email, remaining username ' + + description: [ + 'The private self DTO, including owner-only fields (email, remaining username', 'changes). An account with no stored row falls back to a synthesized default.', + ].join(' '), security: AUTHED, responses: { 200: json(SelfAccountDto, 'The caller’s account'), @@ -232,9 +233,10 @@ const app = new Hono() describeRoute({ tags: ['Lookup'], summary: 'Look up many accounts by id', - description: - 'Accepts repeated `id` query params and/or comma-separated lists. Every requested ' + + description: [ + '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.', + ].join(' '), parameters: [ { name: 'id', @@ -325,9 +327,10 @@ const app = new Hono() describeRoute({ tags: ['Self'], summary: 'Create an account', - description: - 'Mints a new account with an auto-assigned random username (players don’t choose ' + + description: [ + 'Mints a new account with an auto-assigned random username (players don’t choose', 'one initially). Not auth-gated. `platformId` is parsed but not yet persisted.', + ].join(' '), requestBody: form(CreateAccountRequest, 'Platform fields'), responses: { 200: json(CreateAccountResult, 'The created account, in a result envelope') }, }), @@ -375,9 +378,10 @@ const app = new Hono() describeRoute({ tags: ['Lookup'], summary: 'An account’s privacy settings', - description: - 'Nothing stores per-player privacy yet; the id is echoed and recent history is ' + + description: [ + 'Nothing stores per-player privacy yet; the id is echoed and recent history is', 'reported visible (a bare `{}` fails the client’s deserializer).', + ].join(' '), parameters: [ { name: 'id', @@ -431,10 +435,11 @@ const app = new Hono() describeRoute({ tags: ['Profile'], summary: 'Change username', - description: - '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 — ' + + description: [ + '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 —', 'failures carry a message in `error` (see the UsernameResult envelope).', + ].join(' '), security: AUTHED, requestBody: form(UsernameRequest, 'The desired username'), responses: { @@ -529,9 +534,10 @@ const app = new Hono() describeRoute({ tags: ['Profile'], summary: 'Set identity flags', - description: - '`identityFlags` bitmask. In the public DTO, so the update is broadcast via ' + + description: [ + '`identityFlags` bitmask. In the public DTO, so the update is broadcast via', 'AccountUpdate.', + ].join(' '), security: AUTHED, requestBody: form(IdentityFlagsRequest, 'The identityFlags bitmask'), responses: { @@ -561,9 +567,10 @@ const app = new Hono() describeRoute({ tags: ['Profile'], summary: 'Set personal pronouns', - description: - 'Posted as `pronounFlags`. The response carries no account, so the client learns ' + + description: [ + 'Posted as `pronounFlags`. The response carries no account, so the client learns', 'the new value only from the broadcast AccountUpdate.', + ].join(' '), security: AUTHED, requestBody: form(PronounsRequest, 'The pronounFlags bitmask'), responses: { @@ -648,12 +655,6 @@ app.get( 'Account reads, profile mutations and lookups for recflare, a private-server', 'reimplementation of the Rec Room backend. Accounts live in the shared `recflare`', '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'), }, servers: [{ url: 'https://accounts.recflare.net', description: 'Production' }], diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index e194dc5..e85c6a4 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -75,12 +75,6 @@ app.get( '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', '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'), }, servers: [{ url: 'https://api.recflare.net', description: 'Production' }], diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index af72677..224b5cc 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -247,13 +247,14 @@ const app = new Hono() describeRoute({ tags: ['Cached login'], summary: 'Accounts linked to a platform id', - description: - '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 ' + - 'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls ' + - 'back to a fresh login or create_account. ' + - 'EXCEPT platform 1 (Oculus), which is stubbed: it ignores the id and returns one ' + + description: [ + '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', + 'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls', + 'back to a fresh login or create_account.', + 'EXCEPT platform 1 (Oculus), which is stubbed: it ignores the id and returns one', 'canned, non-redeemable entry with `requirePassword: true`.', + ].join(' '), parameters: [ { name: 'platform', @@ -306,11 +307,12 @@ const app = new Hono() describeRoute({ tags: ['Cached login'], summary: 'Bulk cached-login lookup (friends resolution)', - description: - '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 ' + - "entry's own `platformId`. Unlike the single-id route, results are NOT filtered to " + + description: [ + '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', + 'entry’s own `platformId`. Unlike the single-id route, results are NOT filtered to', 'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.', + ].join(' '), requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'), responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') }, }), @@ -373,8 +375,10 @@ const app = new Hono() 200: json(TokenResponse, 'Access token, refresh token and granted scopes'), 400: json( 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( OAuthError, @@ -654,10 +658,11 @@ const app = new Hono() describeRoute({ tags: ['Account'], summary: "Change the caller's password", - description: - '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 ' + + description: [ + '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', 'a password is set, `oldPassword` is empty — which is what the client sends.', + ].join(' '), security: [{ bearerAuth: [] }], requestBody: form(ChangePasswordRequest, 'New password, plus the old one when one is set'), responses: { @@ -729,13 +734,6 @@ app.get( description: [ 'Authentication and token issuance for recflare, a private-server reimplementation', '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'), }, servers: [{ url: 'https://auth.recflare.net', description: 'Production' }], diff --git a/apps/chat/package.json b/apps/chat/package.json index 2ec0acb..c770455 100644 --- a/apps/chat/package.json +++ b/apps/chat/package.json @@ -18,8 +18,13 @@ "dependencies": { "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", + "@standard-community/standard-json": "0.3.5", + "@standard-community/standard-openapi": "0.2.9", "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": { "@cloudflare/vitest-pool-workers": "0.16.20", diff --git a/apps/chat/src/chat.app.ts b/apps/chat/src/chat.app.ts index 97afbbb..33c930b 100644 --- a/apps/chat/src/chat.app.ts +++ b/apps/chat/src/chat.app.ts @@ -1,11 +1,34 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' 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 { NotificationType } from '../../notify/src/notification-types' 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 { addThreadMember, getOrCreateThreadWithMembers, @@ -218,6 +241,74 @@ async function formField(c: Context, name: string): Promise + 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 isn’t 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 sender’s own', + '`lastReadMessageId` comes back already at the message just posted. Pushes', + 'ChatMessageReceived to every member, the sender included — the client doesn’t fold the', + 'HTTP response into its local cache, so without a self-targeted push its own outgoing', + 'message doesn’t appear until the thread is refetched. Note the hub frame’s `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() .use( '*', @@ -232,17 +323,45 @@ const app = new Hono() .onError(withOnError()) .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 // 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 // player only ever sees their own threads. - .get('/thread', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) - return c.json(await getThreadsForPlayer(c.env.DB, id, { limit: messageCount(c) })) - }) + .get( + '/thread', + describeRoute({ + tags: ['Threads'], + summary: 'The caller’s thread list', + description: [ + 'Every thread the caller is a member of, newest conversation first — each carrying its', + '`latestMessage` 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 player', + 'only ever sees their own threads.', + ].join(' '), + security: AUTHED, + parameters: [messageCountParam(DEFAULT_MESSAGE_COUNT)], + responses: { + 200: json(ChatThreadDto.array(), 'The caller’s threads, newest first (empty when none)'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + 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 // create-thread-and-post-first-message call, in one. Resolves to the thread those @@ -252,33 +371,60 @@ const app = new Hono() // (`{"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 // posting an empty message, and reports invalid-arguments the way the reference does. - .post('/thread', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .post( + '/thread', + describeRoute({ + tags: ['Threads'], + summary: 'Open a thread with a set of players and post the first message', + description: [ + 'The client’s 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) + if (id === null) return c.body(null, 401) - const members = [...new Set([id, ...(await memberIds(c))])] - if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400) + const members = [...new Set([id, ...(await memberIds(c))])] + if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400) - const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id) + const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id) - const contents = (await formField(c, 'messageContents'))?.trim() - const posted = - contents === undefined || contents === '' - ? null - : await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents }) - if (posted !== null) { - await pushChatMessage(c, posted) - await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId) + const contents = (await formField(c, 'messageContents'))?.trim() + const posted = + contents === undefined || contents === '' + ? null + : await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents }) + if (posted !== null) { + await pushChatMessage(c, posted) + await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId) + } + + const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id) + if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`) + // The reference answers a wrapper here, not a bare thread. + return c.json({ + chatThread: thread, + chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS, + }) } - - const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id) - if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`) - // The reference answers a wrapper here, not a bare thread. - return c.json({ - chatThread: thread, - chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS, - }) - }) + ) // "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 @@ -288,21 +434,48 @@ const app = new Hono() // 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 // conversation. The array is always present, empty for a brand-new thread. - .post('/thread/withmembers', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .post( + '/thread/withmembers', + describeRoute({ + tags: ['Threads'], + summary: 'Fetch or open the thread with exactly these members', + description: [ + 'The client’s GetChatBetweenPlayers. Fetch-or-create: the thread whose membership is', + 'exactly `ids` plus the caller, opened only if they don’t 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 list’s 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) + if (id === null) return c.body(null, 401) - const members = [...new Set([id, ...(await memberIds(c))])] - // A thread needs someone else in it; naming only yourself is a bad request - // rather than a lonely thread. - if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400) + const members = [...new Set([id, ...(await memberIds(c))])] + // A thread needs someone else in it; naming only yourself is a bad request + // rather than a lonely thread. + if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400) - const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id) - const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT) - const thread = await threadWithMessages(c, chatThreadId, id, limit) - if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`) - return c.json(thread) - }) + const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id) + const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT) + const thread = await threadWithMessages(c, chatThreadId, id, limit) + if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`) + return c.json(thread) + } + ) // 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` @@ -320,15 +493,36 @@ const app = new Hono() // // 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. - .get('/thread/:id{[0-9]+}', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .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 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.', + ].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) + if (id === null) return c.body(null, 401) - const chatThreadId = Number.parseInt(c.req.param('id'), 10) - const limit = messageCount(c, DEFAULT_THREAD_MESSAGE_COUNT) - const thread = await threadWithMessages(c, chatThreadId, id, limit) - return thread === null ? c.notFound() : c.json(thread) - }) + const chatThreadId = Number.parseInt(c.req.param('id'), 10) + const limit = messageCount(c, DEFAULT_THREAD_MESSAGE_COUNT) + const thread = await threadWithMessages(c, chatThreadId, id, limit) + return thread === null ? c.notFound() : c.json(thread) + } + ) // 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` @@ -337,25 +531,40 @@ const app = new Hono() // Answers the SendMessageResponse wrapper (`{chatMessage, chatResult}`), not a bare // message. Blank or missing contents is invalid-arguments with no message attached, // rather than an error status. - .post('/thread/:id{[0-9]+}', (c) => sendToThread(c)) - .post('/thread/:id{[0-9]+}/message', (c) => sendToThread(c)) + .post('/thread/:id{[0-9]+}', sendToThreadRoute('`/thread/{id}`'), (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 // 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. - .on(['POST', 'PUT'], '/thread/:id{[0-9]+}/rename', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .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 isn’t on the thread, 0 on success.', + ].join(' '), + { requestBody: form(RenameThreadRequest, 'The new name') } + ), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) - const chatThreadId = Number.parseInt(c.req.param('id'), 10) - if (!(await isThreadMember(c.env.DB, chatThreadId, id))) { - return c.json(CHAT_MEMBERSHIP_NOT_FOUND) + const chatThreadId = Number.parseInt(c.req.param('id'), 10) + if (!(await isThreadMember(c.env.DB, chatThreadId, id))) { + return c.json(CHAT_MEMBERSHIP_NOT_FOUND) + } + + const name = ((await formField(c, 'name')) ?? '').trim().slice(0, MAX_THREAD_NAME_LENGTH) + await setThreadName(c.env.DB, chatThreadId, name) + return c.json(CHAT_SUCCESS) } - - const name = ((await formField(c, 'name')) ?? '').trim().slice(0, MAX_THREAD_NAME_LENGTH) - await setThreadName(c.env.DB, chatThreadId, name) - return c.json(CHAT_SUCCESS) - }) + ) // 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. @@ -363,25 +572,39 @@ const app = new Hono() // 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 // is what tells their client the thread is gone. - .on(['POST', 'DELETE'], '/thread/:id{[0-9]+}/leave', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .on( + ['POST', 'DELETE'], + '/thread/:id{[0-9]+}/leave', + chatResultRoute( + '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. 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) + if (id === null) return c.body(null, 401) - const chatThreadId = Number.parseInt(c.req.param('id'), 10) - if (!(await isThreadMember(c.env.DB, chatThreadId, id))) { - return c.json(CHAT_MEMBERSHIP_NOT_FOUND) + const chatThreadId = Number.parseInt(c.req.param('id'), 10) + if (!(await isThreadMember(c.env.DB, chatThreadId, id))) { + return c.json(CHAT_MEMBERSHIP_NOT_FOUND) + } + + const notice = await postMessage(c.env.DB, { + chatThreadId, + senderPlayerId: SYSTEM_SENDER_ID, + contents: leftChatContents(id), + }) + await pushChatMessage(c, notice) + + await removeThreadMember(c.env.DB, chatThreadId, id) + return c.json(CHAT_SUCCESS) } - - const notice = await postMessage(c.env.DB, { - chatThreadId, - senderPlayerId: SYSTEM_SENDER_ID, - contents: leftChatContents(id), - }) - await pushChatMessage(c, notice) - - await removeThreadMember(c.env.DB, chatThreadId, id) - return c.json(CHAT_SUCCESS) - }) + ) // 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. @@ -390,34 +613,60 @@ const app = new Hono() // `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 // the one line to change. - .on(['POST', 'PUT'], '/thread/:id{[0-9]+}/snooze', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .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) + if (id === null) return c.body(null, 401) - const chatThreadId = Number.parseInt(c.req.param('id'), 10) - if (!(await isThreadMember(c.env.DB, chatThreadId, id))) { - return c.json(CHAT_MEMBERSHIP_NOT_FOUND) + const chatThreadId = Number.parseInt(c.req.param('id'), 10) + if (!(await isThreadMember(c.env.DB, chatThreadId, id))) { + return c.json(CHAT_MEMBERSHIP_NOT_FOUND) + } + + const on = await formBool(c, 'snooze') + await setThreadSnoozed(c.env.DB, chatThreadId, id, on ? SNOOZED_INDEFINITELY : null) + return c.json(CHAT_SUCCESS) } - - const on = await formBool(c, 'snooze') - await setThreadSnoozed(c.env.DB, chatThreadId, id, on ? SNOOZED_INDEFINITELY : null) - return c.json(CHAT_SUCCESS) - }) + ) // 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. - .on(['PUT', 'POST'], '/thread/:id{[0-9]+}/favorite', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .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 caller’s own inbox and', + 'leaves everyone else’s untouched.', + ].join(' '), + { requestBody: form(FavoriteThreadRequest, 'The favorite flag') } + ), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) - const chatThreadId = Number.parseInt(c.req.param('id'), 10) - if (!(await isThreadMember(c.env.DB, chatThreadId, id))) { - return c.json(CHAT_MEMBERSHIP_NOT_FOUND) + const chatThreadId = Number.parseInt(c.req.param('id'), 10) + if (!(await isThreadMember(c.env.DB, chatThreadId, id))) { + return c.json(CHAT_MEMBERSHIP_NOT_FOUND) + } + + await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite')) + return c.json(CHAT_SUCCESS) } - - await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite')) - return c.json(CHAT_SUCCESS) - }) + ) // 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. @@ -426,23 +675,48 @@ const app = new Hono() // 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 — // re-adding an existing member changes nothing. - .post('/thread/:id{[0-9]+}/member/:playerId{[0-9]+}', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .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', + 'you’re part of. Answers a bare ChatResult rather than an HTTP status, as the reference', + 'does: 3 when 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 — 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) + if (id === null) return c.body(null, 401) - const chatThreadId = Number.parseInt(c.req.param('id'), 10) - if (!(await isThreadMember(c.env.DB, chatThreadId, id))) { - return c.json(CHAT_MEMBERSHIP_NOT_FOUND) + const chatThreadId = Number.parseInt(c.req.param('id'), 10) + if (!(await isThreadMember(c.env.DB, chatThreadId, id))) { + return c.json(CHAT_MEMBERSHIP_NOT_FOUND) + } + + const playerId = Number.parseInt(c.req.param('playerId'), 10) + if (await isThreadMember(c.env.DB, chatThreadId, playerId)) { + return c.json(CHAT_PLAYER_ALREADY_ON_THREAD) + } + + await addThreadMember(c.env.DB, chatThreadId, playerId) + return c.json(CHAT_SUCCESS) } - - const playerId = Number.parseInt(c.req.param('playerId'), 10) - if (await isThreadMember(c.env.DB, chatThreadId, playerId)) { - return c.json(CHAT_PLAYER_ALREADY_ON_THREAD) - } - - await addThreadMember(c.env.DB, chatThreadId, playerId) - return c.json(CHAT_SUCCESS) - }) + ) // 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 @@ -452,23 +726,116 @@ const app = new Hono() // 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 // thread as permanently read. - .on(['PUT', 'POST'], '/thread/:id{[0-9]+}/read', (c) => markRead(c)) - .on(['PUT', 'POST'], '/thread/:id{[0-9]+}/message/:messageId{[0-9]+}/read', (c) => - markRead(c, Number.parseInt(c.req.param('messageId'), 10)) + .on( + ['PUT', 'POST'], + '/thread/:id{[0-9]+}/read', + chatResultRoute( + 'Mark a whole thread read', + [ + 'Moves the caller’s read pointer to the thread’s latest message. The pointer only moves', + 'forward and never past the thread’s real latest message, so an id the client made up', + 'can’t strand the thread as permanently read. 404s for a thread the caller isn’t 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', + 'isn’t 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. // `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 // as for a thread that's gone. - .get('/thread/:id{[0-9]+}/message', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .get( + '/thread/:id{[0-9]+}/message', + describeRoute({ + tags: ['Messages'], + summary: 'A page of one thread’s 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 isn’t in:', + 'whether a thread exists is itself private, so a non-member gets the same answer as for a', + 'thread that’s 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) + if (id === null) return c.body(null, 401) - const chatThreadId = Number.parseInt(c.req.param('id'), 10) - if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound() + const chatThreadId = Number.parseInt(c.req.param('id'), 10) + 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 worker’s `POST /connect/token`.', + }, + }, + }, + }, + }) + ) +) export default app diff --git a/apps/chat/src/openapi.ts b/apps/chat/src/openapi.ts new file mode 100644 index 0000000..16f0e2b --- /dev/null +++ b/apps/chat/src/openapi.ts @@ -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 thread’s 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 thread’s `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 +} diff --git a/apps/chat/src/test/integration/api.test.ts b/apps/chat/src/test/integration/api.test.ts index 5df39bb..fb53f9a 100644 --- a/apps/chat/src/test/integration/api.test.ts +++ b/apps/chat/src/test/integration/api.test.ts @@ -1265,3 +1265,61 @@ describe('PUT /thread/:id/favorite', () => { 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> + } + 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() + } + }) +}) diff --git a/apps/clubs/package.json b/apps/clubs/package.json index f91fa02..52e0fdc 100644 --- a/apps/clubs/package.json +++ b/apps/clubs/package.json @@ -19,8 +19,13 @@ "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", + "@standard-community/standard-json": "0.3.5", + "@standard-community/standard-openapi": "0.2.9", "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": { "@cloudflare/vitest-pool-workers": "0.16.20", diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index e680bf1..0d7b8e8 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -1,7 +1,8 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' 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 { @@ -30,10 +31,58 @@ import { setHomeClub, updateClub, } 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 { 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 club’s id (digits only — a non-numeric id doesn’t match the route)', + schema: { type: 'string' }, +} as const + /** * 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. @@ -54,7 +103,7 @@ const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10 const MAX_CLUB_NAME_LENGTH = 16 /** 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 @@ -142,224 +191,496 @@ const app = new Hono() // 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 // empty object. Returns the bare club (not the envelope), as the reference does. - .get('/club/home/me', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) - const club = await getHomeClub(c.env.DB, id) - return club === null ? c.notFound() : c.json(club) - }) + .get( + '/club/home/me', + describeRoute({ + tags: ['Home club'], + summary: 'The player’s 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 player’s home club'), + 401: UNAUTHORIZED_RESPONSE, + 404: { description: 'No home club, or it has no clubhouse room' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + const club = await getHomeClub(c.env.DB, id) + return club === null ? c.notFound() : c.json(club) + } + ) // 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. - .put('/club/home/me', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .put( + '/club/home/me', + describeRoute({ + tags: ['Home club'], + summary: 'Set the player’s home club', + description: [ + 'Points the player’s home club at the posted `clubId`. They must already be a member', + 'of it — you can’t make a club you don’t 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 isn’t a member of that club'), + 404: { description: 'No such club' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) - const body = (await c.req.parseBody().catch(() => ({}))) as Record - const key = Object.keys(body).find((k) => k.toLowerCase() === 'clubid') - const clubId = Number.parseInt( - typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '', - 10 - ) - if (Number.isNaN(clubId) || clubId === 0) return clubError(c, 'Invalid clubId.') - - const club = await getClub(c.env.DB, clubId) - if (club === null) return c.notFound() - - const membership = await getMembership(c.env.DB, clubId, id) - if (membership < ClubMembershipType.Member) { - return c.json( - { error: 'You are not a member of that club.', success: false, value: null }, - 403 + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const key = Object.keys(body).find((k) => k.toLowerCase() === 'clubid') + const clubId = Number.parseInt( + typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '', + 10 ) - } + if (Number.isNaN(clubId) || clubId === 0) return clubError(c, 'Invalid clubId.') - await setHomeClub(c.env.DB, id, clubId) - return c.json({ error: '', success: true, value: club }) - }) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Member) { + return c.json( + { error: 'You are not a member of that club.', success: false, value: null }, + 403 + ) + } + + await setHomeClub(c.env.DB, id, clubId) + return c.json({ error: '', success: true, value: club }) + } + ) // 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 // 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. - .delete('/club/home/me', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) - await clearHomeClub(c.env.DB, id) - return c.json({ error: '', success: true, value: null }) - }) + .delete( + '/club/home/me', + describeRoute({ + tags: ['Home club'], + summary: 'Clear the player’s 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 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.', + ].join(' '), + security: AUTHED, + responses: { + 200: json(NullEnvelope, 'Cleared (value null)'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + await clearHomeClub(c.env.DB, id) + return c.json({ error: '', success: true, value: null }) + } + ) // 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 // prefix) and sends no auth header, so it isn't gated. Returns an empty // 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 caller’s 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 isn’t 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. - .get('/subscription/details/:accountId{[0-9]+}', (c) => - c.json({ - accountId: Number.parseInt(c.req.param('accountId'), 10), - clubId: 0, - subscriberCount: 0, - }) + .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({ + accountId: Number.parseInt(c.req.param('accountId'), 10), + clubId: 0, + subscriberCount: 0, + }) ) // Details for a named subscription (e.g. `rrplus`). The client deserializes this // 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. - .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- // 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 player’s 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 // envelope, with `LastAnnouncementId` the newest one (null when there are none) // and `LastReadAnnouncementId` 0: nothing tracks read state yet. - .get('/announcements/club/:clubId{[0-9]+}', async (c) => { - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const announcements = await getClubAnnouncements(c.env.DB, clubId) - return c.json({ - error: '', - success: true, - value: { - Announcements: announcements, - ClubId: clubId, - LastAnnouncementId: announcements[0]?.AnnouncementId ?? null, - LastReadAnnouncementId: 0, - }, - }) - }) + .get( + '/announcements/club/:clubId{[0-9]+}', + describeRoute({ + tags: ['Announcements'], + summary: 'A club’s announcements', + description: [ + 'The club’s 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 club’s noticeboard') }, + }), + async (c) => { + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const announcements = await getClubAnnouncements(c.env.DB, clubId) + return c.json({ + error: '', + success: true, + value: { + Announcements: announcements, + ClubId: clubId, + LastAnnouncementId: announcements[0]?.AnnouncementId ?? null, + LastReadAnnouncementId: 0, + }, + }) + } + ) // Post an announcement to a club. Co-owner or above only. The envelope's value is // the new announcement's id. - .post('/announcements/club/:clubId{[0-9]+}', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .post( + '/announcements/club/:clubId{[0-9]+}', + describeRoute({ + tags: ['Announcements'], + summary: 'Post an announcement to a club', + description: 'Co-owner or above only. The envelope’s `value` is the new announcement’s id.', + security: AUTHED, + parameters: [CLUB_ID_PARAM], + requestBody: form(AnnouncementRequest, 'The announcement fields'), + responses: { + 200: json(AnnouncementIdEnvelope, 'The new announcement’s id'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(ErrorEnvelope, 'Below co-owner'), + 404: { description: 'No such club' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const club = await getClub(c.env.DB, clubId) - if (club === null) return c.notFound() + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() - const membership = await getMembership(c.env.DB, clubId, id) - if (membership < ClubMembershipType.Coowner) { - return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const field = (name: string): string | undefined => { + const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) + const v = key === undefined ? undefined : body[key] + return typeof v === 'string' ? v : undefined + } + + const announcementId = await createClubAnnouncement(c.env.DB, clubId, id, { + title: field('title'), + body: field('body'), + imageName: field('imageName'), + meta: field('meta'), + }) + return c.json({ error: '', success: true, value: announcementId }) } - - const body = (await c.req.parseBody().catch(() => ({}))) as Record - const field = (name: string): string | undefined => { - const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) - const v = key === undefined ? undefined : body[key] - return typeof v === 'string' ? v : undefined - } - - const announcementId = await createClubAnnouncement(c.env.DB, clubId, id, { - title: field('title'), - body: field('body'), - imageName: field('imageName'), - meta: field('meta'), - }) - return c.json({ error: '', success: true, value: announcementId }) - }) + ) // 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 // this answers an empty list rather than 401ing — the client shows the "my clubs" // shelf either way, and an error there breaks the screen. - .get('/club/mine/member', async (c) => { - const id = await authedId(c) - if (id === null) return c.json([]) - return c.json(await getClubsByMember(c.env.DB, id)) - }) + .get( + '/club/mine/member', + describeRoute({ + tags: ['Clubs'], + summary: 'The clubs the player is a member of', + description: [ + 'GetMyMembershipClubs — the caller’s 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 caller’s clubs (empty when signed out)') }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.json([]) + return c.json(await getClubsByMember(c.env.DB, id)) + } + ) // The clubs the player created (GetMyCreatedClubs). Empty list when signed out, // like mine/member. - .get('/club/mine/created', async (c) => { - const id = await authedId(c) - if (id === null) return c.json([]) - return c.json(await getClubsByCreator(c.env.DB, id)) - }) + .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) + if (id === null) return c.json([]) + return c.json(await getClubsByCreator(c.env.DB, id)) + } + ) // Club search / browse. Public, non-subscription clubs; `category` filters to that // 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 // (out of range → 30). Public. Answers `{ Clubs, ContinuationToken, TotalClubs }`. - .get('/club/search', async (c) => { - const count = Number.parseInt(c.req.query('count') ?? '', 10) - return c.json( - await searchClubs( - c.env.DB, - c.req.query('category') ?? '', - c.req.query('query') ?? '', - c.req.query('sort'), - Number.isNaN(count) || count <= 0 || count > 100 ? 30 : count + .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) + return c.json( + await searchClubs( + c.env.DB, + c.req.query('category') ?? '', + c.req.query('query') ?? '', + c.req.query('sort'), + Number.isNaN(count) || count <= 0 || count > 100 ? 30 : count + ) ) - ) - }) + } + ) // The set of club category tags a club can be filed under — a fixed list. - .get('/club/categoryTags', (c) => - c.json(['Social', 'Creative', 'Competitive', 'Casual', 'Entertainment']) + .get( + '/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 // (`name`, `description`, `category`). Auth-gated. Answers the `{ error, success, // value }` envelope carrying the new club's details — not a bare club. - .post('/club/create', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .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 club’s Creator. Answers the `{ error, success, value }` envelope carrying the new', + 'club’s full details — not a bare club.', + ].join(' '), + security: AUTHED, + requestBody: form(CreateClubRequest, 'The new club’s fields'), + responses: { + 200: json(ClubDetailsEnvelope, 'The new club’s 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) + if (id === null) return c.body(null, 401) - const body = (await c.req.parseBody().catch(() => ({}))) as Record - // The client sends lowercase field names; accept either casing. - const field = (name: string): string | undefined => { - const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) - const v = key === undefined ? undefined : body[key] - return typeof v === 'string' ? v : undefined - } - const int = (v: string | undefined): number | undefined => { - const n = v === undefined ? Number.NaN : Number.parseInt(v, 10) - return Number.isNaN(n) ? undefined : n - } + const body = (await c.req.parseBody().catch(() => ({}))) as Record + // The client sends lowercase field names; accept either casing. + const field = (name: string): string | undefined => { + const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) + const v = key === undefined ? undefined : body[key] + return typeof v === 'string' ? v : undefined + } + const int = (v: string | undefined): number | undefined => { + const n = v === undefined ? Number.NaN : Number.parseInt(v, 10) + return Number.isNaN(n) ? undefined : n + } - const name = field('name')?.trim() ?? '' - const description = field('description') ?? '' - if (name === '') return clubError(c, 'You must enter a name for your club.') - if (!isValidClubName(name)) { - return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.') - } - if ([...name].length > MAX_CLUB_NAME_LENGTH) { - return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`) - } - // The per-account cap, checked after the cheap validations so a rejected name - // costs no extra D1 read. - const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT) - if (maxClubs > 0 && (await countClubsByCreator(c.env.DB, id)) >= maxClubs) { - logger.info('club create rejected: per-account club limit', { accountId: id }) - return clubError(c, `You can only have ${maxClubs} clubs.`) - } + const name = field('name')?.trim() ?? '' + const description = field('description') ?? '' + if (name === '') return clubError(c, 'You must enter a name for your club.') + if (!isValidClubName(name)) { + return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.') + } + if ([...name].length > MAX_CLUB_NAME_LENGTH) { + return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`) + } + // The per-account cap, checked after the cheap validations so a rejected name + // costs no extra D1 read. + const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT) + if (maxClubs > 0 && (await countClubsByCreator(c.env.DB, id)) >= maxClubs) { + logger.info('club create rejected: per-account club limit', { accountId: id }) + return clubError(c, `You can only have ${maxClubs} clubs.`) + } - const club = await createClub(c.env.DB, id, { - name, - description, - // An unset category files the club under Social, as the reference does. - category: field('category')?.trim() || 'Social', - visibility: parseVisibility(field('visibility')), - joinability: parseJoinability(field('joinability')), - allowJuniors: parseFormBool(field('allowJuniors')), - mainImageName: field('mainImageName'), - // ClubType is deliberately not taken from the client: a player-created club - // is always a regular one. Letting the client pick would let it mint a - // subscription club (type 1), which is excluded from every club listing. - minLevel: int(field('minLevel')), - }) - return c.json({ - error: '', - success: true, - value: await getClubDetails(c.env.DB, club, id), - }) - }) + const club = await createClub(c.env.DB, id, { + name, + description, + // An unset category files the club under Social, as the reference does. + category: field('category')?.trim() || 'Social', + visibility: parseVisibility(field('visibility')), + joinability: parseJoinability(field('joinability')), + allowJuniors: parseFormBool(field('allowJuniors')), + mainImageName: field('mainImageName'), + // ClubType is deliberately not taken from the client: a player-created club + // is always a regular one. Letting the client pick would let it mint a + // subscription club (type 1), which is excluded from every club listing. + minLevel: int(field('minLevel')), + }) + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, club, id), + }) + } + ) // 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`) — @@ -369,141 +690,256 @@ const app = new Hono() // // `/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. - .on('PUT', ['/club/:clubId{[0-9]+}/modifydetails', '/club/:clubId{[0-9]+}/modify'], async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .on( + 'PUT', + ['/club/:clubId{[0-9]+}/modifydetails', '/club/:clubId{[0-9]+}/modify'], + describeRoute({ + tags: ['Clubs'], + summary: 'Edit a club’s details', + description: [ + 'The client PUTs a form of just the fields it’s 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 club’s 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 can’t 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 club’s 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) + if (id === null) return c.body(null, 401) - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const club = await getClub(c.env.DB, clubId) - if (club === null) return c.notFound() + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() - // Editing details is a co-owner power — plain members and moderators can't. - const membership = await getMembership(c.env.DB, clubId, id) - if (membership < ClubMembershipType.Coowner) { - return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) - } - - // `all: true` so a repeated `customTags` field arrives as a list. - const body = (await c.req.parseBody({ all: true }).catch(() => ({}))) as Record - const field = (name: string): string | undefined => { - const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) - const v = key === undefined ? undefined : body[key] - const first = Array.isArray(v) ? v[0] : v - return typeof first === 'string' ? first : undefined - } - const list = (name: string): string[] | undefined => { - const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) - if (key === undefined) return undefined - const v = body[key] - const values = Array.isArray(v) ? v : [v] - return values.filter((t): t is string => typeof t === 'string') - } - const int = (v: string | undefined): number | undefined => { - const n = v === undefined ? Number.NaN : Number.parseInt(v, 10) - return Number.isNaN(n) ? undefined : n - } - - // An empty name/description means "unchanged", not "clear it" — the reference - // only applies these when non-empty. - const name = field('name')?.trim() || undefined - if (name !== undefined) { - if (!isValidClubName(name)) { - return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.') + // Editing details is a co-owner power — plain members and moderators can't. + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) } - if ([...name].length > MAX_CLUB_NAME_LENGTH) { - return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`) + + // `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 field = (name: string): string | undefined => { + const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) + const v = key === undefined ? undefined : body[key] + const first = Array.isArray(v) ? v[0] : v + return typeof first === 'string' ? first : undefined } + const list = (name: string): string[] | undefined => { + const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase()) + if (key === undefined) return undefined + const v = body[key] + const values = Array.isArray(v) ? v : [v] + return values.filter((t): t is string => typeof t === 'string') + } + const int = (v: string | undefined): number | undefined => { + const n = v === undefined ? Number.NaN : Number.parseInt(v, 10) + return Number.isNaN(n) ? undefined : n + } + + // An empty name/description means "unchanged", not "clear it" — the reference + // only applies these when non-empty. + const name = field('name')?.trim() || undefined + if (name !== undefined) { + if (!isValidClubName(name)) { + return clubError(c, 'Club names can only use letters, numbers, and basic punctuation.') + } + if ([...name].length > MAX_CLUB_NAME_LENGTH) { + return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`) + } + } + + const updated = await updateClub(c.env.DB, clubId, { + name, + description: field('description') || undefined, + category: field('category')?.trim() || undefined, + visibility: parseVisibility(field('visibility')), + joinability: parseJoinability(field('joinability')), + allowJuniors: parseFormBool(field('allowJuniors')), + mainImageName: field('mainImageName') || undefined, + minLevel: int(field('minLevel')), + customTags: list('customTags'), + }) + if (updated === null) return c.notFound() + + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, updated, id), + }) } - - const updated = await updateClub(c.env.DB, clubId, { - name, - description: field('description') || undefined, - category: field('category')?.trim() || undefined, - visibility: parseVisibility(field('visibility')), - joinability: parseJoinability(field('joinability')), - allowJuniors: parseFormBool(field('allowJuniors')), - mainImageName: field('mainImageName') || undefined, - minLevel: int(field('minLevel')), - customTags: list('customTags'), - }) - if (updated === null) return c.notFound() - - return c.json({ - error: '', - success: true, - value: await getClubDetails(c.env.DB, updated, id), - }) - }) + ) // 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 // 0). Unlike create/modifydetails this one is *not* enveloped: the reference writes // the details object straight out. - .get('/club/:clubId{[0-9]+}/details', async (c) => { - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const club = await getClub(c.env.DB, clubId) - if (club === null) return c.notFound() - const id = await authedId(c) - return c.json(await getClubDetails(c.env.DB, club, id)) - }) + .get( + '/club/:clubId{[0-9]+}/details', + describeRoute({ + tags: ['Clubs'], + summary: 'A club’s full details', + description: [ + 'The club plus its custom tags, the per-tier permissions, its gallery, and the', + 'caller’s 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 club’s details (not enveloped)'), + 404: { description: 'No such club' }, + }, + }), + async (c) => { + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + const id = await authedId(c) + 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 // (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. - .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, // 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 // tier first). Public, and an unknown club is an empty list. Answers the envelope. - .get('/club/:clubId{[0-9]+}/members', async (c) => { - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const raw = c.req.query('membershipType') - const membershipType = raw === undefined ? Number.NaN : Number.parseInt(raw, 10) + .get( + '/club/:clubId{[0-9]+}/members', + describeRoute({ + tags: ['Membership'], + summary: 'A club’s 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 club’s membership rows') }, + }), + async (c) => { + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const raw = c.req.query('membershipType') + const membershipType = raw === undefined ? Number.NaN : Number.parseInt(raw, 10) - const members = await getClubMembers( - c.env.DB, - clubId, - Number.isNaN(membershipType) ? undefined : membershipType, - c.req.query('sortBy') - ) - return c.json({ error: '', success: true, value: members }) - }) + const members = await getClubMembers( + c.env.DB, + clubId, + Number.isNaN(membershipType) ? undefined : membershipType, + c.req.query('sortBy') + ) + return c.json({ error: '', success: true, value: members }) + } + ) // 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. // Same rules as the other club edits: co-owner or above, and the details envelope. - .put('/club/:clubId{[0-9]+}/minlevel', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .put( + '/club/:clubId{[0-9]+}/minlevel', + describeRoute({ + tags: ['Clubs'], + summary: 'Set the club’s 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 club’s 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) + if (id === null) return c.body(null, 401) - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const club = await getClub(c.env.DB, clubId) - if (club === null) return c.notFound() + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() - const membership = await getMembership(c.env.DB, clubId, id) - if (membership < ClubMembershipType.Coowner) { - return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const key = Object.keys(body).find((k) => k.toLowerCase() === 'minlevel') + const minLevel = Number.parseInt( + typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '', + 10 + ) + if (Number.isNaN(minLevel) || minLevel < 0) return clubError(c, 'Invalid minLevel.') + + const updated = await updateClub(c.env.DB, clubId, { minLevel }) + if (updated === null) return c.notFound() + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, updated, id), + }) } - - const body = (await c.req.parseBody().catch(() => ({}))) as Record - const key = Object.keys(body).find((k) => k.toLowerCase() === 'minlevel') - const minLevel = Number.parseInt( - typeof body[key ?? ''] === 'string' ? String(body[key ?? '']) : '', - 10 - ) - if (Number.isNaN(minLevel) || minLevel < 0) return clubError(c, 'Invalid minLevel.') - - const updated = await updateClub(c.env.DB, clubId, { minLevel }) - if (updated === null) return c.notFound() - return c.json({ - error: '', - success: true, - value: await getClubDetails(c.env.DB, updated, id), - }) - }) + ) // 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 @@ -514,81 +950,147 @@ const app = new Hono() // 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 // remembering to send an empty PUT. - .on(['PUT', 'DELETE'], '/club/:clubId{[0-9]+}/clubhouse', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .on( + ['PUT', 'DELETE'], + '/club/:clubId{[0-9]+}/clubhouse', + describeRoute({ + tags: ['Clubs'], + summary: 'Set or clear the club’s 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” doesn’t 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 club’s 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) + if (id === null) return c.body(null, 401) - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const club = await getClub(c.env.DB, clubId) - if (club === null) return c.notFound() + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() - const membership = await getMembership(c.env.DB, clubId, id) - if (membership < ClubMembershipType.Coowner) { - return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) - } - - let roomId: number | null = null - if (c.req.method !== 'DELETE') { - const body = (await c.req.parseBody().catch(() => ({}))) as Record - const key = Object.keys(body).find((k) => k.toLowerCase() === 'roomid') - const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : '' - if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) { - return clubError(c, 'Invalid roomId.') + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) } - roomId = raw === '' ? null : Number.parseInt(raw, 10) - } - const updated = await updateClub(c.env.DB, clubId, { clubhouseRoomId: roomId }) - if (updated === null) return c.notFound() - return c.json({ - error: '', - success: true, - value: await getClubDetails(c.env.DB, updated, id), - }) - }) + let roomId: number | null = null + if (c.req.method !== 'DELETE') { + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const key = Object.keys(body).find((k) => k.toLowerCase() === 'roomid') + const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : '' + if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) { + return clubError(c, 'Invalid roomId.') + } + roomId = raw === '' ? null : Number.parseInt(raw, 10) + } + + const updated = await updateClub(c.env.DB, clubId, { clubhouseRoomId: roomId }) + if (updated === null) return c.notFound() + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, updated, id), + }) + } + ) // 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 — // 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`. - .get('/club/:clubId{[0-9]+}/mainimage', async (c) => { - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const club = await getClub(c.env.DB, clubId) - if (club === null) return c.notFound() - const id = await authedId(c) - return c.json({ - error: '', - success: true, - value: await getClubDetails(c.env.DB, club, id), - }) - }) - .put('/club/:clubId{[0-9]+}/mainimage', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) - - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const club = await getClub(c.env.DB, clubId) - if (club === null) return c.notFound() - - const membership = await getMembership(c.env.DB, clubId, id) - if (membership < ClubMembershipType.Coowner) { - return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + .get( + '/club/:clubId{[0-9]+}/mainimage', + describeRoute({ + tags: ['Images'], + summary: 'Read the club’s 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 club’s details, carrying MainImageName'), + 404: { description: 'No such club' }, + }, + }), + async (c) => { + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() + const id = await authedId(c) + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, club, id), + }) } + ) + .put( + '/club/:clubId{[0-9]+}/mainimage', + describeRoute({ + tags: ['Images'], + summary: 'Set the club’s main image', + description: [ + 'Sets the main image from an uploaded image’s `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 image’s name'), + responses: { + 200: json(ClubDetailsEnvelope, 'The updated club’s 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) + if (id === null) return c.body(null, 401) - const body = (await c.req.parseBody().catch(() => ({}))) as Record - const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename') - const imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : '' - if (imageName === '') return clubError(c, 'imageName is required.') + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() - const updated = await updateClub(c.env.DB, clubId, { mainImageName: imageName }) - if (updated === null) return c.notFound() - return c.json({ - error: '', - success: true, - value: await getClubDetails(c.env.DB, updated, id), - }) - }) + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename') + const imageName = + typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : '' + if (imageName === '') return clubError(c, 'imageName is required.') + + const updated = await updateClub(c.env.DB, clubId, { mainImageName: imageName }) + if (updated === null) return c.notFound() + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, updated, id), + }) + } + ) // 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 @@ -599,67 +1101,139 @@ const app = new Hono() // 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 // 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) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .on( + ['PUT', 'DELETE'], + '/club/:clubId{[0-9]+}/additionalimage/:index{[0-9]+}', + describeRoute({ + tags: ['Images'], + summary: 'Set or remove one of the club’s 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 there’s never a blank slot. DELETE ignores any body (so it can’t', + '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 client’s 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 (0–2)', + schema: { type: 'string' }, + }, + ], + requestBody: form(ImageNameRequest, 'The uploaded image’s name (PUT only)'), + responses: { + 200: json(ClubDetailsEnvelope, 'The updated club’s details'), + 400: json(ErrorEnvelope, 'The index is past the club’s gallery slots'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(ErrorEnvelope, 'Below co-owner'), + 404: { description: 'No such club' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const club = await getClub(c.env.DB, clubId) - if (club === null) return c.notFound() + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() - const membership = await getMembership(c.env.DB, clubId, id) - if (membership < ClubMembershipType.Coowner) { - return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Coowner) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + const index = Number.parseInt(c.req.param('index'), 10) + if (index >= MAX_ADDITIONAL_IMAGES) { + return clubError(c, `A club has ${MAX_ADDITIONAL_IMAGES} additional image slots (0-based).`) + } + + let imageName = '' + if (c.req.method !== 'DELETE') { + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename') + imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : '' + } + + const updated = await setClubAdditionalImage(c.env.DB, clubId, index, imageName) + if (updated === null) return c.notFound() + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, updated, id), + }) } - - const index = Number.parseInt(c.req.param('index'), 10) - if (index >= MAX_ADDITIONAL_IMAGES) { - return clubError(c, `A club has ${MAX_ADDITIONAL_IMAGES} additional image slots (0-based).`) - } - - let imageName = '' - if (c.req.method !== 'DELETE') { - const body = (await c.req.parseBody().catch(() => ({}))) as Record - const key = Object.keys(body).find((k) => k.toLowerCase() === 'imagename') - imageName = typeof body[key ?? ''] === 'string' ? (body[key ?? ''] as string).trim() : '' - } - - const updated = await setClubAdditionalImage(c.env.DB, clubId, index, imageName) - if (updated === null) return c.notFound() - return c.json({ - error: '', - success: true, - value: await getClubDetails(c.env.DB, updated, id), - }) - }) + ) // A single club by id. 404 when the club isn't in the DB. Public. - .get('/club/:clubId{[0-9]+}', async (c) => { - const club = await getClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10)) - return club ? c.json(club) : c.notFound() - }) + .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)) + return club ? c.json(club) : c.notFound() + } + ) // 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 // 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 // left to return. - .delete('/club/:clubId{[0-9]+}', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .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 who’d set it. The creator only — 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. The envelope’s `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 club’s creator'), + 404: { description: 'No such club' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const club = await getClub(c.env.DB, clubId) - if (club === null) return c.notFound() + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const club = await getClub(c.env.DB, clubId) + if (club === null) return c.notFound() - const membership = await getMembership(c.env.DB, clubId, id) - if (membership < ClubMembershipType.Creator) { - return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + const membership = await getMembership(c.env.DB, clubId, id) + if (membership < ClubMembershipType.Creator) { + return c.json({ error: 'Insufficient permissions.', success: false, value: null }, 403) + } + + await deleteClub(c.env.DB, clubId) + return c.json({ error: '', success: true, value: null }) } - - await deleteClub(c.env.DB, clubId) - 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 // request. What it does depends on the club's Joinability: an Open club takes the @@ -667,27 +1241,51 @@ const app = new Hono() // 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`. - .put('/club/:clubId{[0-9]+}/members/requesttojoin', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .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 club’s 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 club’s details, with the caller’s 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) + if (id === null) return c.body(null, 401) - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const outcome = await requestToJoinClub(c.env.DB, clubId, id) - if (outcome === null) return c.notFound() + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const outcome = await requestToJoinClub(c.env.DB, clubId, id) + if (outcome === null) return c.notFound() - if (outcome.result === 'inviteOnly') { - return clubError(c, 'This club is invite only.') + if (outcome.result === 'inviteOnly') { + return clubError(c, 'This club is invite only.') + } + if (outcome.result === 'banned') { + return c.json({ error: 'You are banned from this club.', success: false, value: null }, 403) + } + + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, outcome.club, id), + }) } - if (outcome.result === 'banned') { - return c.json({ error: 'You are banned from this club.', success: false, value: null }, 403) - } - - return c.json({ - error: '', - success: true, - value: await getClubDetails(c.env.DB, outcome.club, id), - }) - }) + ) // 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 @@ -695,57 +1293,159 @@ const app = new Hono() // 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 // `MyMembershipType` drop to 0 (or stay at -1 for a banned account). - .post('/club/:clubId{[0-9]+}/members/leave', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) + .post( + '/club/:clubId{[0-9]+}/members/leave', + describeRoute({ + tags: ['Membership'], + summary: 'Leave a club', + description: [ + 'No body, like requesttojoin. Idempotent (leaving a club you’re not in is a no-op),', + 'and it also withdraws a pending request; a ban is preserved, since you can’t clear', + 'one 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', + '`MyMembershipType` drop to 0 (or stay at -1 for a banned account).', + ].join(' '), + security: AUTHED, + parameters: [CLUB_ID_PARAM], + responses: { + 200: json(ClubDetailsEnvelope, 'The club’s details, with the caller’s membership gone'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(ErrorEnvelope, 'The creator can’t leave — delete the club instead'), + 404: { description: 'No such club' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) - const clubId = Number.parseInt(c.req.param('clubId'), 10) - const outcome = await leaveClub(c.env.DB, clubId, id) - if (outcome === null) return c.notFound() - if (outcome.result === 'creator') { - return c.json( - { - error: 'You created this club — delete it instead of leaving.', - success: false, - value: null, - }, - 403 - ) + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const outcome = await leaveClub(c.env.DB, clubId, id) + if (outcome === null) return c.notFound() + if (outcome.result === 'creator') { + return c.json( + { + error: 'You created this club — delete it instead of leaving.', + success: false, + value: null, + }, + 403 + ) + } + + return c.json({ + error: '', + success: true, + value: await getClubDetails(c.env.DB, outcome.club, id), + }) } - - return c.json({ - error: '', - success: true, - value: await getClubDetails(c.env.DB, outcome.club, id), - }) - }) + ) // Join / leave a club (auth-gated, idempotent). Both return the club with its // refreshed MemberCount; 404 when the club doesn't exist. - .post('/club/:clubId{[0-9]+}/join', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) - const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id) - return club ? c.json(club) : c.notFound() - }) + .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', + 'can’t 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) + if (id === null) return c.body(null, 401) + const club = await joinClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id) + return club ? c.json(club) : c.notFound() + } + ) // 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. - .post('/club/:clubId{[0-9]+}/leave', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) - const outcome = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id) - if (outcome === null) return c.notFound() - if (outcome.result === 'creator') { - return c.json( - { - error: 'You created this club — delete it instead of leaving.', - success: false, - value: null, - }, - 403 - ) + .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 can’t disagree about who’s 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 can’t leave — delete the club instead'), + 404: { description: 'No such club' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + const outcome = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id) + if (outcome === null) return c.notFound() + if (outcome.result === 'creator') { + return c.json( + { + error: 'You created this club — delete it instead of leaving.', + success: false, + value: null, + }, + 403 + ) + } + 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 player’s 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 club’s 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 worker’s `POST /connect/token`.', + }, + }, + }, + }, + }) + ) +) export default app diff --git a/apps/clubs/src/openapi.ts b/apps/clubs/src/openapi.ts new file mode 100644 index 0000000..af989f7 --- /dev/null +++ b/apps/clubs/src/openapi.ts @@ -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 club’s 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 caller’s 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 announcement’s 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 club’s 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(), +}) diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index fbc48ca..2b95595 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -1306,4 +1306,73 @@ describe('clubs endpoints', () => { // Deleting twice 404s rather than reporting success. 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> + } + 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() + } + }) }) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 7cabbf2..a5df436 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -382,9 +382,10 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Avatar'], summary: 'The player’s avatar items', - description: - 'The items the player has bought (from buyItem, in the inventory table) prepended ' + + description: [ + '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.', + ].join(' '), security: AUTHED, responses: { 200: json(JsonArray, 'Owned items followed by the default catalog'), @@ -407,10 +408,11 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Avatar'], summary: 'Owned custom avatar items', - description: - '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 ' + + description: [ + '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', 'avatar items”.', + ].join(' '), security: AUTHED, responses: { 200: json(CustomAvatarItemsResponse, 'Paginated results (empty for now)'), @@ -462,9 +464,10 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Avatar'], summary: 'The player’s own avatar', - description: - 'The avatar JSON blob stored on the account row, or the default outfit when none is ' + + description: [ + 'The avatar JSON blob stored on the account row, or the default outfit when none is', 'saved (the client NREs on an empty OutfitSelections).', + ].join(' '), security: AUTHED, responses: { 200: json(JsonObject, 'The stored avatar blob (or the default)'), @@ -565,11 +568,12 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Avatar'], summary: 'Save an outfit into a slot', - description: - '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 ' + - 'client’s own serializer. A missing/non-integer `Slot` is a 400 (guessing would ' + + description: [ + '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', + 'client’s own serializer. A missing/non-integer `Slot` is a 400 (guessing would', 'silently overwrite another outfit).', + ].join(' '), security: AUTHED, requestBody: jsonBody(SaveOutfitRequest, 'The outfit, with a target Slot'), responses: { @@ -601,9 +605,10 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Gifts'], summary: 'Pending gift boxes', - description: - 'The player’s unopened gift boxes from their purchases (and, later, from other ' + + description: [ + 'The player’s unopened gift boxes from their purchases (and, later, from other', 'players). The item was already granted at purchase, so an unopened box is cosmetic.', + ].join(' '), security: AUTHED, responses: { 200: json(JsonArray, 'Unopened gift boxes (empty when none)'), @@ -635,12 +640,13 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Gifts'], summary: 'Open (consume) a gift box', - description: - '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, ' + - 'or a box already gone — because the client parses it to finish opening the box. The ' + - 'delete is scoped to the caller; opening someone else’s box is 403. Also served by ' + + description: [ + '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,', + 'or a box already gone — because the client parses it to finish opening the box. The', + 'delete is scoped to the caller; opening someone else’s box is 403. Also served by', 'the `api` worker.', + ].join(' '), requestBody: form(ConsumeGiftRequest, 'The gift-box id'), responses: { 200: json(ConsumeEnvelope, 'Success envelope'), @@ -680,9 +686,10 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Avatar'], summary: 'Another player’s avatar (render subset)', - description: - 'The public render subset used to draw another player’s avatar. No auth. Falls back ' + + description: [ + 'The public render subset used to draw another player’s avatar. No auth. Falls back', 'to the default outfit when the player hasn’t saved one.', + ].join(' '), parameters: [ { name: 'id', @@ -726,10 +733,11 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Equipment'], summary: 'Update owned equipment', - description: - 'Applies the posted `Favorited` flags to the caller’s owned equipment, matched by ' + - '`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller ' + + description: [ + 'Applies the posted `Favorited` flags to the caller’s owned equipment, matched by', + '`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller', 'doesn’t own is silently skipped. Empty body on success.', + ].join(' '), security: AUTHED, requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'), responses: { @@ -782,10 +790,11 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Consumables'], summary: 'Unlocked consumables', - description: - 'The consumables the player has bought (from buyItem, in the consumable table), ' + - 'grouped by item into the unlocked-consumable DTO (Ids/CreatedAts per instance, ' + + description: [ + 'The consumables the player has bought (from buyItem, in the consumable table),', + 'grouped by item into the unlocked-consumable DTO (Ids/CreatedAts per instance,', 'Count their sum). [] when they’ve bought none.', + ].join(' '), security: AUTHED, responses: { 200: json(JsonArray, 'Grouped unlocked consumables (empty when none)'), @@ -808,10 +817,11 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Consumables'], summary: 'Consume a quantity of an owned consumable', - description: - 'Reduces the given consumable instance’s count by `DeltaCount` (default 1), deleting ' + - 'the row at zero. Scoped to the caller. Pushes a ConsumableMappingRemoved socket ' + + description: [ + 'Reduces the given consumable instance’s count by `DeltaCount` (default 1), deleting', + 'the row at zero. Scoped to the caller. Pushes a ConsumableMappingRemoved socket', 'notification. Envelope mirrors the gift-consume ack.', + ].join(' '), security: AUTHED, requestBody: jsonBody(ConsumeConsumableRequest, 'The consumable id and delta'), responses: { @@ -848,10 +858,11 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Storefront'], summary: 'Currency balance', - description: - 'The player’s balance in a CurrencyType (the client fetches `/balance/2`, ' + - 'RecCenterTokens, on load). A first read seeds their starting balance. An unknown or ' + + description: [ + 'The player’s balance in a CurrencyType (the client fetches `/balance/2`,', + 'RecCenterTokens, on load). A first read seeds their starting balance. An unknown or', 'non-account currency returns a 0 balance rather than 404.', + ].join(' '), security: AUTHED, parameters: [ { @@ -932,12 +943,13 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Storefront'], summary: 'Buy a storefront item', - description: - 'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice` ' + - '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 ' + - 'player, but the caller always pays. `Balance` in the response is the CHANGE (negated ' + + description: [ + 'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`', + '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', + 'player, but the caller always pays. `Balance` in the response is the CHANGE (negated', 'price), not the new total. Pushes a StorefrontBalanceUpdate socket notification.', + ].join(' '), security: AUTHED, requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'), responses: { @@ -1136,10 +1148,11 @@ const app = new Hono({ strict: false }) describeRoute({ tags: ['Econ'], summary: 'Report weekly-challenge progress', - description: - '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 ' + + description: [ + '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', 'client gets a well-formed body.', + ].join(' '), requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'), 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', 'the `api` worker. Storefront catalogs are static assets (`sf{N}.json`); balances,', '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'), }, servers: [{ url: 'https://econ.recflare.net', description: 'Production' }], diff --git a/apps/img/package.json b/apps/img/package.json index 2164c36..1841da9 100644 --- a/apps/img/package.json +++ b/apps/img/package.json @@ -18,8 +18,13 @@ "dependencies": { "@cf-wasm/photon": "^0.3.6", "@repo/hono-helpers": "workspace:*", + "@standard-community/standard-json": "0.3.5", + "@standard-community/standard-openapi": "0.2.9", "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": { "@cloudflare/vitest-pool-workers": "0.16.20", diff --git a/apps/img/src/img.app.ts b/apps/img/src/img.app.ts index d92527a..8779a54 100644 --- a/apps/img/src/img.app.ts +++ b/apps/img/src/img.app.ts @@ -1,8 +1,11 @@ import { crop, PhotonImage, resize, SamplingFilter } from '@cf-wasm/photon' import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' 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' @@ -197,16 +200,144 @@ const app = new Hono() .onError(withOnError()) .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' }) + ) - // Stream an image straight from the R2 bucket by key, e.g. - // `GET /DefaultProfileImage.jpg`. The key may contain slashes for nested - // objects. Supports conditional requests via If-None-Match. - // - // 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 - // full body, so the object is buffered. - .get('/:key{.+}', async (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. +// +// 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. +// `GET /DefaultProfileImage.jpg`. The key may contain slashes for nested +// objects. Supports conditional requests via If-None-Match. +// +// 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 +// full body, so the object is buffered. +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=`. 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') if (key.includes('..')) return c.body(null, 400) @@ -255,6 +386,7 @@ const app = new Hono() } return new Response(object.body, { headers }) - }) + } +) export default app diff --git a/apps/img/src/openapi.ts b/apps/img/src/openapi.ts new file mode 100644 index 0000000..2fe3e2f --- /dev/null +++ b/apps/img/src/openapi.ts @@ -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'), +}) diff --git a/apps/img/src/test/integration/api.test.ts b/apps/img/src/test/integration/api.test.ts index 0beafda..43f34bc 100644 --- a/apps/img/src/test/integration/api.test.ts +++ b/apps/img/src/test/integration/api.test.ts @@ -226,4 +226,36 @@ describe('img endpoints', () => { const ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, signature, body) 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> + } + 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) + }) }) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 8565056..1939bcb 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -424,9 +424,10 @@ const app = new Hono() describeRoute({ tags: ['Presence'], summary: 'Login ack (no-op)', - description: - 'A no-op ack. Must NOT touch presence — the client fires this going online, and ' + + description: [ + '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.', + ].join(' '), responses: { 200: EMPTY_OK }, }), (c) => c.body(null, 200) @@ -456,11 +457,12 @@ const app = new Hono() describeRoute({ tags: ['Presence'], summary: 'Clear presence on logout', - description: - 'Clears the player’s presence so they read offline immediately and the instance ' + - '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 ' + + description: [ + 'Clears the player’s presence so they read offline immediately and the instance', + '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', 'bootstrap isn’t wiped. An unauthenticated logout is also a no-op.', + ].join(' '), responses: { 200: EMPTY_OK }, }), async (c) => { @@ -487,9 +489,10 @@ const app = new Hono() describeRoute({ tags: ['Presence'], summary: 'Disconnect notification (no-op ack)', - description: - 'Posted when the client drops a room. Not acted on — presence is cleared by logout ' + + description: [ + 'Posted when the client drops a room. Not acted on — presence is cleared by logout', 'and otherwise expires on its TTL.', + ].join(' '), responses: { 200: EMPTY_OK }, }), (c) => c.body(null, 200) @@ -500,9 +503,10 @@ const app = new Hono() describeRoute({ tags: ['Presence'], summary: 'Batch player presence lookup', - description: - 'Returns each requested player’s presence. `id` is repeatable and each value may ' + + description: [ + 'Returns each requested player’s presence. `id` is repeatable and each value may', 'be a comma-separated list. With no ids, serves a single default (online) player.', + ].join(' '), parameters: [ { name: 'id', @@ -536,11 +540,12 @@ const app = new Hono() describeRoute({ tags: ['Presence'], summary: 'Presence heartbeat', - description: - '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 ' + - 'TTL is close to lapsing, so a still player isn’t written on every beat. With no ' + + description: [ + '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', + 'TTL is close to lapsing, so a still player isn’t written on every beat. With no', 'stored presence the player isn’t in a room yet (roomInstance null, isOnline false).', + ].join(' '), security: AUTHED, requestBody: jsonBody( HeartbeatRequestSchema, @@ -618,9 +623,10 @@ const app = new Hono() describeRoute({ tags: ['Presence'], summary: 'Set status visibility', - description: - 'Updates the stored presence’s status visibility. No-op when the player has no live ' + + description: [ + 'Updates the stored presence’s status visibility. No-op when the player has no live', 'presence or an unauthenticated/invalid token — always acks 200.', + ].join(' '), requestBody: form(StatusVisibilityRequest, 'The statusVisibility value'), responses: { 200: EMPTY_OK }, }), @@ -650,10 +656,11 @@ const app = new Hono() describeRoute({ tags: ['Navigation'], summary: 'Go to a room', - description: - 'Resolves the room (numeric id or name; `dormroom` → the player’s personal dorm), ' + - 'finds or creates an instance, and stores it as presence. `JoinMode=2` requests a ' + + description: [ + 'Resolves the room (numeric id or name; `dormroom` → the player’s personal dorm),', + 'finds or creates an instance, and stores it as presence. `JoinMode=2` requests a', 'private instance.', + ].join(' '), security: AUTHED, requestBody: form(JoinModeRequest, 'Optional JoinMode'), parameters: [ @@ -693,10 +700,11 @@ const app = new Hono() describeRoute({ tags: ['Navigation'], summary: 'Matchmake with no target (preserve or dorm)', - description: - 'Returns the player’s current instance if they have one (so Orientation isn’t warped ' + - 'away), else their personal dorm when authed, or the shared offline dorm when not. ' + + description: [ + 'Returns the player’s current instance if they have one (so Orientation isn’t warped', + 'away), else their personal dorm when authed, or the shared offline dorm when not.', 'Not auth-gated.', + ].join(' '), responses: { 200: json(MatchmakeResponse, 'Current, personal-dorm, or offline-dorm instance'), }, @@ -729,10 +737,11 @@ const app = new Hono() describeRoute({ tags: ['Navigation'], summary: 'Matchmake into a club’s clubhouse', - description: - '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 ' + + description: [ + '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', 'club is unknown, has no clubhouse set, or the caller isn’t a member.', + ].join(' '), security: AUTHED, requestBody: form(JoinModeRequest, 'Optional JoinMode'), parameters: [ @@ -788,9 +797,10 @@ const app = new Hono() describeRoute({ tags: ['Navigation'], summary: 'Matchmake into a specific subroom', - description: - 'Enters a specific subroom (scene) of a room. The subroom decides the scene loaded ' + + description: [ + '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 room’s first.', + ].join(' '), security: AUTHED, requestBody: form(JoinModeRequest, 'Optional JoinMode'), parameters: [ @@ -833,9 +843,10 @@ const app = new Hono() describeRoute({ tags: ['Navigation'], summary: 'Matchmake into a room (default subroom)', - description: - 'The 2023 client’s two-segment matchmake. Resolves the room from D1 so the instance ' + + description: [ + 'The 2023 client’s two-segment matchmake. Resolves the room from D1 so the instance', 'carries its real scene, and stores it as presence.', + ].join(' '), security: AUTHED, requestBody: form(JoinModeRequest, 'Optional JoinMode'), parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }], @@ -859,10 +870,11 @@ const app = new Hono() describeRoute({ tags: ['Navigation'], summary: 'Matchmake into a room by id or name', - description: - 'Single-segment matchmake. `dorm` → the player’s personal dorm; otherwise resolves ' + - 'the room by id or name. (Note: this dorm keyword is `dorm`, while goto/room uses ' + + description: [ + 'Single-segment matchmake. `dorm` → the player’s personal dorm; otherwise resolves', + 'the room by id or name. (Note: this dorm keyword is `dorm`, while goto/room uses', '`dormroom`.)', + ].join(' '), security: AUTHED, requestBody: form(JoinModeRequest, 'Optional JoinMode'), parameters: [ @@ -902,9 +914,10 @@ const app = new Hono() describeRoute({ tags: ['Navigation'], summary: 'Go to the dorm', - description: - 'Authed → the player’s personal dorm (persisted as presence); unauthenticated → the ' + + description: [ + 'Authed → the player’s personal dorm (persisted as presence); unauthenticated → the', 'shared offline dorm. Unlike matchmake/none, this always goes to the dorm.', + ].join(' '), responses: { 200: json(MatchmakeResponse, 'The dorm instance') }, }), async (c) => { @@ -958,9 +971,10 @@ const app = new Hono() describeRoute({ tags: ['Room instance'], summary: 'Set instance in-progress flag', - description: - 'The room owner flips the instance’s in-progress flag when a session starts (e.g. a ' + + description: [ + 'The room owner flips the instance’s in-progress flag when a session starts (e.g. a', 'round begins). Body is `inProgress=True|False`.', + ].join(' '), security: AUTHED, requestBody: form(InProgressRequest, 'The inProgress flag'), parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], @@ -996,9 +1010,10 @@ const app = new Hono() describeRoute({ tags: ['Room instance'], summary: 'A room’s live instances', - description: - 'The owner’s view of active sessions of their room. Auth-gated and gated to the ' + + description: [ + 'The owner’s view of active sessions of their room. Auth-gated and gated to the', 'room’s creator or a co-owner (403 otherwise). Unknown room → 404.', + ].join(' '), security: AUTHED, parameters: [ { @@ -1096,12 +1111,6 @@ app.get( '`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', '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'), }, servers: [{ url: 'https://match.recflare.net', description: 'Production' }], diff --git a/apps/playersettings/package.json b/apps/playersettings/package.json index 66d6ed6..2c23c9d 100644 --- a/apps/playersettings/package.json +++ b/apps/playersettings/package.json @@ -17,8 +17,13 @@ "dependencies": { "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", + "@standard-community/standard-json": "0.3.5", + "@standard-community/standard-openapi": "0.2.9", "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": { "@cloudflare/vitest-pool-workers": "0.16.20", diff --git a/apps/playersettings/src/openapi.ts b/apps/playersettings/src/openapi.ts new file mode 100644 index 0000000..33153ba --- /dev/null +++ b/apps/playersettings/src/openapi.ts @@ -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(), + }) + ), +]) diff --git a/apps/playersettings/src/playersettings.app.ts b/apps/playersettings/src/playersettings.app.ts index f49a19d..5bd2cab 100644 --- a/apps/playersettings/src/playersettings.app.ts +++ b/apps/playersettings/src/playersettings.app.ts @@ -1,14 +1,34 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' 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 { DEFAULT_SETTINGS } from './default-settings' +import { + AUTHED, + formOrJson, + HealthResponse, + json, + PlayerSettingEntry, + SettingFormWrite, + SettingJsonWrite, + UNAUTHORIZED_RESPONSE, +} from './openapi' import type { Context } from 'hono' 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). * Returns `null` when the header is missing, the token is invalid, or the `sub` @@ -72,41 +92,126 @@ const app = new Hono() .onError(withOnError()) .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 per-player KV map; seeds (and persists) the defaults on first read. - .get('/playersettings', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .get( + '/playersettings', + describeRoute({ + tags: ['Player Settings'], + summary: 'The player’s settings', + description: [ + 'The authenticated player’s 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 player’s settings (defaults on first read)'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) - const kvKey = `player:${id}` - let stored = await c.env.RECFLARE_PLAYER_SETTINGS.get>(kvKey, 'json') - if (!stored || Object.keys(stored).length === 0) { - stored = Object.fromEntries(DEFAULT_SETTINGS.map((s) => [s.Key, s.Value])) - await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(stored)) + const kvKey = `player:${id}` + let stored = await c.env.RECFLARE_PLAYER_SETTINGS.get>(kvKey, 'json') + if (!stored || Object.keys(stored).length === 0) { + stored = Object.fromEntries(DEFAULT_SETTINGS.map((s) => [s.Key, s.Value])) + await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(stored)) + } + + 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. // 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. - .put('/playersettings', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) + .put( + '/playersettings', + describeRoute({ + tags: ['Player Settings'], + summary: 'Write the player’s settings', + description: [ + 'Upserts the posted setting(s) into the caller’s KV map. The write MERGES: a single', + 'key PUT (`key=PlayerSessionCount&value=1`, which is what the client sends) leaves the', + 'player’s 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) + if (id === null) return unauthorized(c) - const incoming = await parseSettings(c) - if (incoming.length === 0) return c.body(null, 200) + const incoming = await parseSettings(c) + if (incoming.length === 0) return c.body(null, 200) - const kvKey = `player:${id}` - const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get>(kvKey, 'json') - const merged: Record = { ...existing } - for (const { key, value } of incoming) merged[key] = value + const kvKey = `player:${id}` + const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get>( + kvKey, + 'json' + ) + const merged: Record = { ...existing } + for (const { key, value } of incoming) merged[key] = value - await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(merged)) - return c.body(null, 200) - }) + await c.env.RECFLARE_PLAYER_SETTINGS.put(kvKey, JSON.stringify(merged)) + 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 player’s 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 worker’s `POST /connect/token`.', + }, + }, + }, + }, + }) + ) +) export default app diff --git a/apps/playersettings/src/test/integration/api.test.ts b/apps/playersettings/src/test/integration/api.test.ts index 94b3543..3be76da 100644 --- a/apps/playersettings/src/test/integration/api.test.ts +++ b/apps/playersettings/src/test/integration/api.test.ts @@ -133,4 +133,38 @@ describe('playersettings endpoints', () => { const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9'))) 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> + } + 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) + }) }) diff --git a/apps/storage/package.json b/apps/storage/package.json index 7469db0..83cac11 100644 --- a/apps/storage/package.json +++ b/apps/storage/package.json @@ -17,8 +17,13 @@ "dependencies": { "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", + "@standard-community/standard-json": "0.3.5", + "@standard-community/standard-openapi": "0.2.9", "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": { "@cloudflare/vitest-pool-workers": "0.16.20", diff --git a/apps/storage/src/openapi.ts b/apps/storage/src/openapi.ts new file mode 100644 index 0000000..799184a --- /dev/null +++ b/apps/storage/src/openapi.ts @@ -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 `/` part of + * the stored key — the client keeps it and later references the blob by it, and the + * `cdn` worker reads it back from `/`. On a name-only post it + * is the name that was sent, echoed straight back. + */ +export const UploadResponse = z.object({ + filename: z + .string() + .describe('`/[.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 client’s 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 +})() diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index 40a9e2f..2d6756d 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -1,9 +1,20 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' 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 { + AUTHED, + ErrorResponse, + json, + text, + UNAUTHORIZED_RESPONSE, + UPLOAD_REQUEST_BODY, + UploadResponse, +} from './openapi' + import type { App } from './context' /** @@ -72,9 +83,18 @@ const app = new Hono() .onError(withOnError()) .notFound(withNotFound()) - .get('/', async (c) => { - return c.text('hello, world!') - }) + .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!') + } + ) // 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 @@ -83,40 +103,99 @@ const app = new Hono() // (the `/` part the `cdn` worker serves back) the client // references it by. Also accepts a name-only post (no binary) that just echoes // back an explicit `name`/`filename`/`imagename`. Mirrors the reference `Upload`. - .post('/upload', async (c) => { - const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) - if (id === null) return c.body(null, 401) + .post( + '/upload', + describeRoute({ + tags: ['Upload'], + summary: 'Upload a file', + description: [ + 'Stores the posted file in the shared CDN R2 bucket under', + '`//` and returns the', + '`/` 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 worker’s `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 server’s `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()) + if (id === null) return c.body(null, 401) - const body = await c.req.parseBody().catch(() => ({}) as Record) + const body = await c.req.parseBody().catch(() => ({}) as Record) - // The binary part is identified by being a file (filename/content-type), - // not by its field name — matching the reference's part detection. - const file = Object.values(body).find((v): v is File => v instanceof File) + // The binary part is identified by being a file (filename/content-type), + // not by its field name — matching the reference's part detection. + const file = Object.values(body).find((v): v is File => v instanceof File) - if (file) { - const fileType = textField(body, 'filetype') ?? '0' - const subfolder = subfolderForFileType(fileType) - if (subfolder === undefined) { - // makeUploadName == "" → no destination for an unknown/missing type. - return c.json({ error: 'missing or unknown FileType' }, 400) + if (file) { + const fileType = textField(body, 'filetype') ?? '0' + const subfolder = subfolderForFileType(fileType) + if (subfolder === undefined) { + // makeUploadName == "" → no destination for an unknown/missing type. + return c.json({ error: 'missing or unknown FileType' }, 400) + } + // Folder each upload under its date (e.g. `room/2026-02-03/`) so the + // bucket stays browsable. The date is part of the returned name, so the key + // the `cdn` worker reads back (`/`) still round-trips — as + // does the extension, which is why it goes on the key, not just the name. + const datePrefix = new Date().toISOString().slice(0, 10) + const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}` + await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, await file.arrayBuffer(), { + httpMetadata: { contentType: file.type || 'application/octet-stream' }, + }) + return c.json({ filename }) } - // Folder each upload under its date (e.g. `room/2026-02-03/`) so the - // bucket stays browsable. The date is part of the returned name, so the key - // the `cdn` worker reads back (`/`) still round-trips — as - // does the extension, which is why it goes on the key, not just the name. - const datePrefix = new Date().toISOString().slice(0, 10) - const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}` - await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, await file.arrayBuffer(), { - httpMetadata: { contentType: file.type || 'application/octet-stream' }, - }) - return c.json({ filename }) + + // No binary — accept an explicit name and echo it straight back. + const explicitName = textField(body, 'imagename', 'filename', 'name') + if (explicitName) return c.json({ filename: explicitName }) + + return c.json({ error: 'missing filename or valid upload data' }, 400) } + ) - // No binary — accept an explicit name and echo it straight back. - const explicitName = textField(body, 'imagename', 'filename', 'name') - if (explicitName) return c.json({ filename: explicitName }) - - 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 worker’s `POST /connect/token`.', + }, + }, + }, + }, + }) + ) +) export default app diff --git a/apps/storage/src/test/integration/api.test.ts b/apps/storage/src/test/integration/api.test.ts index 65a4bb4..56dca01 100644 --- a/apps/storage/src/test/integration/api.test.ts +++ b/apps/storage/src/test/integration/api.test.ts @@ -159,3 +159,35 @@ it('POST /upload 400s when there is neither a file nor a name', async () => { }) 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> + } + 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) +}) diff --git a/apps/www/src/docs.ts b/apps/www/src/docs.ts index 9e7ecd5..5885b3e 100644 --- a/apps/www/src/docs.ts +++ b/apps/www/src/docs.ts @@ -21,6 +21,11 @@ export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }> { slug: 'accounts', title: 'accounts — profiles & lookups' }, { slug: 'match', title: 'match — matchmaking & presence' }, { 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' }, ] @@ -46,9 +51,13 @@ function overviewSpec(): Record { '---', '', 'These specs are **descriptive, not enforced** — they document a protocol', - 'reverse-engineered from the game client (the only real consumer), so a field', - 'marked required means "the client always sends it", not "the server rejects it if', - 'absent". Each service also serves its own spec at `https://./openapi.json`.', + 'reverse-engineered from the game client (the only real consumer). They record', + 'observed behaviour, not a designed contract, and the handlers are lenient: they', + '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 don’t repeat it. Each', + 'service also serves its own spec at `https://./openapi.json`.', ].join('\n') return { openapi: '3.1.0', diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 2f0b763..f85491f 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -1,6 +1,8 @@ import { SELF } from 'cloudflare:test' import { expect, it } from 'vitest' +import { DOCUMENTED_SERVICES } from '../../docs' + it('rejects unauthenticated account reads', async () => { const res = await SELF.fetch('https://example.com/api/me') 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() // Mounts the self-hosted Scalar bundle (not a CDN) and lists every service's spec. 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`) } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11b0383..7d04dc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,12 +249,27 @@ importers: '@repo/jwt': specifier: workspace:* 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: specifier: 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: specifier: 1.0.1 version: 1.0.1 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.16.20 @@ -286,12 +301,27 @@ importers: '@repo/jwt': specifier: workspace:* 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: specifier: 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: specifier: 1.0.1 version: 1.0.1 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.16.20 @@ -403,12 +433,27 @@ importers: '@repo/hono-helpers': specifier: workspace:* 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: specifier: 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: specifier: 1.0.1 version: 1.0.1 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.16.20 @@ -606,12 +651,27 @@ importers: '@repo/jwt': specifier: workspace:* 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: specifier: 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: specifier: 1.0.1 version: 1.0.1 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.16.20 @@ -677,12 +737,27 @@ importers: '@repo/jwt': specifier: workspace:* 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: specifier: 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: specifier: 1.0.1 version: 1.0.1 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.16.20