diff --git a/.env.example b/.env.example index b7f3df3..823cb41 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,14 @@ RECFLARE_DOMAIN=rec.example.com # RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3 # RECFLARE_MAX_ACCOUNTS_PER_IP=3 +# How many rooms one account may create (`rooms`) and how many clubs (`clubs`). +# Enforced on creation only — lowering either never touches what players already have, +# it just stops new ones. Set either to 0 to turn that cap off. +# ...ROOMS counts rooms the account created, minus their auto-provisioned dorm. +# ...CLUBS counts clubs the account created (subscription clubs don't count). +# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10 +# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10 + # RecCenterTokens a new player is granted, the first time their balance is read (`econ`). # 0 means players start broke. Applies only to players who haven't been granted yet — # raising it later does NOT top up existing players. diff --git a/apps/clubs/src/clubs-db.ts b/apps/clubs/src/clubs-db.ts index cb7742b..2fff3ca 100644 --- a/apps/clubs/src/clubs-db.ts +++ b/apps/clubs/src/clubs-db.ts @@ -778,6 +778,23 @@ export async function deleteClub(db: D1Database, clubId: number): Promise { + const row = await db + .prepare( + `SELECT COUNT(*) AS n FROM club + WHERE creator_account_id = ?1 + AND json_extract(data, '$.ClubType') != ?2` + ) + .bind(accountId, SUBSCRIPTION_CLUB_TYPE) + .first<{ n: number }>() + return row?.n ?? 0 +} + /** All clubs created by an account (GetMyCreatedClubs), oldest first. */ export async function getClubsByCreator(db: D1Database, accountId: number): Promise { const { results } = await db diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 95b5c0a..e680bf1 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' -import { withNotFound, withOnError } from '@repo/hono-helpers' +import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' import { @@ -9,6 +9,7 @@ import { ClubJoinability, ClubMembershipType, ClubVisibility, + countClubsByCreator, createClub, createClubAnnouncement, deleteClub, @@ -41,6 +42,14 @@ async function authedId(c: Context): Promise { return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } +/** + * How many clubs one account may create, when the `MAX_CLUBS_PER_ACCOUNT` var is + * unset. Counts the clubs the account created (subscription clubs excluded — those + * aren't made by hand). Setting the var to 0 lifts the cap entirely. Existing clubs + * are never touched: lowering the cap just stops new ones. + */ +const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10 + /** Longest a club name may be (the reference's MaxNameLength). */ const MAX_CLUB_NAME_LENGTH = 16 @@ -323,6 +332,13 @@ const app = new Hono() 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, diff --git a/apps/clubs/src/context.ts b/apps/clubs/src/context.ts index 5061140..6dcef0d 100644 --- a/apps/clubs/src/context.ts +++ b/apps/clubs/src/context.ts @@ -8,6 +8,12 @@ export type Env = SharedHonoEnv & { JWT_SECRET: SecretsStoreSecret // Shared `recflare` D1 database holding the club / club_member tables. See clubs-db.ts. DB: D1Database + // How many clubs one account may create (optional). Unset falls back to + // DEFAULT_MAX_CLUBS_PER_ACCOUNT in clubs.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_CLUBS_PER_ACCOUNT?: string | number // add additional Bindings here } diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 2af4e67..fbc48ca 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -706,6 +706,51 @@ describe('clubs endpoints', () => { ).toBe(404) }) + test('POST /club/create enforces the per-account club cap', async () => { + const create = async (name: string) => + exports.default.fetch(`${ORIGIN}/club/create`, { + method: 'POST', + headers: { ...(await bearer('7200')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ name }).toString(), + }) + + // The cap an operator actually runs is the `MAX_CLUBS_PER_ACCOUNT` var; the + // constant in the worker is only the fallback. + const original = env.MAX_CLUBS_PER_ACCOUNT + try { + env.MAX_CLUBS_PER_ACCOUNT = 2 + expect((await create('CapOne')).status).toBe(200) + expect((await create('CapTwo')).status).toBe(200) + + const rejected = await create('CapThree') + expect(rejected.status).toBe(400) + const body = (await rejected.json()) as { error: string; success: boolean } + expect(body.success).toBe(false) + expect(body.error).toMatch(/only have 2 clubs/i) + + // A subscription club doesn't count against the cap — it isn't made by hand. + await env.DB.prepare('INSERT INTO club (data) VALUES (?1)') + .bind( + JSON.stringify({ + ClubId: 9500, + Name: 'Subs7200', + ClubType: 1, + CreatorAccountId: 7200, + CreatedAt: '2026-07-01T00:00:00Z', + }) + ) + .run() + env.MAX_CLUBS_PER_ACCOUNT = 3 + expect((await create('CapThreeReal')).status).toBe(200) + + // 0 lifts the cap entirely. + env.MAX_CLUBS_PER_ACCOUNT = 0 + expect((await create('Uncapped')).status).toBe(200) + } finally { + env.MAX_CLUBS_PER_ACCOUNT = original + } + }) + test('GET /club/search filters by category/query and sorts', async () => { type Result = { Clubs: Array<{ ClubId: number; Name: string; Category: string }> diff --git a/apps/clubs/wrangler.jsonc b/apps/clubs/wrangler.jsonc index 4b4c497..5709a62 100644 --- a/apps/clubs/wrangler.jsonc +++ b/apps/clubs/wrangler.jsonc @@ -35,6 +35,10 @@ "head_sampling_rate": 1 // 100% } }, + // The per-account cap (MAX_CLUBS_PER_ACCOUNT) is deliberately NOT set here. It's + // injected at deploy time from the gitignored .env (RECFLARE_MAX_CLUBS_PER_ACCOUNT, see + // .env.example), so tuning it never means editing a versioned file. Unset — the + // default — falls back to DEFAULT_MAX_CLUBS_PER_ACCOUNT in src/clubs.app.ts. "vars": { "ENVIRONMENT": "development", // overridden during deployment "SENTRY_RELEASE": "unknown" // overridden during deployment diff --git a/apps/rooms/src/context.ts b/apps/rooms/src/context.ts index eb26fb7..ac62a5e 100644 --- a/apps/rooms/src/context.ts +++ b/apps/rooms/src/context.ts @@ -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 */ diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index e35f327..6d72f6e 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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() 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() const body = (await c.req.parseBody().catch(() => ({}))) as Record 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 = { WarningMask: warningMask } // Only touch CustomWarning when the field is present (an empty string clears it). @@ -702,7 +720,9 @@ const app = new Hono() } 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) }) diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 2f42c96..ba800a1 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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, 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) diff --git a/apps/rooms/wrangler.jsonc b/apps/rooms/wrangler.jsonc index 48ea6e9..ac7d180 100644 --- a/apps/rooms/wrangler.jsonc +++ b/apps/rooms/wrangler.jsonc @@ -56,6 +56,10 @@ "head_sampling_rate": 1 // 100% } }, + // The per-account cap (MAX_ROOMS_PER_ACCOUNT) is deliberately NOT set here. It's + // injected at deploy time from the gitignored .env (RECFLARE_MAX_ROOMS_PER_ACCOUNT, see + // .env.example), so tuning it never means editing a versioned file. Unset — the + // default — falls back to DEFAULT_MAX_ROOMS_PER_ACCOUNT in src/rooms.app.ts. "vars": { "ENVIRONMENT": "development", // overridden during deployment "SENTRY_RELEASE": "unknown" // overridden during deployment diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index d127d03..3d50710 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -145,7 +145,11 @@ export async function setRoomName(db: D1Database, roomId: number, name: string): } /** Set a room's ImageName in place (the caller is responsible for the owner check). */ -export async function setRoomImage(db: D1Database, roomId: number, imageName: string): Promise { +export async function setRoomImage( + db: D1Database, + roomId: number, + imageName: string +): Promise { await db .prepare("UPDATE room SET data = json_set(data, '$.ImageName', ?2) WHERE room_id = ?1") .bind(roomId, imageName) @@ -444,6 +448,23 @@ export async function getRoomsByCreator(db: D1Database, accountId: number): Prom return parseAll(results) } +/** + * How many rooms an account has made, for the per-account room cap. Dorms don't + * count: every player gets one auto-provisioned, so counting it would silently cost + * them a slot they never asked for. + */ +export async function countRoomsByCreator(db: D1Database, accountId: number): Promise { + const row = await db + .prepare( + `SELECT COUNT(*) AS n FROM room + WHERE creator_account_id = ?1 + AND COALESCE(is_dorm, 0) = 0` + ) + .bind(accountId) + .first<{ n: number }>() + return row?.n ?? 0 +} + /** * An account's public, non-dorm rooms — the publicly viewable "rooms owned by * " list (excludes private rooms, dorms, and list-excluded rooms). @@ -900,7 +921,9 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr Name: `@${username}'s Dorm`, CreatorAccountId: accountId, IsDorm: true, - Roles: [{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 }], + Roles: [ + { AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 }, + ], SubRooms: [{ ...templateSub, CreatorAccountId: accountId }], CreatedAt: new Date().toISOString(), }