mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add max rooms and clubs per account
This commit is contained in:
@@ -20,6 +20,12 @@ export type Env = SharedHonoEnv & {
|
||||
// images/files live here under the `room/` key prefix; bound so deleting a room
|
||||
// can remove its image object.
|
||||
CDN_ASSETS: R2Bucket
|
||||
// How many rooms one account may create (optional). Unset falls back to
|
||||
// DEFAULT_MAX_ROOMS_PER_ACCOUNT in rooms.app.ts; 0 lifts the cap. Typed
|
||||
// `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
|
||||
// number while the same var set from the dashboard or `--var` arrives as a string —
|
||||
// read it through `intVar`, never as a bare number.
|
||||
MAX_ROOMS_PER_ACCOUNT?: string | number
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
canManageRoom,
|
||||
cloneRoom,
|
||||
cloneSubRoom,
|
||||
countRoomsByCreator,
|
||||
deleteRoom,
|
||||
findSubRoom,
|
||||
getBaseRooms,
|
||||
@@ -30,12 +31,12 @@ import {
|
||||
setRoomImage,
|
||||
setRoomName,
|
||||
setRoomRole,
|
||||
updateRoomFields,
|
||||
toggleCheer,
|
||||
toggleFavorite,
|
||||
toggleRoomTag,
|
||||
updateRoomFields,
|
||||
} from '@repo/domain'
|
||||
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
@@ -67,6 +68,15 @@ function allIds(idParam: string): number[] {
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
}
|
||||
|
||||
/**
|
||||
* How many rooms one account may create, when the `MAX_ROOMS_PER_ACCOUNT` var is
|
||||
* unset. Cloning is the only way to make a room, so the cap is enforced there; it
|
||||
* counts rooms the account created, minus their auto-provisioned dorm. Setting the
|
||||
* var to 0 lifts the cap entirely, which a small private server will want. Existing
|
||||
* rooms are never touched — lowering the cap just stops new ones.
|
||||
*/
|
||||
const DEFAULT_MAX_ROOMS_PER_ACCOUNT = 10
|
||||
|
||||
/** Account ids granted the global (Role 0) maker pen — the reference server's
|
||||
* hardcoded moderator/dev accounts. */
|
||||
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
|
||||
@@ -430,6 +440,13 @@ const app = new Hono<App>()
|
||||
if (await getRoomByName(c.env.DB, name)) {
|
||||
return roomEnvelope(c, null, 'A room with that name already exists!')
|
||||
}
|
||||
// Cloning is how a player makes a room, so the per-account cap belongs here.
|
||||
// Checked after the cheap validations so a rejected name costs no extra D1 read.
|
||||
const maxRooms = intVar(c.env.MAX_ROOMS_PER_ACCOUNT, DEFAULT_MAX_ROOMS_PER_ACCOUNT)
|
||||
if (maxRooms > 0 && (await countRoomsByCreator(c.env.DB, accountId)) >= maxRooms) {
|
||||
logger.info('room create rejected: per-account room limit', { accountId })
|
||||
return roomEnvelope(c, null, `You can only have ${maxRooms} rooms.`)
|
||||
}
|
||||
const room = await cloneRoom(
|
||||
c.env.DB,
|
||||
Number.parseInt(c.req.param('roomId'), 10),
|
||||
@@ -670,7 +687,8 @@ const app = new Hono<App>()
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const warningMask =
|
||||
typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN
|
||||
if (Number.isNaN(warningMask)) return roomEnvelope(c, null, 'You must provide a valid warning mask!')
|
||||
if (Number.isNaN(warningMask))
|
||||
return roomEnvelope(c, null, 'You must provide a valid warning mask!')
|
||||
|
||||
const patch: Record<string, unknown> = { WarningMask: warningMask }
|
||||
// Only touch CustomWarning when the field is present (an empty string clears it).
|
||||
@@ -702,7 +720,9 @@ const app = new Hono<App>()
|
||||
}
|
||||
const cloningAllowed = body.cloningAllowed.toLowerCase() === 'true'
|
||||
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, { CloningAllowed: cloningAllowed })
|
||||
const updated = await updateRoomFields(c.env.DB, roomId, room, {
|
||||
CloningAllowed: cloningAllowed,
|
||||
})
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomEnvelope(c, updated)
|
||||
})
|
||||
|
||||
@@ -500,6 +500,56 @@ describe('rooms endpoints', () => {
|
||||
expect(missing).toMatchObject({ success: false, value: null })
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/clone enforces the per-account room cap', async () => {
|
||||
const headers = {
|
||||
...(await bearer('803')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
const clone = async (name: string) =>
|
||||
(await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: new URLSearchParams({ name }).toString(),
|
||||
})
|
||||
).json()) as { success: boolean; error: string; value: unknown }
|
||||
|
||||
// The cap an operator actually runs is the `MAX_ROOMS_PER_ACCOUNT` var; the
|
||||
// constant in the worker is only the fallback.
|
||||
const original = env.MAX_ROOMS_PER_ACCOUNT
|
||||
try {
|
||||
env.MAX_ROOMS_PER_ACCOUNT = 2
|
||||
expect(await clone('CapOne')).toMatchObject({ success: true })
|
||||
expect(await clone('CapTwo')).toMatchObject({ success: true })
|
||||
|
||||
const rejected = await clone('CapThree')
|
||||
expect(rejected).toMatchObject({ success: false, value: null })
|
||||
expect(rejected.error).toMatch(/only have 2 rooms/i)
|
||||
|
||||
// A dorm doesn't count against the cap — it's auto-provisioned, not made.
|
||||
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
RoomId: 30303,
|
||||
Name: '^Dorm803',
|
||||
CreatorAccountId: 803,
|
||||
IsDorm: true,
|
||||
SubRooms: [],
|
||||
Roles: [],
|
||||
})
|
||||
)
|
||||
.run()
|
||||
env.MAX_ROOMS_PER_ACCOUNT = 3
|
||||
expect(await clone('CapThreeForReal')).toMatchObject({ success: true })
|
||||
|
||||
// 0 lifts the cap entirely.
|
||||
env.MAX_ROOMS_PER_ACCOUNT = 0
|
||||
expect(await clone('Uncapped')).toMatchObject({ success: true })
|
||||
} finally {
|
||||
env.MAX_ROOMS_PER_ACCOUNT = original
|
||||
}
|
||||
})
|
||||
|
||||
const putForm = async (path: string, fields: Record<string, string>, sub?: string) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'PUT',
|
||||
@@ -614,7 +664,10 @@ describe('rooms endpoints', () => {
|
||||
|
||||
// No token → 401. A non-owner → Success:false (room untouched).
|
||||
expect((await del()).status).toBe(401)
|
||||
expect(await bodyOf(await del('2'))).toMatchObject({ Success: false, ErrorId: 'Rooms.NotOwner' })
|
||||
expect(await bodyOf(await del('2'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.NotOwner',
|
||||
})
|
||||
expect(await roomExists()).toBe(true)
|
||||
|
||||
// Owner → Success:true; the room, its interactions, and the CDN image are gone.
|
||||
@@ -656,7 +709,7 @@ describe('rooms endpoints', () => {
|
||||
expect(ok.status).toBe(200)
|
||||
const okBody = await envOf(ok)
|
||||
expect(okBody).toMatchObject({ success: true, error: '' })
|
||||
expect((okBody.value?.Roles as Array<{ AccountId: number; Role: number }>)).toContainEqual(
|
||||
expect(okBody.value?.Roles as Array<{ AccountId: number; Role: number }>).toContainEqual(
|
||||
expect.objectContaining({ AccountId: 5, Role: 20 })
|
||||
)
|
||||
expect(await rolesOf()).toContainEqual(expect.objectContaining({ AccountId: 5, Role: 20 }))
|
||||
@@ -726,9 +779,7 @@ describe('rooms endpoints', () => {
|
||||
// No token → 401.
|
||||
expect((await putForm('/rooms/2/cloning', { cloningAllowed: 'False' })).status).toBe(401)
|
||||
// A valid token but no role on the room → 403.
|
||||
expect(
|
||||
(await putForm('/rooms/2/cloning', { cloningAllowed: 'False' }, '999')).status
|
||||
).toBe(403)
|
||||
expect((await putForm('/rooms/2/cloning', { cloningAllowed: 'False' }, '999')).status).toBe(403)
|
||||
// Unknown room → failure envelope.
|
||||
expect(
|
||||
await envOf(await putForm('/rooms/99999/cloning', { cloningAllowed: 'False' }, '1'))
|
||||
@@ -736,14 +787,18 @@ describe('rooms endpoints', () => {
|
||||
|
||||
// Owner disables cloning; it persists as a real JSON boolean (not 0/1). The
|
||||
// success envelope carries the updated room as `value`.
|
||||
const disabled = await envOf(await putForm('/rooms/2/cloning', { cloningAllowed: 'False' }, '1'))
|
||||
const disabled = await envOf(
|
||||
await putForm('/rooms/2/cloning', { cloningAllowed: 'False' }, '1')
|
||||
)
|
||||
expect(disabled).toMatchObject({ success: true, error: '' })
|
||||
expect(disabled.value?.CloningAllowed).toBe(false)
|
||||
const raw = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
|
||||
.bind(2)
|
||||
.first<{ data: string }>()
|
||||
expect(raw!.data).toContain('"CloningAllowed":false')
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { CloningAllowed: boolean }
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
||||
CloningAllowed: boolean
|
||||
}
|
||||
expect(room.CloningAllowed).toBe(false)
|
||||
|
||||
// The co-owner (account 2) may re-enable it.
|
||||
@@ -842,7 +897,9 @@ describe('rooms endpoints', () => {
|
||||
it('PUT /rooms/:id/accessibility sets the room-level Accessibility (auth-gated, owner/co-owner-only)', async () => {
|
||||
// No token → 401; a valid token with no role → 403.
|
||||
expect((await putForm('/rooms/2/accessibility', { accessibility: '1' })).status).toBe(401)
|
||||
expect((await putForm('/rooms/2/accessibility', { accessibility: '1' }, '999')).status).toBe(403)
|
||||
expect((await putForm('/rooms/2/accessibility', { accessibility: '1' }, '999')).status).toBe(
|
||||
403
|
||||
)
|
||||
// Unknown room → failure envelope.
|
||||
expect(
|
||||
await envOf(await putForm('/rooms/99999/accessibility', { accessibility: '1' }, '1'))
|
||||
@@ -956,7 +1013,11 @@ describe('rooms endpoints', () => {
|
||||
|
||||
it('PUT /rooms/:id/tags is auth-gated, owner-only, and toggles (add/remove)', async () => {
|
||||
// The lowercase `{ success, error, value }` envelope this endpoint returns.
|
||||
type TagResult = { success: boolean; error: string; value: { Tags?: Array<{ Tag: string }> } | null }
|
||||
type TagResult = {
|
||||
success: boolean
|
||||
error: string
|
||||
value: { Tags?: Array<{ Tag: string }> } | null
|
||||
}
|
||||
const envOf = async (res: Response) => (await res.json()) as TagResult
|
||||
const tagsIn = (r: TagResult) => (r.value?.Tags ?? []).map((t) => t.Tag)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user