fix #19 clubhouses

This commit is contained in:
Devin Zuczek
2026-07-21 12:59:47 -04:00
parent d56fe3a276
commit 7f497fcee0
7 changed files with 691 additions and 21 deletions
+90 -4
View File
@@ -519,6 +519,17 @@ export async function setHomeClub(
.run()
}
/**
* Drop the player's home club (the field is removed from their account row, not set
* to 0 — `getHomeClub` reads a missing field as "no home club"). Idempotent.
*/
export async function clearHomeClub(db: D1Database, accountId: number): Promise<void> {
await db
.prepare("UPDATE account SET data = json_remove(data, '$.homeClubId') WHERE account_id = ?1")
.bind(accountId)
.run()
}
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
export interface ClubMember {
ClubMemberId: number
@@ -659,6 +670,30 @@ export async function getClub(db: D1Database, clubId: number): Promise<Club | nu
)
}
/**
* Delete a club and everything hanging off it — its memberships and announcements —
* and clear it from the home club of anyone who'd set it. Returns false when there
* was no such club. Batched so a half-deleted club can't be left behind.
*/
export async function deleteClub(db: D1Database, clubId: number): Promise<boolean> {
if ((await getClub(db, clubId)) === null) return false
await db.batch([
db.prepare('DELETE FROM club_member WHERE club_id = ?1').bind(clubId),
db.prepare('DELETE FROM club_announcement WHERE club_id = ?1').bind(clubId),
// The account table belongs to the auth worker; a dangling homeClubId already
// reads as "no home club" (getHomeClub), but leaving it would point at whatever
// club later reuses the id.
db
.prepare(
`UPDATE account SET data = json_remove(data, '$.homeClubId')
WHERE json_extract(data, '$.homeClubId') = ?1`
)
.bind(clubId),
db.prepare('DELETE FROM club WHERE club_id = ?1').bind(clubId),
])
return true
}
/**
* Subscription clubs (`ClubType` 1) are a creator's paid-subscriber club, not a
* club you browse or list among your own — they're excluded from the "my clubs"
@@ -740,19 +775,70 @@ export async function joinClub(
return { ...club, MemberCount: count }
}
/**
* How a request to join resolved. `joined` is an Open club (no approval needed),
* `requested` an AskToJoin club (now PendingRequested), `alreadyPending` a repeat
* request, `alreadyMember` someone who's already in. `inviteOnly` and `banned` are
* refusals — the caller can't get in this way.
*/
export type JoinRequestResult =
'joined' | 'requested' | 'alreadyPending' | 'alreadyMember' | 'inviteOnly' | 'banned'
/**
* Ask to join a club. Unlike `joinClub` this honours the club's Joinability strictly:
* an InviteOnly club can only be entered through an invite, so a request is refused
* rather than parked as pending. Returns the outcome plus the club with its refreshed
* MemberCount, or null when the club doesn't exist.
*/
export async function requestToJoinClub(
db: D1Database,
clubId: number,
accountId: number
): Promise<{ result: JoinRequestResult; club: Club } | null> {
const club = await getClub(db, clubId)
if (!club) return null
const current = await getMembership(db, clubId, accountId)
// A ban can't be shed by asking again, and existing members/requests stay as-is.
if (current === ClubMembershipType.Banned) return { result: 'banned', club }
if (current >= MEMBER_THRESHOLD) return { result: 'alreadyMember', club }
if (current === ClubMembershipType.PendingRequested) return { result: 'alreadyPending', club }
if (club.Joinability === ClubJoinability.InviteOnly) return { result: 'inviteOnly', club }
const open = club.Joinability === ClubJoinability.Open
await setMembership(
db,
clubId,
accountId,
open ? ClubMembershipType.Member : ClubMembershipType.PendingRequested
)
const count = await syncMemberCount(db, clubId)
return { result: open ? 'joined' : 'requested', club: { ...club, MemberCount: count } }
}
/**
* Remove `accountId`'s membership of a club (idempotent). A ban is preserved — you
* can't clear it by leaving — but any member/pending row is dropped. Returns the
* club with its refreshed MemberCount, or null when the club doesn't exist. The
* club itself is left in place even when the last member leaves.
* outcome plus the club with its refreshed MemberCount, or null when the club doesn't
* exist. The club itself is left in place even when the last member leaves.
*
* The creator can't leave: a club with no owner has no one who can administer it, and
* there's no ownership transfer, so they have to delete the club instead. `creator`
* reports that refusal, with the club unchanged.
*/
export async function leaveClub(
db: D1Database,
clubId: number,
accountId: number
): Promise<Club | null> {
): Promise<{ result: 'left' | 'creator'; club: Club } | null> {
const club = await getClub(db, clubId)
if (!club) return null
const current = await getMembership(db, clubId, accountId)
if (current === ClubMembershipType.Creator) return { result: 'creator', club }
await db
.prepare(
'DELETE FROM club_member WHERE club_id = ?1 AND account_id = ?2 AND membership_type <> ?3'
@@ -760,5 +846,5 @@ export async function leaveClub(
.bind(clubId, accountId, ClubMembershipType.Banned)
.run()
const count = await syncMemberCount(db, clubId)
return { ...club, MemberCount: count }
return { result: 'left', club: { ...club, MemberCount: count } }
}
+137 -13
View File
@@ -5,11 +5,13 @@ import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
clearHomeClub,
ClubJoinability,
ClubMembershipType,
ClubVisibility,
createClub,
createClubAnnouncement,
deleteClub,
getClub,
getClubAnnouncements,
getClubDetails,
@@ -20,6 +22,7 @@ import {
getMembership,
joinClub,
leaveClub,
requestToJoinClub,
searchClubs,
setHomeClub,
updateClub,
@@ -164,6 +167,17 @@ const app = new Hono<App>()
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 })
})
// 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
@@ -334,7 +348,10 @@ const app = new Hono<App>()
// and absent fields keep their stored value. `customTags` may repeat; when present
// it replaces the club's tag set wholesale. Co-owner or above only. Answers the
// same `{ error, success, value }` envelope create does.
.put('/club/:clubId{[0-9]+}/modifydetails', async (c) => {
//
// `/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)
@@ -472,8 +489,14 @@ const app = new Hono<App>()
// 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
// or above only. Answers the envelope with a null value, as the reference does.
.put('/club/:clubId{[0-9]+}/clubhouse', async (c) => {
// or above only. Answers the 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).
//
// 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)
@@ -486,17 +509,24 @@ const app = new Hono<App>()
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() === 'roomid')
const raw = typeof body[key ?? ''] === 'string' ? String(body[key ?? '']).trim() : ''
if (raw !== '' && Number.isNaN(Number.parseInt(raw, 10))) {
return clubError(c, 'Invalid roomId.')
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)
}
await updateClub(c.env.DB, clubId, {
clubhouseRoomId: 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),
})
return c.json({ error: '', success: true, value: null })
})
// The club's main image. PUT sets it from an uploaded image's `imageName` (the
@@ -548,6 +578,87 @@ const app = new Hono<App>()
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)
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)
}
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
// 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`.
.put('/club/:clubId{[0-9]+}/members/requesttojoin', 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()
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),
})
})
// 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
// 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).
.post('/club/:clubId{[0-9]+}/members/leave', 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
)
}
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) => {
@@ -556,11 +667,24 @@ const app = new Hono<App>()
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 club = await leaveClub(c.env.DB, Number.parseInt(c.req.param('clubId'), 10), id)
return club ? c.json(club) : c.notFound()
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)
})
export default app
+251 -4
View File
@@ -325,6 +325,25 @@ describe('clubs endpoints', () => {
body: 'name=Ghost',
})
expect(missing.status).toBe(404)
// /modify is the same endpoint under the client's shorter name.
const short = async (sub: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=my%20club&description=rock%20out&category=Casual',
})
const renamed = (await (await short('6000')).json()) as Details
expect(renamed.value.Club).toMatchObject({
Name: 'my club',
Description: 'rock out',
Category: 'Casual',
})
// ...and it's gated the same way.
expect((await short('6001')).status).toBe(403)
expect(
(await exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, { method: 'PUT' })).status
).toBe(401)
})
test('GET/PUT /club/:id/mainimage reads and sets the club image, co-owner only', async () => {
@@ -473,22 +492,77 @@ describe('clubs endpoints', () => {
// Give it a clubhouse → the home club now resolves.
const clubhouse = await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100')
expect(clubhouse.status).toBe(200)
expect(await clubhouse.json()).toEqual({ error: '', success: true, value: null })
// The details envelope carries the new room back — the client re-renders from it.
const setRoom = (await clubhouse.json()) as {
error: string
success: boolean
value: { Club: { ClubhouseRoomId: number | null } }
}
expect(setRoom).toMatchObject({ error: '', success: true })
expect(setRoom.value.Club.ClubhouseRoomId).toBe(77)
const home = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
headers: await bearer('9100'),
})
expect((await home.json()) as Club).toMatchObject({ ClubId: clubId, ClubhouseRoomId: 77 })
// Clearing the clubhouse takes the home club away again.
await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100')
// Clearing the clubhouse takes the home club away again — and reports the cleared
// room, so the client doesn't keep showing the old one.
const clear = (await (await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100')).json()) as {
value: { Club: { ClubhouseRoomId: number | null } }
}
expect(clear.value.Club.ClubhouseRoomId).toBeNull()
const cleared = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
headers: await bearer('9100'),
})
expect(cleared.status).toBe(404)
// Only co-owners may set the clubhouse; signed out is a 401 on both.
// DELETE clears it too, ignoring any body it's sent.
await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=88', '9100')
const deleted = (await (
await form(`/club/${clubId}/clubhouse`, 'DELETE', 'roomId=99', '9100')
).json()) as { success: boolean; value: { Club: { ClubhouseRoomId: number | null } } }
expect(deleted.success).toBe(true)
expect(deleted.value.Club.ClubhouseRoomId).toBeNull()
// DELETE /club/home/me drops the home club without touching the membership, and
// is idempotent when there's none set.
await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100')
await form('/club/home/me', 'PUT', `clubId=${clubId}`, '9100')
const dropped = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
method: 'DELETE',
headers: await bearer('9100'),
})
expect(dropped.status).toBe(200)
expect(await dropped.json()).toEqual({ error: '', success: true, value: null })
expect(
(await exports.default.fetch(`${ORIGIN}/club/home/me`, { headers: await bearer('9100') }))
.status
).toBe(404)
// Still a member of the club they'd made their home.
const mine = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('9100') })
).json()) as Club[]
expect(mine.map((c) => c.ClubId)).toContain(clubId)
// Clearing again, and clearing when nothing was set, both succeed.
for (const sub of ['9100', '9101']) {
const again = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
method: 'DELETE',
headers: await bearer(sub),
})
expect(again.status).toBe(200)
}
expect(
(await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'DELETE' })).status
).toBe(401)
// Only co-owners may set or clear the clubhouse; signed out is a 401 on both.
expect((await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=1', '9101')).status).toBe(403)
expect((await form(`/club/${clubId}/clubhouse`, 'DELETE', '', '9101')).status).toBe(403)
expect(
(await exports.default.fetch(`${ORIGIN}/club/${clubId}/clubhouse`, { method: 'DELETE' }))
.status
).toBe(401)
const anon = await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'PUT' })
expect(anon.status).toBe(401)
})
@@ -875,4 +949,177 @@ describe('clubs endpoints', () => {
).json()) as Club[]
expect(member811.map((c) => c.ClubId)).not.toContain(club.ClubId)
})
test('requesttojoin follows the club joinability', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const request = async (clubId: number, sub: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, {
method: 'PUT',
headers: await bearer(sub),
})
type Details = { error: string; success: boolean; value: { MyMembershipType: number } | null }
const open = await create('820', { name: 'Open Doors', joinability: 'Open' })
const ask = await create('821', { name: 'Ask First', joinability: 'AskToJoin' })
const invite = await create('822', { name: 'Invite Only', joinability: 'InviteOnly' })
// Open → straight in as a Member (10).
const joined = (await (await request(open, '830')).json()) as Details
expect(joined).toMatchObject({ success: true })
expect(joined.value?.MyMembershipType).toBe(10)
// AskToJoin → PendingRequested (1), and a repeat request leaves it there.
const asked = (await (await request(ask, '830')).json()) as Details
expect(asked.value?.MyMembershipType).toBe(1)
expect(
(((await (await request(ask, '830')).json()) as Details).value ?? {}).MyMembershipType
).toBe(1)
// InviteOnly → refused, with no membership row created.
const refused = await request(invite, '830')
expect(refused.status).toBe(400)
expect(((await refused.json()) as Details).success).toBe(false)
// No token, and an unknown club.
expect(
(
await exports.default.fetch(`${ORIGIN}/club/${open}/members/requesttojoin`, {
method: 'PUT',
})
).status
).toBe(401)
expect((await request(99999, '830')).status).toBe(404)
})
test('members/leave drops a membership and withdraws a pending request', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const call = async (clubId: number, sub: string, action: 'requesttojoin' | 'leave') =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/${action}`, {
method: action === 'leave' ? 'POST' : 'PUT',
headers: await bearer(sub),
})
type Details = {
success: boolean
value: { Club: { MemberCount: number }; MyMembershipType: number } | null
}
const open = await create('840', { name: 'Revolving', joinability: 'Open' })
const ask = await create('841', { name: 'Waitlist', joinability: 'AskToJoin' })
// A member leaves → membership 0, and the count drops back to the creator alone.
await call(open, '850', 'requesttojoin')
const left = (await (await call(open, '850', 'leave')).json()) as Details
expect(left.success).toBe(true)
expect(left.value?.MyMembershipType).toBe(0)
expect(left.value?.Club.MemberCount).toBe(1)
// Leaving again is a no-op, not an error.
expect(
(((await (await call(open, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType
).toBe(0)
// Leaving withdraws a pending request too.
expect(
(((await (await call(ask, '850', 'requesttojoin')).json()) as Details).value ?? {})
.MyMembershipType
).toBe(1)
expect(
(((await (await call(ask, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType
).toBe(0)
// The creator can't leave their own club — they'd leave it ownerless.
for (const path of [`/club/${open}/members/leave`, `/club/${open}/leave`]) {
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: await bearer('840'),
})
expect(res.status).toBe(403)
}
// ...and they're still the creator afterwards.
const stillIn = (await (
await exports.default.fetch(`${ORIGIN}/club/${open}/details`, {
headers: await bearer('840'),
})
).json()) as { MyMembershipType: number }
expect(stillIn.MyMembershipType).toBe(100)
// No token, and an unknown club.
expect(
(await exports.default.fetch(`${ORIGIN}/club/${open}/members/leave`, { method: 'POST' }))
.status
).toBe(401)
expect((await call(99999, '850', 'leave')).status).toBe(404)
})
test('DELETE /club/:id is the creators only, and takes the memberships with it', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const del = async (clubId: number, sub?: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}`, {
method: 'DELETE',
...(sub === undefined ? {} : { headers: await bearer(sub) }),
})
const clubId = await create('860', { name: 'Doomed', joinability: 'Open' })
await exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, {
method: 'PUT',
headers: await bearer('861'),
})
// Signed out, a plain member, and an unknown club.
expect((await del(clubId)).status).toBe(401)
expect((await del(clubId, '861')).status).toBe(403)
expect((await del(99999, '860')).status).toBe(404)
// The creator can. The club, and its members, are gone.
const res = await del(clubId, '860')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ error: '', success: true, value: null })
expect((await exports.default.fetch(`${ORIGIN}/club/${clubId}`)).status).toBe(404)
expect(
await (await exports.default.fetch(`${ORIGIN}/club/${clubId}/members`)).json()
).toMatchObject({ value: [] })
const member861 = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('861') })
).json()) as Array<{ ClubId: number }>
expect(member861.map((c) => c.ClubId)).not.toContain(clubId)
// Deleting twice 404s rather than reporting success.
expect((await del(clubId, '860')).status).toBe(404)
})
})
+61
View File
@@ -8,6 +8,7 @@ import {
deleteExpiredPresence,
deletePresence,
getAccount,
getClubSummary,
getExpiredPresenceInstanceIds,
getJoinableInstance,
getOrCreateDormRoom,
@@ -16,6 +17,7 @@ import {
getRoomById,
getRoomByName,
getRoomInstancesByRoom,
isClubMember,
refreshInstanceFullness,
RoomInstanceType,
setPresence,
@@ -718,6 +720,65 @@ const app = new Hono<App>()
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Matchmake into a club's clubhouse (`/matchmake/club/{clubId}`). Registered before
// the single-segment `/matchmake/:room` route so `club` isn't read as a room name.
// Members only: the clubhouse is the club's private space, so a non-member (or
// someone with a pending request, or banned) is refused rather than let in.
.post(
'/matchmake/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a clubs 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 ' +
'club is unknown, has no clubhouse set, or the caller isnt a member.',
security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [
{
name: 'clubId',
in: 'path',
required: true,
description: 'Club id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeResponse,
'The clubhouse instance (or errorCode 20 with null when it cant be entered)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClubSummary(c.env.DB, clubId)
// One response for "no such club", "no clubhouse", and "not a member": the
// client only needs "you're not going there", and a distinct code for the last
// case would tell a non-member which clubs exist and have a clubhouse.
if (!club?.clubhouseRoomId) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (!(await isClubMember(c.env.DB, clubId, id))) {
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
}
const joinMode = await readJoinMode(c)
const instance = await resolveRoomInstance(
c,
String(club.clubhouseRoomId),
joinMode === 2,
id
)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
// — the client uses this to enter a room's other scenes). The subroom decides the
// scene the client loads and which instances are joinable, so it must be carried
@@ -108,6 +108,40 @@ beforeAll(async () => {
insertAccount.bind(JSON.stringify({ accountId: 42, username: 'Tester' })),
insertAccount.bind(JSON.stringify({ accountId: 43, username: 'Roomie' })),
])
// Club tables (owned by the clubs worker) — matchmake/club reads the clubhouse
// room and the caller's membership from them.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS club (
data TEXT NOT NULL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL
)`
).run()
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS club_member (
club_member_id INTEGER PRIMARY KEY AUTOINCREMENT,
club_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
membership_type INTEGER NOT NULL DEFAULT 0,
created_at TEXT
)`
).run()
const insertClub = env.DB.prepare('INSERT OR IGNORE INTO club (data) VALUES (?1)')
await env.DB.batch([
// Club 4 has room 2 as its clubhouse; club 5 has none set.
insertClub.bind(JSON.stringify({ ClubId: 4, Name: 'Clubbers', ClubhouseRoomId: 2 })),
insertClub.bind(JSON.stringify({ ClubId: 5, Name: 'Homeless', ClubhouseRoomId: null })),
])
const insertMember = env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([
insertMember.bind(4, 120, 100), // creator
insertMember.bind(4, 121, 10), // member
insertMember.bind(4, 122, 1), // pending request — not a member yet
insertMember.bind(4, 123, -1), // banned
insertMember.bind(5, 120, 100),
])
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -309,6 +343,58 @@ describe('public endpoints', () => {
expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE })
})
test('POST /matchmake/club/:clubId places members into the clubhouse', async () => {
const matchmake = async (path: string, sub?: string) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(sub === undefined ? {} : await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'JoinMode=0',
})
type Body = {
errorCode: number
roomInstance: { roomId: number; location: string; roomInstanceId: number } | null
}
// A member lands in an instance of the club's clubhouse (room 2)...
const res = await matchmake('/matchmake/club/4', '121')
expect(res.status).toBe(200)
const body = (await res.json()) as Body
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({ roomId: 2, location: RECCENTER_SCENE })
// ...and it's recorded as their presence, like any other matchmake.
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(121)
.first<{ data: string }>()
const presence = JSON.parse(row!.data) as { roomInstance: { roomInstanceId: number } }
expect(presence.roomInstance.roomInstanceId).toBe(body.roomInstance!.roomInstanceId)
// The creator is a member too, and joins the same public instance.
const creator = (await (await matchmake('/matchmake/club/4', '120')).json()) as Body
expect(creator.roomInstance?.roomInstanceId).toBe(body.roomInstance!.roomInstanceId)
// Everyone who isn't a member is turned away with the same answer: a non-member,
// a pending request, a banned account, a club with no clubhouse, an unknown club.
for (const [path, sub] of [
['/matchmake/club/4', '199'],
['/matchmake/club/4', '122'],
['/matchmake/club/4', '123'],
['/matchmake/club/5', '120'],
['/matchmake/club/9999', '120'],
] as const) {
expect(await (await matchmake(path, sub)).json()).toEqual({
errorCode: 20,
roomInstance: null,
})
}
// Signed out is a 401, not a matchmaking error.
expect((await matchmake('/matchmake/club/4')).status).toBe(401)
})
test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
method: 'POST',
@@ -896,6 +982,7 @@ describe('auth-gated endpoints', () => {
'GET /rooms/requiring/rrplus',
'POST /goto/none',
'POST /goto/room/{room}',
'POST /matchmake/club/{clubId}',
'POST /matchmake/none',
'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}',
+64
View File
@@ -0,0 +1,64 @@
/**
* Cross-worker *reads* of the club tables. The `clubs` worker owns the schema and
* every write (see apps/clubs/src/clubs-db.ts); this is the narrow view other
* workers need right now `match`, which has to know a club's clubhouse room and
* whether the player asking for it is actually a member.
*
* Deliberately read-only, and deliberately small: clubs are a JSON blob in
* `club.data`, so anything that needs the whole DTO should go through the clubs
* worker's API rather than growing this file into a second copy of its model.
*/
/**
* A player's membership state in a club (mirror of the clubs worker's
* `ClubMembershipType`). Only the values other workers reason about are named here;
* the tiers between are just higher numbers.
*/
export const CLUB_MEMBERSHIP_BANNED = -1
export const CLUB_MEMBERSHIP_NONE = 0
/** At/above this, a row is an actual member rather than pending/banned. */
export const CLUB_MEMBERSHIP_MEMBER = 10
/** The club fields other workers read. */
export interface ClubSummary {
clubId: number
name: string
/** The room players spawn into for this club; null when it has no clubhouse. */
clubhouseRoomId: number | null
}
/** Look up a club's name and clubhouse room. Null when there's no such club. */
export async function getClubSummary(db: D1Database, clubId: number): Promise<ClubSummary | null> {
const row = await db
.prepare(
`SELECT json_extract(data, '$.Name') AS name,
json_extract(data, '$.ClubhouseRoomId') AS clubhouseRoomId
FROM club WHERE club_id = ?1`
)
.bind(clubId)
.first<{ name: string | null; clubhouseRoomId: number | null }>()
if (row === null) return null
return { clubId, name: row.name ?? '', clubhouseRoomId: row.clubhouseRoomId }
}
/** A player's membership type in a club (0 = no row, i.e. not a member). */
export async function getClubMembership(
db: D1Database,
clubId: number,
accountId: number
): Promise<number> {
const row = await db
.prepare('SELECT membership_type AS t FROM club_member WHERE club_id = ?1 AND account_id = ?2')
.bind(clubId, accountId)
.first<{ t: number }>()
return row?.t ?? CLUB_MEMBERSHIP_NONE
}
/** Whether an account is an actual member of a club (Member tier or above). */
export async function isClubMember(
db: D1Database,
clubId: number,
accountId: number
): Promise<boolean> {
return (await getClubMembership(db, clubId, accountId)) >= CLUB_MEMBERSHIP_MEMBER
}
+1
View File
@@ -1,5 +1,6 @@
export { RoomInstanceType, Accessibility, Role } from './enums'
export * from './accounts-db'
export * from './clubs-db'
export * from './password'
export * from './rooms-db'
export * from './room-instance-db'