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:
@@ -52,6 +52,14 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
|
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
|
||||||
# RECFLARE_MAX_ACCOUNTS_PER_IP=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`).
|
# 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 —
|
# 0 means players start broke. Applies only to players who haven't been granted yet —
|
||||||
# raising it later does NOT top up existing players.
|
# raising it later does NOT top up existing players.
|
||||||
|
|||||||
@@ -778,6 +778,23 @@ export async function deleteClub(db: D1Database, clubId: number): Promise<boolea
|
|||||||
*/
|
*/
|
||||||
const SUBSCRIPTION_CLUB_TYPE = 1
|
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. */
|
/** All clubs created by an account (GetMyCreatedClubs), oldest first. */
|
||||||
export async function getClubsByCreator(db: D1Database, accountId: number): Promise<Club[]> {
|
export async function getClubsByCreator(db: D1Database, accountId: number): Promise<Club[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
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 { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ClubJoinability,
|
ClubJoinability,
|
||||||
ClubMembershipType,
|
ClubMembershipType,
|
||||||
ClubVisibility,
|
ClubVisibility,
|
||||||
|
countClubsByCreator,
|
||||||
createClub,
|
createClub,
|
||||||
createClubAnnouncement,
|
createClubAnnouncement,
|
||||||
deleteClub,
|
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())
|
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). */
|
/** Longest a club name may be (the reference's MaxNameLength). */
|
||||||
const MAX_CLUB_NAME_LENGTH = 16
|
const MAX_CLUB_NAME_LENGTH = 16
|
||||||
|
|
||||||
@@ -323,6 +332,13 @@ const app = new Hono<App>()
|
|||||||
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
||||||
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
|
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, {
|
const club = await createClub(c.env.DB, id, {
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export type Env = SharedHonoEnv & {
|
|||||||
JWT_SECRET: SecretsStoreSecret
|
JWT_SECRET: SecretsStoreSecret
|
||||||
// Shared `recflare` D1 database holding the club / club_member tables. See clubs-db.ts.
|
// Shared `recflare` D1 database holding the club / club_member tables. See clubs-db.ts.
|
||||||
DB: D1Database
|
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
|
// add additional Bindings here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -706,6 +706,51 @@ describe('clubs endpoints', () => {
|
|||||||
).toBe(404)
|
).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 () => {
|
test('GET /club/search filters by category/query and sorts', async () => {
|
||||||
type Result = {
|
type Result = {
|
||||||
Clubs: Array<{ ClubId: number; Name: string; Category: string }>
|
Clubs: Array<{ ClubId: number; Name: string; Category: string }>
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
"head_sampling_rate": 1 // 100%
|
"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": {
|
"vars": {
|
||||||
"ENVIRONMENT": "development", // overridden during deployment
|
"ENVIRONMENT": "development", // overridden during deployment
|
||||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export type Env = SharedHonoEnv & {
|
|||||||
// images/files live here under the `room/` key prefix; bound so deleting a room
|
// images/files live here under the `room/` key prefix; bound so deleting a room
|
||||||
// can remove its image object.
|
// can remove its image object.
|
||||||
CDN_ASSETS: R2Bucket
|
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 */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
canManageRoom,
|
canManageRoom,
|
||||||
cloneRoom,
|
cloneRoom,
|
||||||
cloneSubRoom,
|
cloneSubRoom,
|
||||||
|
countRoomsByCreator,
|
||||||
deleteRoom,
|
deleteRoom,
|
||||||
findSubRoom,
|
findSubRoom,
|
||||||
getBaseRooms,
|
getBaseRooms,
|
||||||
@@ -30,12 +31,12 @@ import {
|
|||||||
setRoomImage,
|
setRoomImage,
|
||||||
setRoomName,
|
setRoomName,
|
||||||
setRoomRole,
|
setRoomRole,
|
||||||
updateRoomFields,
|
|
||||||
toggleCheer,
|
toggleCheer,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
toggleRoomTag,
|
toggleRoomTag,
|
||||||
|
updateRoomFields,
|
||||||
} from '@repo/domain'
|
} 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 { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
@@ -67,6 +68,15 @@ function allIds(idParam: string): number[] {
|
|||||||
.filter((n) => !Number.isNaN(n))
|
.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
|
/** Account ids granted the global (Role 0) maker pen — the reference server's
|
||||||
* hardcoded moderator/dev accounts. */
|
* hardcoded moderator/dev accounts. */
|
||||||
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
|
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)) {
|
if (await getRoomByName(c.env.DB, name)) {
|
||||||
return roomEnvelope(c, null, 'A room with that name already exists!')
|
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(
|
const room = await cloneRoom(
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
Number.parseInt(c.req.param('roomId'), 10),
|
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 body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const warningMask =
|
const warningMask =
|
||||||
typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN
|
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 }
|
const patch: Record<string, unknown> = { WarningMask: warningMask }
|
||||||
// Only touch CustomWarning when the field is present (an empty string clears it).
|
// 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 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)
|
await pushRoomUpdate(c, accountId, updated)
|
||||||
return roomEnvelope(c, updated)
|
return roomEnvelope(c, updated)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -500,6 +500,56 @@ describe('rooms endpoints', () => {
|
|||||||
expect(missing).toMatchObject({ success: false, value: null })
|
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) =>
|
const putForm = async (path: string, fields: Record<string, string>, sub?: string) =>
|
||||||
SELF.fetch(`${ORIGIN}${path}`, {
|
SELF.fetch(`${ORIGIN}${path}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -614,7 +664,10 @@ describe('rooms endpoints', () => {
|
|||||||
|
|
||||||
// No token → 401. A non-owner → Success:false (room untouched).
|
// No token → 401. A non-owner → Success:false (room untouched).
|
||||||
expect((await del()).status).toBe(401)
|
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)
|
expect(await roomExists()).toBe(true)
|
||||||
|
|
||||||
// Owner → Success:true; the room, its interactions, and the CDN image are gone.
|
// 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)
|
expect(ok.status).toBe(200)
|
||||||
const okBody = await envOf(ok)
|
const okBody = await envOf(ok)
|
||||||
expect(okBody).toMatchObject({ success: true, error: '' })
|
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.objectContaining({ AccountId: 5, Role: 20 })
|
||||||
)
|
)
|
||||||
expect(await rolesOf()).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.
|
// No token → 401.
|
||||||
expect((await putForm('/rooms/2/cloning', { cloningAllowed: 'False' })).status).toBe(401)
|
expect((await putForm('/rooms/2/cloning', { cloningAllowed: 'False' })).status).toBe(401)
|
||||||
// A valid token but no role on the room → 403.
|
// A valid token but no role on the room → 403.
|
||||||
expect(
|
expect((await putForm('/rooms/2/cloning', { cloningAllowed: 'False' }, '999')).status).toBe(403)
|
||||||
(await putForm('/rooms/2/cloning', { cloningAllowed: 'False' }, '999')).status
|
|
||||||
).toBe(403)
|
|
||||||
// Unknown room → failure envelope.
|
// Unknown room → failure envelope.
|
||||||
expect(
|
expect(
|
||||||
await envOf(await putForm('/rooms/99999/cloning', { cloningAllowed: 'False' }, '1'))
|
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
|
// Owner disables cloning; it persists as a real JSON boolean (not 0/1). The
|
||||||
// success envelope carries the updated room as `value`.
|
// 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).toMatchObject({ success: true, error: '' })
|
||||||
expect(disabled.value?.CloningAllowed).toBe(false)
|
expect(disabled.value?.CloningAllowed).toBe(false)
|
||||||
const raw = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
|
const raw = await env.DB.prepare('SELECT data FROM room WHERE room_id = ?1')
|
||||||
.bind(2)
|
.bind(2)
|
||||||
.first<{ data: string }>()
|
.first<{ data: string }>()
|
||||||
expect(raw!.data).toContain('"CloningAllowed":false')
|
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)
|
expect(room.CloningAllowed).toBe(false)
|
||||||
|
|
||||||
// The co-owner (account 2) may re-enable it.
|
// 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 () => {
|
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.
|
// 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' })).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.
|
// Unknown room → failure envelope.
|
||||||
expect(
|
expect(
|
||||||
await envOf(await putForm('/rooms/99999/accessibility', { accessibility: '1' }, '1'))
|
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 () => {
|
it('PUT /rooms/:id/tags is auth-gated, owner-only, and toggles (add/remove)', async () => {
|
||||||
// The lowercase `{ success, error, value }` envelope this endpoint returns.
|
// 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 envOf = async (res: Response) => (await res.json()) as TagResult
|
||||||
const tagsIn = (r: TagResult) => (r.value?.Tags ?? []).map((t) => t.Tag)
|
const tagsIn = (r: TagResult) => (r.value?.Tags ?? []).map((t) => t.Tag)
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@
|
|||||||
"head_sampling_rate": 1 // 100%
|
"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": {
|
"vars": {
|
||||||
"ENVIRONMENT": "development", // overridden during deployment
|
"ENVIRONMENT": "development", // overridden during deployment
|
||||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||||
|
|||||||
@@ -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). */
|
/** 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<void> {
|
export async function setRoomImage(
|
||||||
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
imageName: string
|
||||||
|
): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.prepare("UPDATE room SET data = json_set(data, '$.ImageName', ?2) WHERE room_id = ?1")
|
.prepare("UPDATE room SET data = json_set(data, '$.ImageName', ?2) WHERE room_id = ?1")
|
||||||
.bind(roomId, imageName)
|
.bind(roomId, imageName)
|
||||||
@@ -444,6 +448,23 @@ export async function getRoomsByCreator(db: D1Database, accountId: number): Prom
|
|||||||
return parseAll(results)
|
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<number> {
|
||||||
|
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
|
* An account's public, non-dorm rooms — the publicly viewable "rooms owned by
|
||||||
* <player>" list (excludes private rooms, dorms, and list-excluded rooms).
|
* <player>" 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`,
|
Name: `@${username}'s Dorm`,
|
||||||
CreatorAccountId: accountId,
|
CreatorAccountId: accountId,
|
||||||
IsDorm: true,
|
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 }],
|
SubRooms: [{ ...templateSub, CreatorAccountId: accountId }],
|
||||||
CreatedAt: new Date().toISOString(),
|
CreatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user