From 7fbaad1fd832c6a0cb92e4b60a6aa29cafa45690 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 10 Aug 2026 10:26:39 -0400 Subject: [PATCH] [www] add basic room list --- apps/rooms/src/rooms.app.ts | 22 ++- apps/rooms/src/test/integration/api.test.ts | 35 ++++- apps/www/src/auth-messages.ts | 4 + apps/www/src/client/App.tsx | 159 ++++++++++++++++++++ apps/www/src/client/styles.css | 102 +++++++++++++ apps/www/src/test/integration/api.test.ts | 3 +- apps/www/src/upstream.ts | 1 + apps/www/src/www.app.ts | 2 + 8 files changed, 319 insertions(+), 9 deletions(-) diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 01b1600..ebc58ec 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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() })(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()) diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 3b7f9dc..34fc38d 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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 caller’s 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') diff --git a/apps/www/src/auth-messages.ts b/apps/www/src/auth-messages.ts index 754fdc3..3e13f3d 100644 --- a/apps/www/src/auth-messages.ts +++ b/apps/www/src/auth-messages.ts @@ -32,6 +32,10 @@ const AUTH_MESSAGES: Record = { '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. */ diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index c1dbb3d..5581fca 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -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 = { + [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>(url: string, opts: CallOptions const fetchMe = (): Promise => call(`${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 { + const rooms = await call(`${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: () => }, { 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(null) + const [error, setError] = useState('') + + useEffect(() => { + void fetchMyRooms() + .then(setRooms) + .catch((e) => setError(e instanceof Error ? e.message : String(e))) + }, []) + + return ( +
+

My rooms

+

+ Every room you've made, newest first — unpublished ones included. Your dorm isn't + here: it was made for you rather than by you. +

+ {error ? ( +

{error}

+ ) : rooms === null ? ( +

Loading…

+ ) : rooms.length === 0 ? ( +

+ You haven't made a room yet. Rooms are created in game — clone one you like, or start + from a blank one in the Rec Center. +

+ ) : ( + // `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. +
    + {rooms.map((room) => ( + + ))} +
+ )} +
+ ) +} + +/** + * 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 ( +
  • + +
    +
    + {/* 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. */} + ^{room.Name} + + {visibility} + +
    + {room.Description &&

    {room.Description}

    } +

    + {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()}`} +

    +
    +
  • + ) +} + /** Admin-only: send a coach/system message to every online player. */ function CoachMessageForm() { const [message, setMessage] = useState('') diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index cec1d41..d506896 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -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 { diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 4bb5217..2a4e7d7 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -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', }, }) }) diff --git a/apps/www/src/upstream.ts b/apps/www/src/upstream.ts index 430232f..361f321 100644 --- a/apps/www/src/upstream.ts +++ b/apps/www/src/upstream.ts @@ -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. diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index 5d32a20..ad0dcad 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -16,6 +16,7 @@ import { notifyBase, postAuthForm, readAuthError, + roomsBase, } from './upstream' import type { App } from './context' @@ -67,6 +68,7 @@ const app = new Hono() api: apiBase(c.env), img: imgBase(c.env), notify: notifyBase(c.env), + rooms: roomsBase(c.env), }, }) })