add max rooms and clubs per account

This commit is contained in:
Devin Zuczek
2026-07-21 16:49:57 -04:00
parent c853cc1c6f
commit 40c38d7a18
11 changed files with 226 additions and 16 deletions
+17
View File
@@ -778,6 +778,23 @@ export async function deleteClub(db: D1Database, clubId: number): Promise<boolea
*/
const SUBSCRIPTION_CLUB_TYPE = 1
/**
* How many clubs an account has made, for the per-account club cap. Subscription
* clubs don't count — they're provisioned for a creator's subscribers rather than
* made by hand, so they shouldn't eat a slot.
*/
export async function countClubsByCreator(db: D1Database, accountId: number): Promise<number> {
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<Club[]> {
const { results } = await db
+17 -1
View File
@@ -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<App>): Promise<number | null> {
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<App>()
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,
+6
View File
@@ -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
}
@@ -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 }>
+4
View File
@@ -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