Player caps were two settings pretending to be one. RoomEntity.MaxCapacity is
what the ROOM advertises (and what matchmaking hands to
RoomInstance.MaxCapacity); RoomSceneEntity.MaxPlayers is what ONE sub-room caps
itself at. The details payload was reading its room-level MaxPlayers off
sub-room 0, which collapsed the two — editing a sub-room would have moved the
room's advertised capacity with it. It now reports MaxCapacity, and each entry
in SubRooms[] reports its own figure.
The in-game path already existed (the "Max Player Count in This Subroom"
slider). Admin had no way to reach it at all — only zip import ever set it — so
a cap could not be corrected on a room the admin does not own:
GET api/admin/v1/rooms/{id}/subrooms
PUT api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayers {"MaxPlayers":n}
subRoomId is the sub-room's ORDER INDEX — the id the client and the rest of the
wire use — not the row's primary key. Row ids are serialised as strings because
the admin SPA is JavaScript and mangles integers past 2^53. Both paths clamp to
1..80, matching rooms/{id}/props: 0 would make a sub-room unjoinable, and a
shared range stops the two settings disagreeing about what is legal.
The test drives the client's exact wire rather than a convenient one:
NLDBPDCNNCF.MNBPCGJNLNP builds a BLNOGFGHIIF whose single JSON key is
"maxPlayers" and PUTs it — this route does NOT form-encode like most of its
siblings, so a form-bodied test would have passed while the real client's body
went unread. It also asserts the reply carries the new value, since the client
deserialises it back as FGCPNAACHIK.
10 KiB
DorkNet Admin Page
The admin page is the browser operations console for DorkNet. In the
microservices deployment it lives at https://admin.<domain>/ and is
served by the web service from the built Vite SPA assets.
The admin backend endpoints are under /api/admin/v1. Requests from the
SPA are same-origin, so https://admin.<domain>/api/admin/v1/* is also
handled by the web service route on the admin host. The same
/api/admin/* route family is available as a path-owned moderation slice
for non-admin-host routing, so /internal/routes can show moderation as
the owner for that path family.
Runtime Routing
| Item | Value |
|---|---|
| SPA source | DorkNet.Server/admin-ui |
| Build output | DorkNet.Server/wwwroot/admin |
| Container path | /app/wwwroot/admin |
| Public browser host | admin.<domain> |
| API base used by the SPA | /api/admin/v1 |
| Login endpoint | POST /api/admin/v1/login |
| Same-origin admin-host service | web |
| Path-routed admin API slice | moderation |
DorkNet.Gateway preserves the original Host header when it proxies to
backend services. That matters for the admin page: the admin.<domain>
host route must reach web, and web must contain
/app/wwwroot/admin/index.html.
Authentication
The first account created on a fresh database is promoted to admin so
there is always a bootstrap operator. The login form posts to
/api/admin/v1/login; the SPA stores the returned values in browser
localStorage:
| Key | Contents |
|---|---|
dorknet.admin.token |
DorkNet admin JWT |
dorknet.admin.me |
Admin identity object used by the layout |
Every admin API request sends Authorization: Bearer <token>. Protected
actions are guarded by AdminOnlyAttribute, which checks the resolved
player row still has IsAdmin = true. A 401 clears the local session
and sends the browser back to login.
If the admin host is public, put it behind Cloudflare Access or an equivalent outer access control. The DorkNet JWT is still required after that outer check.
Navigation
| Section | Routes | Workflows |
|---|---|---|
| Overview | / |
Live ops dashboard, online players, active sessions, quick kick/ban/broadcast actions |
| Moderation | /players |
Player directory, bans, reports, per-player ban/grant/gift/password/avatar/account actions |
| Activity | /activity |
Admin audit log and per-player request logs |
| Content | /rooms, /rooms/:id, /import-room, /content |
Room list/detail, room import, instances, leaderboards, community board, loading tips, 3D Charades word lists |
| Operations | /broadcast, /settings |
Server broadcast, server toggles (signups, everyone-is-friends, profanity filter, imported-room version clamp), signup codes, weekly challenges, Play menu tags, Rec Center doors, game config values |
Several older admin URLs are kept as redirects:
| Old route | Current route |
|---|---|
/bans |
/players?tab=bans |
/reports |
/players?tab=reports |
/gift, /passwords, /grants |
/players |
/audit |
/activity?tab=audit |
/logs |
/activity?tab=logs |
/rr-originals, /instances, /leaderboards |
/rooms |
/community |
/content?tab=community |
/loading-tips |
/content?tab=tips |
/signup-codes |
/settings?tab=signup |
/import-room-legacy still exists for the legacy room importer.
3D Charades word lists (/content?tab=charades)
The March 2023 client fetches a charades deck at card-box spawn from
GET api/activities/charades/v1/words/{source}, where {source} is one
of three baked CardBox.cardSource slots — Charades,
CharadesAprilFoolsDay, and Icebreakers (verified in the 2023.03.21
il2cpp dump). The response is a JSON array of
{ "EN_US": "<phrase>", "Difficulty": <int> } (Difficulty is the client
CNMMMNJJDMM enum: 0 easy, 1 hard, 10 very hard, 20 icebreaker).
The admin tab exposes:
- A library of unlimited named word lists (
CharadesWordListEntityrows), each with a per-card difficulty. Paste-import accepts one phrase per line with an optional| easy|hard|veryhard|icebreakersuffix. - Live card slots — three dropdowns binding each client slot to any
library list. Switching a slot just repoints its binding
(
ServerSettingsEntity.CharadesSlotBindingsJson); it takes effect on the next card-box refresh (room rejoin). An unbound slot falls back to the built-in list seeded for it.
Three built-in lists (Default / April Fools / Icebreakers) are seeded on first boot and bound to their slots. Seeding is idempotent — admin edits survive restarts.
Profanity filter toggle (/settings)
ServerSettingsEntity.ProfanityFilterDisabled gates the server-side
api/sanitize/* filter. When on, every sanitize route returns input
unchanged and treats all text as clean, so room/invention names and chat
are never censored. Off by default; checked per request so it takes effect
immediately.
Imported-room version clamp (/settings)
ServerSettingsEntity.RoomBlobVersionClampDisabled gates the CDN's
PersistedRoomData version clamp
(RoomDataBlobService.ClampVersionsFor2023). Rooms imported from modern
RecNet zip exports carry room-data blobs whose top-level version varints
(field 1 DEPRECATED_RoomPersistenceVersion, field 30
PersistedRoomVersion) are far past what the March-2023 client knows —
a Sep-2025 save stamps version=131 against the client's maximum of 16
(RoomDataBlobService.Client2023MaxPersistedRoomVersion; the proto's
LatestVersion=19 came from a newer build's dump and this client rejects
it) — and the client rejects the whole room with its "update Rec Room to
visit this room" gate before spawning anything.
This clamp only addresses the room-header version gate. A room whose
CircuitsV2 chip graph (circuit_data, field 18) was saved in a newer Rec
Room is rejected by a separate CircuitsV2 version gate regardless — check
the client's Player.log, where the stack frame under "Booting player to
dorm" names the failing subsystem (CircuitsV2Manager = the circuit
graph, not the header). That case is not fixable by this clamp.
While the clamp is active (the default), the CDN serve path rewrites the
two varints down to the 2023 maxima on the way out for .room / .meta /
.dat blobs. The rewrite is wire-surgical: every other byte is preserved,
already-compatible blobs pass through byte-identical, and nothing stored
in S3 is modified. Toggle endpoint:
POST api/admin/v1/settings/room-blob-version-clamp with
{"Disabled": bool}. Clamped responses are served with a 60s edge TTL so
flipping the toggle lands within a minute.
Per-sub-room max players (/rooms → room detail)
Player caps are two separate settings, and conflating them is the easy mistake here:
| Setting | Column | Scope |
|---|---|---|
MaxCapacity |
RoomEntity.MaxCapacity |
What the ROOM advertises; flows into RoomInstance.MaxCapacity for matchmaking and into the room-level MaxPlayers of the details payload. |
MaxPlayers |
RoomSceneEntity.MaxPlayers |
What ONE sub-room caps itself at; appears per entry in the details SubRooms[]. |
The room-level figure must not be read off sub-room 0 — that collapses the two into one and makes an admin edit to a sub-room silently move the room's advertised capacity.
Sub-room caps have two write paths onto the same column:
- In game (room owner only) — the "Max Player Count in This Subroom"
slider issues
PUT rooms/{id}/subrooms/{sub}/maxplayers. Unlike most of its siblings this one does not form-encode:NLDBPDCNNCF.MNBPCGJNLNP(Int64, Int64, Int32) builds aBLNOGFGHIIFwhose single JSON key ismaxPlayers, with verb 3 = PUT. It reads the reply back asFGCPNAACHIK, so the response is the full room-details shape and has to carry the new value. - Admin (any room, ownership not required):
GET api/admin/v1/rooms/{id}/subrooms— every sub-room with its cap, matchmaking flag, sandbox flag and current data blob. Row ids go out as strings; the SPA is JavaScript and mangles integers past 2^53.PUT api/admin/v1/rooms/{id}/subrooms/{subRoomId}/maxplayerswith{"MaxPlayers": int}.subRoomIdis the sub-room's order index — the id the client and the rest of the wire use — not the row's primary key.
Both paths clamp to 1..80, matching the room-level cap in
rooms/{id}/props: 0 would make a sub-room unjoinable, and a shared range
keeps the two settings from disagreeing about what is a legal value.
Enforcement caveat carried over from RoomEntity.MaxCapacity: this is the
advertised cap. The client never sets Photon RoomOptions.MaxPlayers, so
hard enforcement still needs a ClientMod Photon patch.
Build And Deploy
The service Dockerfile builds the admin SPA for service images that need
static assets. In the microservices stack the web image must include:
/app/wwwroot/admin/index.html
/app/wwwroot/admin/assets/*
The admin SPA calls relative URLs, so it does not need a configured API
origin as long as the browser is opened on https://admin.<domain>/.
The native admin mobile app points at the same public admin host; see
../DorkNet.AdminMobile/README.md.
Smoke Checks
Check the static host:
curl -I https://admin.yourdomain.com/
Check that protected admin API routing is alive. A 401 is expected
without a valid admin token:
curl -i https://admin.yourdomain.com/api/admin/v1/stats
Check the built assets inside the running web container:
docker exec <web-container> sh -lc 'find /app/wwwroot/admin -maxdepth 2 -type f | sort | head -50'
Useful logs:
docker logs <stack>-gateway-1 --tail=200
docker logs <stack>-web-1 --tail=200
docker logs <stack>-moderation-1 --tail=200
For normal browser admin traffic, start with gateway and web.
Check moderation when you are testing /api/admin/* through a
non-admin host such as api.<domain>.
Troubleshooting
| Symptom | Check |
|---|---|
admin.<domain>/ returns 404 |
web logs for probe="/app/wwwroot/admin/index.html" exists=False; rebuild/redeploy the web image |
| Admin page loads but actions 404 | Gateway /internal/routes, then whether the request host is admin.<domain> or a path-routed host |
| Login or actions return 401 | Token expired, local session cleared, or the player no longer has IsAdmin = true |
| Browser spins forever | Open the browser network tab, then check gateway and web logs for the stuck URL |
| Upload/import fails near 100 MB | Use the chunked importer; the SPA uses 50 MB chunks to stay below Cloudflare request limits |