[www] add basic room list

This commit is contained in:
Devin Zuczek
2026-08-10 10:26:39 -04:00
parent d461961e54
commit 7fbaad1fd8
8 changed files with 319 additions and 9 deletions
+19 -3
View File
@@ -50,12 +50,19 @@ import {
unbanPlayerFromRoom,
updateRoomFields,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import {
intVar,
logger,
withCleanSpec,
withDefaultCors,
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types'
import {
AccessibilityRequest,
AUTHED,
@@ -85,8 +92,8 @@ import {
PublishSaveRequest,
RestrictionsRequest,
RoleRequest,
RoomBanEnvelope,
RoomBanEntryDto,
RoomBanEnvelope,
RoomDto,
RoomEnvelope,
roomIdParam,
@@ -546,6 +553,15 @@ const app = new Hono<App>()
})(c, next)
)
// The website (`www`) is a browser origin calling these endpoints directly, the way
// rec.net's own site called the game's API — its "My rooms" list is this worker's
// `GET /rooms/ownedby/me` — so the responses need CORS headers or the browser
// discards them. `origin: '*'` is deliberate and safe HERE because these endpoints
// authenticate with a bearer token in the `Authorization` header, never a cookie: a
// hostile page can't read another origin's stored token, so there is no ambient
// credential for `*` to expose. Do not add cookie auth without narrowing it.
.use('*', withDefaultCors())
.onError(withOnError())
.notFound(withNotFound())
+30 -5
View File
@@ -181,6 +181,32 @@ describe('rooms endpoints', () => {
expect(other).toEqual([])
})
// The website's "My rooms" list is a browser calling this worker from another origin,
// so a response without CORS headers is one the browser throws away — and the page
// can't tell that apart from the server being down. Pinned on the preflight too: the
// SPA sends `Authorization`, which makes even the GET a preflighted request.
it('answers CORS so the website can read a room list from the browser', async () => {
const preflight = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com',
'access-control-request-method': 'GET',
'access-control-request-headers': 'authorization',
},
})
expect(preflight.status).toBe(204)
expect(preflight.headers.get('access-control-allow-origin')).toBe('*')
expect(preflight.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
'authorization'
)
const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
headers: { ...(await bearer('1')), origin: 'https://www.example.com' },
})
expect(res.status).toBe(200)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
})
it('GET /rooms/ownedby|createdby/me lists the callers UNPUBLISHED rooms too', async () => {
// "My Rooms" is the owner's own list, not a catalog: it must show a room that
// isn't public yet, or a freshly created room (which starts Private — see
@@ -214,9 +240,9 @@ describe('rooms endpoints', () => {
}
// The same room is absent from the account's PUBLIC profile list.
const publicList = (await (
await SELF.fetch(`${ORIGIN}/rooms/ownedby/804`)
).json()) as Array<{ Name: string }>
const publicList = (await (await SELF.fetch(`${ORIGIN}/rooms/ownedby/804`)).json()) as Array<{
Name: string
}>
expect(publicList.some((r) => r.Name === 'MyUnpublishedRoom')).toBe(false)
})
@@ -725,8 +751,7 @@ describe('rooms endpoints', () => {
const namesIn = async (path: string) => {
const body = (await (await SELF.fetch(`${ORIGIN}${path}`)).json()) as
| { Results: Array<{ Name: string }> }
| Array<{ Name: string }>
{ Results: Array<{ Name: string }> } | Array<{ Name: string }>
return (Array.isArray(body) ? body : body.Results).map((r) => r.Name)
}
expect(await namesIn('/rooms/hot?take=200')).not.toContain('ParkCloneUnpublished')
+4
View File
@@ -32,6 +32,10 @@ const AUTH_MESSAGES: Record<string, string> = {
'no linked account for this platform identity':
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
// Deliberately says nothing about when it lifts: auth sends one fixed description for
// every ban (see its BANNED_DESCRIPTION), permanent or timed, so there is no expiry
// here to quote.
'this account is banned': 'This account is banned and cannot be signed in to.',
}
/** Fallbacks when nothing above matched, so a player never reads an OAuth code. */
+159
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Accessibility } from '@repo/domain/src/enums'
import { NotificationType } from '../../../notify/src/notification-types'
import { authFailure, authUnreachable } from '../auth-messages'
import {
@@ -30,6 +32,7 @@ interface Hosts {
api: string
img: string
notify: string
rooms: string
}
/**
@@ -56,6 +59,42 @@ interface SelfAccount {
availableUsernameChanges?: number
}
/**
* One room from `rooms` (`GET /rooms/ownedby/me`), narrowed to what this page draws. The
* worker serves the stored room blob verbatim — dozens of fields the game needs and this
* list doesn't — so only the handful read here is declared.
*/
interface OwnedRoom {
RoomId: number
Name: string
Description: string
/** A key on the `img` worker; a room with no image of its own gets the fallback. */
ImageName: string
/** The `Accessibility` ordinal, NOT the enum name — see ACCESSIBILITY_LABEL. */
Accessibility: number
CreatedAt: string
/** Always present: the worker folds the live counters in on every read. */
Stats: {
CheerCount: number
FavoriteCount: number
VisitorCount: number
VisitCount: number
}
}
/**
* What a room's `Accessibility` ordinal is called on screen. The two dev values are
* reachable — the game sets them — so they're named rather than left to fall through
* to the unknown case in RoomCard.
*/
const ACCESSIBILITY_LABEL: Record<number, string> = {
[Accessibility.Private]: 'Private',
[Accessibility.Public]: 'Public',
[Accessibility.Unlisted]: 'Unlisted',
[Accessibility.Dev_only]: 'Dev only',
[Accessibility.Dev_Unlisted]: 'Dev unlisted',
}
/**
* RecNet (4) is the web platform, stamped as the token's `platform` claim on sign-in.
* NOT passed on signup: create_account treats an asserted platform as one to verify
@@ -197,6 +236,28 @@ async function call<T = Record<string, unknown>>(url: string, opts: CallOptions
const fetchMe = (): Promise<SelfAccount> =>
call<SelfAccount>(`${where().accounts}/account/me`, { authed: true })
/**
* The caller's own rooms, from the `rooms` worker — the same list the game's "My Rooms"
* loads. `ownedby/me` rather than `createdby/me`: the dorm is auto-provisioned, not a
* room the player made, and it's the one room they can't do anything with from here.
*
* The worker deliberately does NOT filter on accessibility for this list, so a room that
* has never been published shows up — which is the point, since that's the one its owner
* is most likely to be looking for.
*
* Sorted newest-first here rather than upstream: the query has no ORDER BY (D1 hands
* back insertion order, which is not a promise), and the room someone just made is the
* one they came to see.
*/
async function fetchMyRooms(): Promise<OwnedRoom[]> {
const rooms = await call<OwnedRoom[]>(`${where().rooms}/rooms/ownedby/me`, { authed: true })
// A bare array is the contract; anything else is treated as "no rooms" rather than
// thrown, since `.sort` on a non-array would surface as an unreadable TypeError.
if (!Array.isArray(rooms)) return []
// ISO-8601 timestamps, so lexical order IS chronological order.
return [...rooms].sort((a, b) => (a.CreatedAt < b.CreatedAt ? 1 : -1))
}
/**
* Sign in with auth's password grant, posted directly the way the game posts it. The
* account is resolved by `username` (case-insensitive) — web players sign in with their
@@ -1099,6 +1160,9 @@ function Dashboard({
// The dashboard sections, shown one at a time via the left tab rail. Admin-only
// sections are appended when the session carries an admin role.
const sections = [
// First, so a player who just signed in lands on what they made rather than on a
// settings form they opened the page to avoid.
{ id: 'rooms', label: 'My rooms', render: () => <MyRooms /> },
{
id: 'username',
label: 'Username',
@@ -1147,6 +1211,101 @@ function Dashboard({
)
}
/**
* The rooms the signed-in player owns.
*
* Read-only on purpose: rooms are made and edited in game, and there is nothing here a
* player could change that the game doesn't already own. What the web is better at is
* the overview — everything you've made in one place, including the rooms you never
* published, which are invisible everywhere else.
*/
function MyRooms() {
const [rooms, setRooms] = useState<OwnedRoom[] | null>(null)
const [error, setError] = useState('')
useEffect(() => {
void fetchMyRooms()
.then(setRooms)
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [])
return (
<section className="card">
<h2>My rooms</h2>
<p className="muted">
Every room you&apos;ve made, newest first unpublished ones included. Your dorm isn&apos;t
here: it was made for you rather than by you.
</p>
{error ? (
<p className="error">{error}</p>
) : rooms === null ? (
<p className="muted">Loading</p>
) : rooms.length === 0 ? (
<p className="muted">
You haven&apos;t made a room yet. Rooms are created in game clone one you like, or start
from a blank one in the Rec Center.
</p>
) : (
// `where()` THROWS when the config never landed, and a throw in render takes the
// page down (see useSlideshow). It can't here: this branch is only reached once
// the fetch above resolved, and that fetch went through `where()` itself.
<ul className="rooms">
{rooms.map((room) => (
<RoomCard key={room.RoomId} room={room} imgHost={where().img} />
))}
</ul>
)}
</section>
)
}
/**
* One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's
* doing.
*
* The thumbnail is asked for at 256px wide — one of the img worker's four allowed sizes,
* so it's a cached variant rather than the full-size upload. A room with no image of its
* own still answers 200 there (the worker serves its fallback), so there's no broken
* frame to handle.
*/
function RoomCard({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
// Unknown ordinals shouldn't happen, but the label is the only thing telling an owner
// whether a room is visible — so show the raw value rather than nothing at all.
const visibility =
ACCESSIBILITY_LABEL[room.Accessibility] ?? `Accessibility ${room.Accessibility}`
const created = new Date(room.CreatedAt)
return (
<li className="room">
<img
className="room-thumb"
src={`${imgHost}/${room.ImageName}?width=256`}
alt=""
loading="lazy"
/>
<div className="room-body">
<div className="room-head">
{/* The caret is how the game writes a room name, so it reads as the thing you
type to get there rather than as a title someone wrote. */}
<span className="room-name">^{room.Name}</span>
<span className={`badge ${room.Accessibility === Accessibility.Public ? 'live' : ''}`}>
{visibility}
</span>
</div>
{room.Description && <p className="room-desc">{room.Description}</p>}
<p className="room-stats">
{room.Stats.VisitCount.toLocaleString()} visit
{room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '}
favourite
{room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '}
cheer{room.Stats.CheerCount === 1 ? '' : 's'}
{!Number.isNaN(created.getTime()) && ` · made ${created.toLocaleDateString()}`}
</p>
</div>
</li>
)
}
/** Admin-only: send a coach/system message to every online player. */
function CoachMessageForm() {
const [message, setMessage] = useState('')
+102
View File
@@ -605,6 +605,108 @@ h2 {
font-weight: 600;
}
/* ---- My rooms ----------------------------------------------------------- */
/*
* The owner's own room list. Rows rather than a grid of tiles: a room is identified by
* its name, and the counts underneath are the reason to look — both read left-to-right,
* which a tile would stack into a column of tiny type.
*/
.rooms {
list-style: none;
margin: 18px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.room {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 14px;
align-items: start;
padding-top: 12px;
border-top: 1px solid var(--line);
}
/* The first row sits directly under the intro copy, which already separates it. */
.room:first-child {
padding-top: 0;
border-top: none;
}
/* Fixed 3:2 frame — room thumbnails are screenshots and arrive at any ratio, and a
per-room height would leave the names in a ragged column. */
.room-thumb {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface-hi);
}
.room-body {
min-width: 0;
}
.room-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
/* Room names have no spaces to break at, so let a long one wrap anywhere rather than
widen the row past the panel. */
.room-name {
font-family: var(--display);
font-weight: 700;
font-size: 1rem;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
/* Whether the room is visible to anyone else — the one piece of state an owner can't
see anywhere but here. Public gets the same green "healthy" reading as the server
status; every other value stays neutral, since Private isn't a fault. */
.badge {
flex: none;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--muted);
}
.badge.live {
color: var(--live);
border-color: color-mix(in srgb, var(--live) 45%, transparent);
}
.room-desc {
margin: 6px 0 0;
font-size: 0.875rem;
color: var(--muted);
/* Two lines: enough to tell rooms apart, not enough for one to own the list. */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
}
.room-stats {
margin: 8px 0 0;
font-size: 0.8rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
/* ---- Forms -------------------------------------------------------------- */
label {
+2 -1
View File
@@ -32,7 +32,7 @@ beforeAll(async () => {
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
// the pass path can't be tested here (it would call Cloudflare's siteverify for real).
//
// The hostnames matter as much as the key: the SPA calls auth/accounts/api/notify
// The hostnames matter as much as the key: the SPA calls auth/accounts/api/notify/rooms
// DIRECTLY (as rec.net's site did), and this is the only place it learns where they are.
// A build with them missing can't sign anyone in.
it('advertises signup and where the other workers live', async () => {
@@ -48,6 +48,7 @@ it('advertises signup and where the other workers live', async () => {
api: 'https://api.rec.example.com',
img: 'https://img.rec.example.com',
notify: 'https://notify.rec.example.com',
rooms: 'https://rooms.rec.example.com',
},
})
})
+1
View File
@@ -15,6 +15,7 @@ export const accountsBase = (env: Env): string => `https://accounts.${env.DOMAIN
export const notifyBase = (env: Env): string => `https://notify.${env.DOMAIN}`
export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}`
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
export const roomsBase = (env: Env): string => `https://rooms.${env.DOMAIN}`
/**
* POST a form body to the `auth` worker, carrying the browser's real IP across.
+2
View File
@@ -16,6 +16,7 @@ import {
notifyBase,
postAuthForm,
readAuthError,
roomsBase,
} from './upstream'
import type { App } from './context'
@@ -67,6 +68,7 @@ const app = new Hono<App>()
api: apiBase(c.env),
img: imgBase(c.env),
notify: notifyBase(c.env),
rooms: roomsBase(c.env),
},
})
})