updating api docs

This commit is contained in:
Devin Zuczek
2026-07-22 11:43:30 -04:00
parent 68b98665b2
commit 23b78104e8
28 changed files with 3358 additions and 780 deletions
+1136 -436
View File
@@ -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 clubs id (digits only — a non-numeric id doesnt match the route)',
schema: { type: 'string' },
} as const
/**
* Resolve the account id from a Bearer token. Returns `null` when the header is
* 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 players home club',
description: [
'The club whose clubhouse the player spawns into (a field on their account row).',
'404 when they have no home club, the club is gone, or it has no clubhouse room —',
'the client expects a 404 for “no home club” and errors on an empty object. Returns',
'the bare club, not the envelope, as the reference does.',
].join(' '),
security: AUTHED,
responses: {
200: json(ClubDto, 'The players home club'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No home club, or it has no clubhouse room' },
},
}),
async (c) => {
const id = await authedId(c)
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 players home club',
description: [
'Points the players home club at the posted `clubId`. They must already be a member',
'of it — you cant make a club you dont belong to your home. Answers the envelope',
'carrying the bare club.',
].join(' '),
security: AUTHED,
requestBody: form(HomeClubRequest, 'The club to make home'),
responses: {
200: json(ClubEnvelope, 'The envelope carrying the new home club'),
400: json(ErrorEnvelope, 'Missing, non-numeric or zero clubId'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The caller isnt a member of that club'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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 players home club',
description: [
'The player spawns into the default hub again instead of a clubhouse. No body,',
'idempotent (clearing when none is set is a no-op, not a 404), and it doesnt touch',
'their membership of the club. The envelopes `value` is null because theres no home',
'club left to describe; GET goes back to 404ing.',
].join(' '),
security: AUTHED,
responses: {
200: json(NullEnvelope, 'Cleared (value null)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
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 callers club-subscription memberships',
description: [
'A real client endpoint with no backing implementation yet. The client calls it on',
'the clubs host at `/subscription/mine/member` (no `/club` prefix) and sends no auth',
'header, so it isnt gated. Always `[]` — no subscription memberships (the client',
'chokes on null).',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty for now') },
}),
(c) => c.json([])
)
// Subscription details for an account (numeric id) — simulated: no club, no subs.
.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 players clubs with unread announcements',
description: [
'MyClubsWithUnreadAnnouncements. Nothing tracks what a player has read yet, so',
'nothing is unread → always `[]`.',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty for now') },
}),
(c) => c.json([])
)
// A club's announcements — its noticeboard, newest first. Public. Answers the
// 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 clubs announcements',
description: [
'The clubs noticeboard, newest first. Public. Answers the envelope, with',
'`LastAnnouncementId` the newest one (null when there are none) and',
'`LastReadAnnouncementId` 0 — nothing tracks read state yet. An unknown club simply',
'has no announcements.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: { 200: json(ClubAnnouncementsEnvelope, 'The clubs noticeboard') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const 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 envelopes `value` is the new announcements id.',
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(AnnouncementRequest, 'The announcement fields'),
responses: {
200: json(AnnouncementIdEnvelope, 'The new announcements id'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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 callers memberships from `club_member`, oldest club',
'first (pending/denied/banned rows excluded). A caller with no valid token has no',
'clubs, so this answers `[]` rather than 401ing: the client shows the “my clubs”',
'shelf either way, and an error there breaks the screen.',
].join(' '),
security: AUTHED,
responses: { 200: json(ClubDto.array(), 'The callers clubs (empty when signed out)') },
}),
async (c) => {
const id = await authedId(c)
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 clubs Creator. Answers the `{ error, success, value }` envelope carrying the new',
'clubs full details — not a bare club.',
].join(' '),
security: AUTHED,
requestBody: form(CreateClubRequest, 'The new clubs fields'),
responses: {
200: json(ClubDetailsEnvelope, 'The new clubs details'),
400: json(
ErrorEnvelope,
'Missing/invalid/too-long name, or the per-account club limit is reached'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
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 clubs details',
description: [
'The client PUTs a form of just the fields its changing — enums by name',
'(`visibility=Public`, `joinability=Open`, `allowJuniors=True`) — and absent fields',
'keep their stored value (an empty `name`/`description` means “unchanged”, not',
'“clear it”). `customTags` may repeat; when present it replaces the clubs tag set',
'wholesale. Co-owner or above only. `/modify` is the same endpoint under the shorter',
'name the client also PUTs to — one handler, so the two cant drift. Answers the same',
'details envelope create does, since the client re-renders the club screen from it.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ModifyClubRequest, 'The fields to change'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Invalid or too-long name'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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 clubs full details',
description: [
'The club plus its custom tags, the per-tier permissions, its gallery, and the',
'callers own membership. Public — a signed-out viewer just gets `MyMembershipType` 0.',
'Unlike create/modifydetails this one is NOT enveloped: the details object is written',
'straight out, as the reference does.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsDto, 'The clubs details (not enveloped)'),
404: { description: 'No such club' },
},
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const 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 clubs members',
description:
'Public, and an unknown club is an empty list rather than a 404. Answers the envelope.',
parameters: [
CLUB_ID_PARAM,
{
name: 'membershipType',
in: 'query',
required: false,
description: [
'Filter to exactly that tier — an exact match, not a threshold, so `30` lists',
'co-owners only, not the creator above them',
].join(' '),
schema: { type: 'string' },
},
{
name: 'sortBy',
in: 'query',
required: false,
description: '1 = account id, 2 = oldest first, anything else = highest tier first',
schema: { type: 'string' },
},
],
responses: { 200: json(ClubMembersEnvelope, 'The clubs membership rows') },
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const 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 clubs minimum join level',
description: [
'The reference has no such route (it only takes `minLevel` on modifydetails), but the',
'client PUTs it here. Same rules as the other club edits: co-owner or above, and the',
'details envelope back.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(MinLevelRequest, 'The new minimum level'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Missing, non-numeric or negative minLevel'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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 clubs clubhouse room',
description: [
'The clubhouse is the room players spawn into when the club is their home. PUT with',
'`roomId` sets it; omitting `roomId` clears it. DELETE is the same thing with the',
'clearing spelled out — it ignores any body and always unsets the room, so “remove the',
'clubhouse” doesnt depend on the client remembering to send an empty PUT. Co-owner or',
'above only. Answers the full details envelope: the reference returns a null value',
'here, but the client re-renders from the response and leaves the old clubhouse on',
'screen unless it gets the updated club back.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ClubhouseRequest, 'The clubhouse room (PUT only; DELETE ignores the body)'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Non-numeric roomId'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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 clubs main image',
description: [
'The reference has no GET here (it 404s), but the client asks for it, so this answers',
'the same details envelope rather than erroring; the image name is on',
'`value.Club.MainImageName`. Public.',
].join(' '),
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, carrying MainImageName'),
404: { description: 'No such club' },
},
}),
async (c) => {
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const 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 clubs main image',
description: [
'Sets the main image from an uploaded images `imageName` (the name the `storage`',
'worker handed back). Co-owner or above only. Answers the details envelope.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
requestBody: form(ImageNameRequest, 'The uploaded images name'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'Missing imageName'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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 clubs gallery images',
description: [
'One gallery image by position (0-based — the client PUTs the first image to 0, the',
'second to 1), taking the same `imageName` the `storage` worker handed back. Co-owner',
'or above, like the main image. The list is PACKED, never sparse: a PUT past the end',
'appends rather than leaving a gap, and DELETE removes that position and shifts the',
'rest up, so theres never a blank slot. DELETE ignores any body (so it cant',
'accidentally set an image instead) and deleting an empty position is a no-op. The',
'images come back on `value.AdditionalImages` as whole image records, in order — a',
'bare array of names fails the clients parser.',
].join(' '),
security: AUTHED,
parameters: [
CLUB_ID_PARAM,
{
name: 'index',
in: 'path',
required: true,
description: 'The 0-based gallery slot; a club has 3 slots (02)',
schema: { type: 'string' },
},
],
requestBody: form(ImageNameRequest, 'The uploaded images name (PUT only)'),
responses: {
200: json(ClubDetailsEnvelope, 'The updated clubs details'),
400: json(ErrorEnvelope, 'The index is past the clubs gallery slots'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Below co-owner'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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 whod set it. The creator only — not co-owners, who can edit a',
'club but cant destroy one — which is also the way out for a creator, since they',
'arent allowed to leave. The envelopes `value` is null: the club is gone, so there',
'are no details left to return.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(NullEnvelope, 'Deleted (value null)'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'Not the clubs creator'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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 clubs Joinability: an Open club takes the caller straight in as a',
'Member, an AskToJoin club records a PendingRequested row for a co-owner to approve,',
'and an InviteOnly club refuses (you can only get in through an invite). Repeats are',
'idempotent; a banned account stays out. Answers the details envelope so the client',
'can read its new `MyMembershipType`.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, with the callers new membership'),
400: json(ErrorEnvelope, 'The club is invite only'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The caller is banned from the club'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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 youre not in is a no-op),',
'and it also withdraws a pending request; a ban is preserved, since you cant clear',
'one by leaving. The creator is refused — theyd leave the club ownerless, so they',
'have to delete it instead. Answers the details envelope so the client sees',
'`MyMembershipType` drop to 0 (or stay at -1 for a banned account).',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDetailsEnvelope, 'The clubs details, with the callers membership gone'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The creator cant leave — delete the club instead'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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',
'cant be shed by re-joining. Returns the bare club with its refreshed MemberCount',
'(not the details envelope — see members/requesttojoin for that).',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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 cant disagree about whos still in the club.',
].join(' '),
security: AUTHED,
parameters: [CLUB_ID_PARAM],
responses: {
200: json(ClubDto, 'The club, with its refreshed MemberCount'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorEnvelope, 'The creator cant leave — delete the club instead'),
404: { description: 'No such club' },
},
}),
async (c) => {
const id = await authedId(c)
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 players home',
'club. Everything is D1-backed on the shared `recflare` database; the',
'`/subscription/*` routes are stubs, since there are no subscription clubs yet.',
'',
'Most writes answer the `{ error, success, value }` envelope with HTTP 200, and the',
'ones the client re-renders a club screen from carry the clubs FULL details as',
'`value` rather than null.',
].join('\n'),
},
servers: [{ url: 'https://clubs.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app
+338
View File
@@ -0,0 +1,338 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
* OpenAPI schemas for the clubs worker.
*
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
* rationale as the auth/accounts/econ/match workers: a reverse-engineered protocol,
* lenient handlers, no runtime validation.
*
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
* schema inline, which renders correctly in any tool.
*/
/** Emit a zod schema as an `application/json` response body. */
export function json(schema: z.ZodType, description: string) {
return { description, content: { 'application/json': { schema: resolver(schema) } } }
}
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** A form-urlencoded / multipart request body (the client posts both). */
export function form(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
const s = toOpenApiSchema(schema)
return {
description,
content: {
'application/x-www-form-urlencoded': { schema: s },
'multipart/form-data': { schema: s },
},
}
}
/** An `application/json` request body. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
/** The empty-body 401 the auth-gated routes return. */
export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/** An opaque JSON array — the empty-list stubs (`[]`) the client still expects. */
export const JsonArray = z.array(z.unknown())
/** An empty JSON object — the stub shape the client deserializes into an object. */
export const EmptyObject = z.object({})
// ---- Core entities ---------------------------------------------------------
/**
* The client-facing club DTO (mirror of the Go `Club` JSON tags). The stored blob also
* carries `CreatedAt`, `CustomTags` and `AdditionalImages`, none of which are on this
* object — the tags and gallery are served on the details view instead.
*/
export const ClubDto = z.object({
ClubId: z.int(),
Name: z.string().describe('At most 16 characters; letters, digits and basic punctuation'),
Description: z.string(),
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
Joinability: z.int().describe('ClubJoinability: 0 = Open, 1 = InviteOnly, 2 = AskToJoin'),
AllowJuniors: z.boolean(),
MainImageName: z.string().describe('An image name from the storage worker; DefaultImgPurple'),
ClubType: z.int().describe('0 = a regular club; 1 = a subscription club (never listed)'),
ClubhouseRoomId: z.int().nullable().describe('The room a home-club member spawns into'),
CreatorAccountId: z.int(),
IsRRO: z.boolean(),
MinLevel: z.int(),
State: z.int(),
MemberCount: z.int().describe('Derived from the club_member rows at/above Member (10)'),
})
/**
* An image record as every image on the site is served (`SavedImage`). A club's gallery
* serves these whole — see AdditionalImages on the details view.
*/
export const SavedImageDto = z.object({
Id: z.int(),
Type: z.int().describe('SavedImageType: 1 = share camera, 3 = room, 4 = profile, …'),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
Description: z.string().nullable(),
PlayerId: z.int(),
TaggedPlayerIds: z.array(z.int()),
RoomId: z.int().nullable(),
PlayerEventId: z.int().nullable(),
CreatedAt: z.string(),
CheerCount: z.int(),
CommentCount: z.int(),
})
/**
* What a membership tier may do in a club. These are the defaults every club gets
* (co-owners everything, moderators approve/ban, members none); nothing edits them yet,
* so they're derived per club rather than stored.
*/
export const ClubPermissionDto = z.object({
ClubId: z.int(),
Type: z.int().describe('The ClubMembershipType tier these permissions describe'),
ApproveMember: z.boolean(),
BanUnban: z.boolean(),
CreateEvent: z.boolean(),
EditDetails: z.boolean(),
EditPermissionSettings: z.boolean(),
PostAnnouncement: z.boolean(),
})
/**
* The club-details payload the client renders a club screen from: the club, its tags,
* the per-tier permissions, its gallery, and the caller's own membership.
*/
export const ClubDetailsDto = z.object({
AdditionalImages: z
.array(SavedImageDto)
.describe(
[
'The clubs gallery as WHOLE image records, not image names — the client',
'deserializes each entry into an object, so a bare array of names fails its parser',
'("expected \'{\'"). The list is packed and in order: removing an image shifts the',
'rest up, never leaving a blank slot.',
].join(' ')
),
Club: ClubDto,
ClubId: z.int(),
CoownerPermissions: ClubPermissionDto,
CustomTags: z.array(z.string()).describe('Set wholesale by modifydetails repeated customTags'),
MemberPermissions: ClubPermissionDto,
ModeratorPermissions: ClubPermissionDto,
MyMembershipType: z
.int()
.describe(
[
'The callers own ClubMembershipType: -1 banned, 0 none (also a signed-out viewer),',
'1 pending request, 2 pending invite, 3 denied, 10 member, 20 moderator, 30 co-owner,',
'100 creator',
].join(' ')
),
})
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
export const ClubMemberDto = z.object({
ClubMemberId: z.int(),
ClubId: z.int(),
AccountId: z.int(),
MembershipType: z.int().describe('See MyMembershipType for the tiers'),
CreatedAt: z.string().nullable().describe('When the membership row was first written'),
})
/** One entry on a club's noticeboard (mirror of the Go `ClubAnnouncement`). */
export const ClubAnnouncementDto = z.object({
AnnouncementId: z.int(),
ClubId: z.int(),
AccountId: z.int().describe('Who posted it'),
Title: z.string(),
Body: z.string(),
ImageName: z.string(),
Meta: z.string(),
CreatedAt: z.string().nullable(),
})
// ---- Envelopes -------------------------------------------------------------
//
// Most club writes answer the `{ error, success, value }` envelope with HTTP 200 (or
// 400/403 carrying the same shape with `success: false`). The envelope's `value` is the
// entity the client re-renders from, so routes that change a club return the FULL
// details view rather than a null value — `PUT /club/:id/clubhouse` left the old
// clubhouse on screen until it answered the details envelope.
/** The success envelope carrying a club's full details. */
export const ClubDetailsEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: ClubDetailsDto,
})
/** The success envelope carrying a bare club (`PUT /club/home/me`). */
export const ClubEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: ClubDto,
})
/**
* The envelope with nothing left to describe — clearing the home club, deleting a club.
* Only used where the entity is genuinely gone; anything the client re-renders from
* returns the details envelope instead.
*/
export const NullEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.null(),
})
/** A rejected action: the same envelope, carrying the message the client shows. */
export const ErrorEnvelope = z.object({
error: z.string().describe('The message shown to the player'),
success: z.boolean().describe('Always false'),
value: z.null(),
})
/** The envelope carrying a club's members (`GET /club/:clubId/members`). */
export const ClubMembersEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.array(ClubMemberDto),
})
/** The envelope carrying a club's noticeboard (`GET /announcements/club/:clubId`). */
export const ClubAnnouncementsEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.object({
Announcements: z.array(ClubAnnouncementDto).describe('Newest first'),
ClubId: z.int(),
LastAnnouncementId: z.int().nullable().describe('The newest one; null when there are none'),
LastReadAnnouncementId: z.int().describe('Always 0 — nothing tracks read state yet'),
}),
})
/** The envelope carrying a new announcement's id (`POST /announcements/club/:clubId`). */
export const AnnouncementIdEnvelope = z.object({
error: z.string(),
success: z.boolean(),
value: z.int().describe('The new announcements id'),
})
// ---- Other response shapes -------------------------------------------------
/** `GET /club/search` — a page of clubs plus the full match count. */
export const ClubSearchResponse = z.object({
Clubs: z.array(ClubDto),
ContinuationToken: z.null().describe('Always null — the whole page is served at once'),
TotalClubs: z.int().describe('How many clubs matched, not the page size'),
})
/** `GET /subscription/details/:accountId` — simulated: no club, no subscribers. */
export const SubscriptionDetailsResponse = z.object({
accountId: z.int(),
clubId: z.int().describe('Always 0 — no subscription clubs yet'),
subscriberCount: z.int().describe('Always 0'),
})
/** The set of category tags a club can be filed under — a fixed list. */
export const CategoryTags = z.array(z.string())
/** `GET /subscription/subscriberCount/:accountId` — a bare JSON integer. */
export const SubscriberCountResponse = z
.int()
.describe('Always 0 — there are no club subscriptions yet')
/**
* `GET /club/:clubId/hasDisabledClubChat` — a bare JSON boolean, like the other
* `is…`/`has…` gates the client polls. Nothing can turn club chat off yet, so it's
* always false; not in the reference, so if the client chokes on this it likely wants
* the `{ error, success, value }` envelope the other club endpoints use.
*/
export const ChatDisabledResponse = z.boolean()
// ---- Request schemas -------------------------------------------------------
//
// Every write takes a form body (urlencoded or multipart — the client posts both) with
// lowercase field names; the handlers match field names case-insensitively.
/** `POST /club/create` form body. */
export const CreateClubRequest = z.object({
name: z
.string()
.describe('Required; at most 16 characters, letters/digits/basic punctuation only'),
description: z.string().optional(),
category: z.string().optional().describe('Defaults to Social when unset'),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
.string()
.optional()
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
mainImageName: z.string().optional(),
minLevel: z.string().optional(),
})
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
export const ModifyClubRequest = z.object({
name: z.string().optional().describe('Empty means unchanged, not "clear it"'),
description: z.string().optional().describe('Empty means unchanged'),
category: z.string().optional(),
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
joinability: z
.string()
.optional()
.describe('By name (`Open`/`InviteOnly`/`AskToJoin`) or number'),
allowJuniors: z.string().optional().describe('`True`/`false`/`1`/`yes`'),
mainImageName: z.string().optional(),
minLevel: z.string().optional(),
customTags: z
.array(z.string())
.optional()
.describe('May repeat; when present it replaces the clubs tag set wholesale'),
})
/** `PUT /club/home/me` form body. */
export const HomeClubRequest = z.object({
clubId: z.string().describe('The club to make home; the caller must be a member of it'),
})
/** `PUT /club/:clubId/minlevel` form body. */
export const MinLevelRequest = z.object({
minLevel: z.string().describe('The minimum player level to join; negative/NaN is 400'),
})
/** `PUT /club/:clubId/clubhouse` form body. */
export const ClubhouseRequest = z.object({
roomId: z.string().optional().describe('The clubhouse room; omitting it clears the clubhouse'),
})
/** `PUT /club/:clubId/mainimage` and `/additionalimage/:index` form body. */
export const ImageNameRequest = z.object({
imageName: z.string().describe('The image name the `storage` worker handed back'),
})
/** `POST /announcements/club/:clubId` form body. */
export const AnnouncementRequest = z.object({
title: z.string().optional(),
body: z.string().optional(),
imageName: z.string().optional(),
meta: z.string().optional(),
})
@@ -1306,4 +1306,73 @@ describe('clubs endpoints', () => {
// Deleting twice 404s rather than reporting success.
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()
}
})
})