mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
updating api docs
This commit is contained in:
@@ -179,9 +179,10 @@ const app = new Hono<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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' }],
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
+20
-22
@@ -247,13 +247,14 @@ const app = new Hono<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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' }],
|
||||
|
||||
@@ -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",
|
||||
|
||||
+494
-127
@@ -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<App>, name: string): Promise<string | undefi
|
||||
return typeof value === 'string' ? value : c.req.query(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* A concise `describeRoute` spec for one of the thread-scoped actions that answers the
|
||||
* bare ChatResult integer rather than an HTTP status — rename, leave, snooze, favorite,
|
||||
* add-member and the read-pointer moves. They share the auth gate, the `:id` path param,
|
||||
* and the "3 when the caller isn't on the thread" behaviour.
|
||||
*/
|
||||
function chatResultRoute(
|
||||
summary: string,
|
||||
description: string,
|
||||
extra: {
|
||||
requestBody?: ReturnType<typeof form>
|
||||
parameters?: unknown[]
|
||||
successDescription?: string
|
||||
/** Set for the read-pointer routes, which 404 a non-member instead of answering 3. */
|
||||
notFound?: boolean
|
||||
} = {}
|
||||
) {
|
||||
return describeRoute({
|
||||
tags: ['Chat'],
|
||||
summary,
|
||||
description,
|
||||
security: AUTHED,
|
||||
parameters: [THREAD_ID_PARAM, ...((extra.parameters ?? []) as never[])],
|
||||
...(extra.requestBody === undefined ? {} : { requestBody: extra.requestBody }),
|
||||
responses: {
|
||||
200: json(
|
||||
ChatResult,
|
||||
extra.successDescription ??
|
||||
'The ChatResult (0 on success, 3 when the caller 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<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -232,17 +323,45 @@ const app = new Hono<App>()
|
||||
.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<App>()
|
||||
// (`{"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<App>()
|
||||
// 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<App>()
|
||||
//
|
||||
// 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<App>()
|
||||
// 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<App>()
|
||||
// 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<App>()
|
||||
// `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<App>()
|
||||
// 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<App>()
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every schema inlines — a `$ref` here means a schema picked up a `.meta({ id })`
|
||||
// and emitted a reference the framework didn't hoist into components.schemas.
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
|
||||
// `.on([...], …)` routes contribute every method they were registered for.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /thread/{id}/leave',
|
||||
'GET /',
|
||||
'GET /thread',
|
||||
'GET /thread/{id}',
|
||||
'GET /thread/{id}/message',
|
||||
'POST /thread',
|
||||
'POST /thread/withmembers',
|
||||
'POST /thread/{id}',
|
||||
'POST /thread/{id}/favorite',
|
||||
'POST /thread/{id}/leave',
|
||||
'POST /thread/{id}/member/{playerId}',
|
||||
'POST /thread/{id}/message',
|
||||
'POST /thread/{id}/message/{messageId}/read',
|
||||
'POST /thread/{id}/read',
|
||||
'POST /thread/{id}/rename',
|
||||
'POST /thread/{id}/snooze',
|
||||
'PUT /thread/{id}/favorite',
|
||||
'PUT /thread/{id}/message/{messageId}/read',
|
||||
'PUT /thread/{id}/read',
|
||||
'PUT /thread/{id}/rename',
|
||||
'PUT /thread/{id}/snooze',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1136
-436
@@ -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<App>()
|
||||
// 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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<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]
|
||||
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<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]
|
||||
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<string, unknown>
|
||||
// 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<string, unknown>
|
||||
// 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<App>()
|
||||
//
|
||||
// `/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<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.')
|
||||
// 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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<App>()
|
||||
// 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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<App>()
|
||||
// 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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<App>()
|
||||
// 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<App>()
|
||||
// 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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every schema inlines: a `.meta({ id })` on any of them would emit a $ref this
|
||||
// setup doesn't always hoist into components.schemas, leaving it dangling.
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax (regex constraints and all) becomes
|
||||
// OpenAPI's `{param}`; the `.on([...])` clubhouse and additionalimage routes
|
||||
// contribute both their methods, and modifydetails/modify both their paths.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /club/home/me',
|
||||
'DELETE /club/{clubId}',
|
||||
'DELETE /club/{clubId}/additionalimage/{index}',
|
||||
'DELETE /club/{clubId}/clubhouse',
|
||||
'GET /announcements/club/{clubId}',
|
||||
'GET /announcements/v2/mine/unread',
|
||||
'GET /club/categoryTags',
|
||||
'GET /club/home/me',
|
||||
'GET /club/mine/created',
|
||||
'GET /club/mine/member',
|
||||
'GET /club/search',
|
||||
'GET /club/{clubId}',
|
||||
'GET /club/{clubId}/details',
|
||||
'GET /club/{clubId}/hasDisabledClubChat',
|
||||
'GET /club/{clubId}/mainimage',
|
||||
'GET /club/{clubId}/members',
|
||||
'GET /subscription/details/{accountId}',
|
||||
'GET /subscription/details/{subscription}',
|
||||
'GET /subscription/mine/member',
|
||||
'GET /subscription/subscriberCount/{accountId}',
|
||||
'POST /announcements/club/{clubId}',
|
||||
'POST /club/create',
|
||||
'POST /club/{clubId}/join',
|
||||
'POST /club/{clubId}/leave',
|
||||
'POST /club/{clubId}/members/leave',
|
||||
'PUT /club/home/me',
|
||||
'PUT /club/{clubId}/additionalimage/{index}',
|
||||
'PUT /club/{clubId}/clubhouse',
|
||||
'PUT /club/{clubId}/mainimage',
|
||||
'PUT /club/{clubId}/members/requesttojoin',
|
||||
'PUT /club/{clubId}/minlevel',
|
||||
'PUT /club/{clubId}/modify',
|
||||
'PUT /club/{clubId}/modifydetails',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+53
-46
@@ -382,9 +382,10 @@ const app = new Hono<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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' }],
|
||||
|
||||
@@ -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",
|
||||
|
||||
+143
-11
@@ -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<App>()
|
||||
.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=<base64>`. Signed over the',
|
||||
'bytes actually returned, i.e. the resized body when a transform applies. Omitted',
|
||||
'when the worker has no `IMG_SIGNING_KEY`.',
|
||||
].join(' '),
|
||||
schema: { type: 'string', enum: ['p1'] },
|
||||
},
|
||||
{
|
||||
name: 'If-None-Match',
|
||||
in: 'header',
|
||||
required: false,
|
||||
description:
|
||||
'Conditional request against the R2 object etag. Ignored when a transform is requested.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: imageBytes('The image bytes (or the DefaultProfileImage.jpg fallback)'),
|
||||
304: { description: 'If-None-Match matched the stored object etag; no body' },
|
||||
400: { description: 'The key contained `..`; no body' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const key = c.req.param('key')
|
||||
if (key.includes('..')) return c.body(null, 400)
|
||||
|
||||
@@ -255,6 +386,7 @@ const app = new Hono<App>()
|
||||
}
|
||||
|
||||
return new Response(object.body, { headers })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
@@ -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<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual(['GET /', 'GET /{key}'])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
|
||||
// Schemas must inline: a `$ref` here is a dangling reference (see openapi.ts).
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+53
-44
@@ -424,9 +424,10 @@ const app = new Hono<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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<App>()
|
||||
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' }],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
),
|
||||
])
|
||||
@@ -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<App>()
|
||||
.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<Record<string, string>>(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<Record<string, string>>(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<Record<string, string>>(kvKey, 'json')
|
||||
const merged: Record<string, string> = { ...existing }
|
||||
for (const { key, value } of incoming) merged[key] = value
|
||||
const kvKey = `player:${id}`
|
||||
const existing = await c.env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
kvKey,
|
||||
'json'
|
||||
)
|
||||
const merged: Record<string, string> = { ...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
|
||||
|
||||
@@ -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<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual(['GET /', 'GET /playersettings', 'PUT /playersettings'])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
|
||||
// Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d
|
||||
// schema used in a response emits a $ref this hono-openapi + zod v4 setup does
|
||||
// not always hoist, leaving a dangling reference.
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the storage worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||
* rationale as the auth/accounts/match/econ workers: a reverse-engineered protocol,
|
||||
* lenient handlers, no runtime validation.
|
||||
*
|
||||
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||
* schema inline, which renders correctly in any tool.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
/** A `text/plain` response body. */
|
||||
export function text(description: string) {
|
||||
return { description, content: { 'text/plain': { schema: { type: 'string' as const } } } }
|
||||
}
|
||||
|
||||
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return jsonSchema as OpenAPIV3_1.SchemaObject
|
||||
}
|
||||
|
||||
/** A form-urlencoded / multipart request body (the client posts both). */
|
||||
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
const s = toOpenApiSchema(schema)
|
||||
return {
|
||||
description,
|
||||
content: {
|
||||
'application/x-www-form-urlencoded': { schema: s },
|
||||
'multipart/form-data': { schema: s },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The empty-body 401 the auth-gated routes return. */
|
||||
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
|
||||
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
// ---- Response schemas ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `POST /upload` success body. `filename` is the `<upload-date>/<random-name>` part of
|
||||
* the stored key — the client keeps it and later references the blob by it, and the
|
||||
* `cdn` worker reads it back from `<type-subfolder>/<filename>`. On a name-only post it
|
||||
* is the name that was sent, echoed straight back.
|
||||
*/
|
||||
export const UploadResponse = z.object({
|
||||
filename: z
|
||||
.string()
|
||||
.describe('`<YYYY-MM-DD>/<uuid>[.ext]`, or the posted name on a name-only upload'),
|
||||
})
|
||||
|
||||
/** The `{ error }` body the 400s carry. */
|
||||
export const ErrorResponse = z.object({ error: z.string() })
|
||||
|
||||
// ---- Request schemas -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The text parts of `POST /upload`. Field names are matched case-insensitively, so the
|
||||
* client's `imageName` / `FileType` casing is only indicative. The binary part is not in
|
||||
* this schema — see `UPLOAD_REQUEST_BODY`.
|
||||
*/
|
||||
export const UploadRequest = z.object({
|
||||
FileType: z
|
||||
.string()
|
||||
.describe(
|
||||
[
|
||||
'The 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
|
||||
})()
|
||||
+112
-33
@@ -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<App>()
|
||||
.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<App>()
|
||||
// (the `<upload-date>/<random-name>` part the `cdn` worker serves back) the client
|
||||
// references it by. Also accepts a name-only post (no binary) that just echoes
|
||||
// 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',
|
||||
'`<type-subfolder>/<upload-date>/<random-name>` and returns the',
|
||||
'`<upload-date>/<random-name>` part the client references it by (the same name the',
|
||||
'`cdn` worker serves back). The subfolder comes from `FileType`; RoomSave (1) lands',
|
||||
'under `room/` so the cdn 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<string, unknown>)
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
|
||||
// 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/<uuid>`) so the
|
||||
// bucket stays browsable. The date is part of the returned name, so the key
|
||||
// the `cdn` worker reads back (`<subfolder>/<name>`) 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/<uuid>`) so the
|
||||
// bucket stays browsable. The date is part of the returned name, so the key
|
||||
// the `cdn` worker reads back (`<subfolder>/<name>`) 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
|
||||
|
||||
@@ -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<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual(['GET /', 'POST /upload'])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
|
||||
// Every schema inlines: a `$ref` here would be a dangling reference (see openapi.ts).
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
})
|
||||
|
||||
+12
-3
@@ -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<string, unknown> {
|
||||
'---',
|
||||
'',
|
||||
'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://<service>.<domain>/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://<service>.<domain>/openapi.json`.',
|
||||
].join('\n')
|
||||
return {
|
||||
openapi: '3.1.0',
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
|
||||
Generated
+75
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user