diff --git a/.env.example b/.env.example index 823cb41..3877b8d 100644 --- a/.env.example +++ b/.env.example @@ -64,3 +64,16 @@ RECFLARE_DOMAIN=rec.example.com # 0 means players start broke. Applies only to players who haven't been granted yet — # raising it later does NOT top up existing players. # RECFLARE_STARTING_TOKENS=10000 + +# Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare +# Turnstile widget, and both of that widget's keys live in the shared Secrets Store +# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets. +# +# wrangler secrets-store secret create --name TURNSTILE_SITE_KEY \ +# --scopes workers --remote +# wrangler secrets-store secret create --name TURNSTILE_SECRET_KEY \ +# --scopes workers --remote +# +# Setting them both is what opens web signup; with either missing it stays closed. See +# DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account +# caps above apply regardless. diff --git a/DEPLOYING.md b/DEPLOYING.md index 7ed3a7f..91f0d8c 100644 --- a/DEPLOYING.md +++ b/DEPLOYING.md @@ -217,6 +217,52 @@ single address, so raise it (or set it to `0`) if real players report being lock > `just deploy`. `.env` is the durable place. Real secrets don't belong there either — they > go in the Cloudflare Secrets Store, like the shared `JWT_SECRET` above. +### Signing up on the website (Turnstile) + +Players get an account by launching the game, which needs no setup. The website can create +one too — that path has no platform identity behind it, so it runs behind a +[Turnstile](https://developers.cloudflare.com/turnstile/) bot check and is **closed until +you configure one**. Two steps, both one-time: + +1. Create the widget: Cloudflare dashboard → **Turnstile** → **Add widget**, mode + **Managed**, hostnames your domain (add `localhost` if you want it in `just dev` against + real keys). It gives you a **site key** and a **secret key**. +2. Put both in the same Secrets Store the shared `JWT_SECRET` lives in — they're the switch + that opens signup, and store values survive deploys: + + ```bash + wrangler secrets-store secret create --name TURNSTILE_SITE_KEY \ + --scopes workers --remote + wrangler secrets-store secret create --name TURNSTILE_SECRET_KEY \ + --scopes workers --remote + ``` + +Then `just deploy -F www`. The site key is public — the browser needs it to render the +widget, and gets it from `GET /api/config` — but it lives next to its secret so signup is +configured in one place. The secret key never leaves the worker: `/api/signup` verifies the +token against Turnstile server-side before it calls `auth`. + +Signup opens only when **both** resolve. With either missing, `/api/config` reports signup +closed (the site shows sign-in only) and `POST /api/signup` refuses — a missed step costs +you the signup form, never an unprotected one. That is also how you turn signup back off: +`wrangler secrets-store secret delete --name TURNSTILE_SECRET_KEY --remote`, +then redeploy `www` (values are cached per isolate, so a warm worker keeps the old one +until fresh isolates start). For local dev, seed the same two names into the local store +from `apps/www` — Turnstile's documented always-passes test keypair +(`1x00000000000000000000AA` / `1x0000000000000000000000000000000AA`) works there without a +widget: + +```bash +cd apps/www +printf '1x00000000000000000000AA' | + wrangler secrets-store secret create local --name TURNSTILE_SITE_KEY --scopes workers +printf '1x0000000000000000000000000000000AA' | + wrangler secrets-store secret create local --name TURNSTILE_SECRET_KEY --scopes workers +``` + +Both `auth` account caps above still apply on top of the bot check, and the per-IP one is +the only cap that can see a web signup. + ## Repository Structure - `apps/` - The service workers, one deployable Worker per subdirectory. Each has diff --git a/apps/img/static/DefaultProfileImage.jpg b/apps/img/static/DefaultProfileImage.jpg index 8d6c83a..9afb3bb 100644 Binary files a/apps/img/static/DefaultProfileImage.jpg and b/apps/img/static/DefaultProfileImage.jpg differ diff --git a/apps/rooms/migrations/0009_subroom_permissions.sql b/apps/rooms/migrations/0009_subroom_permissions.sql new file mode 100644 index 0000000..7399fde --- /dev/null +++ b/apps/rooms/migrations/0009_subroom_permissions.sql @@ -0,0 +1,29 @@ +-- Per-subroom permission overrides. `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions` +-- is how a room's creator changes what a role may do in one subroom (spawn inventions, +-- invite, use the delete-all button, …). The client addresses an entry by the +-- (`Permission`, `Role`) pair and re-PUTs that pair to change it, so the pair is the +-- primary key: sending it again overwrites the stored row rather than appending a second. +-- +-- A row IS an override, so the client's `Override` flag is not a column. It's the checkbox +-- the client draws next to each permission — `Override: true` stores the value, and +-- `Override: false` means "fall back to the default", which deletes the row. Reads always +-- serve `Override: true`. +-- +-- Read on one path only — `GET /photon_access_token`, where a stored entry overwrites the +-- matching default in the permission table the client applies when it spawns. That's why +-- this is its own table rather than a field on the subroom's `data` blob: that blob is +-- served to the client verbatim inside the room, and nothing client-facing reads these. +-- +-- `value` is the client's string kept verbatim: usually `True`/`False`, but a permission +-- whose UI isn't a True/False picker carries something else, and we don't interpret it. +-- +-- Generated from packages/domain/src/rooms-db.ts (SUBROOM_SCHEMA_DDL) — keep in sync. + +CREATE TABLE IF NOT EXISTS subroom_permission ( + sub_room_id INTEGER NOT NULL, + permission TEXT NOT NULL, + role INTEGER NOT NULL, + type INTEGER NOT NULL DEFAULT 0, + value TEXT NOT NULL, + PRIMARY KEY (sub_room_id, permission, role) + ); diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 3d82d4b..1c5b02f 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -64,6 +64,12 @@ export const FORBIDDEN_RESPONSE = { description: 'A valid token, but not the room’s creator or a co-owner (empty body)', } +/** The 403 the friends-only routes return (empty body). */ +export const NOT_FRIENDS_RESPONSE = { + description: + 'A valid token, but the caller is not that player (nor a friend of theirs) (empty body)', +} + // ---- Parameters ------------------------------------------------------------ /** A digits-only id path parameter (the route patterns constrain these to `[0-9]+`). */ @@ -83,6 +89,9 @@ export const roomIdParam = idParam('roomId', 'Room id') /** The `:subRoomId` path parameter. */ export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)') +/** The `:playerId` path parameter (an account id). */ +export const playerIdParam = idParam('playerId', 'The account whose list to read') + /** An optional string query parameter. */ export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject { return { name, in: 'query', required: false, description, schema: { type: 'string' } } @@ -479,6 +488,35 @@ export const SubRoomAccessibilityRequest = z.object({ ), }) +/** + * `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions` — the entries to change, keyed by + * (`Permission`, `Role`). Only the pairs sent are touched. `Override` is the client's + * checkbox: true stores the entry, false clears it back to the default. + */ +export const SubRoomPermissionsRequest = z + .array( + z.object({ + Permission: z + .string() + .describe('e.g. `CAN_SAVE_INVENTIONS`, `CAN_INVITE`, `CAN_USE_DELETE_ALL_BUTTON`'), + Role: z.int().describe('The role tier the entry applies to (0 = everyone, 30 = co-owner)'), + Override: z + .boolean() + .describe( + 'The override checkbox, and a JSON boolean unlike `Value`: true stores this entry, ' + + 'false DELETES any stored one so the pair falls back to its default' + ), + Type: z.int().describe('Always 0 in what the client sends; stored verbatim'), + Value: z + .string() + .describe( + 'A STRING, not a boolean — usually `True` / `False`, but kept verbatim: not every ' + + 'permission’s UI is a True/False picker. Ignored when `Override` is false' + ), + }) + ) + .describe('An array — the client sends one even when changing a single permission') + /** * `POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save` — promotes one save to live. * Any id from the subroom's history works, so this is both publish and restore. @@ -543,11 +581,13 @@ export const SubRoomSavesPage = z.object({ /** One entry of the permission table the client applies when it spawns into a room. */ export const RoomPermissionDto = z.object({ - Override: z.boolean(), + Override: z.boolean().describe('Always true on an entry that came from a subroom’s overrides'), Permission: z.string().describe('e.g. `CAN_USE_MAKER_PEN`, `CAN_SAVE_INVENTIONS`'), Role: z.int().describe('The role tier the permission applies to (0 = everyone)'), Type: z.int(), - Value: z.string().describe('Always `True` — a permission is present or absent'), + Value: z + .string() + .describe('A STRING, not a boolean — `True` on the defaults, anything on an override'), }) /** @@ -556,6 +596,11 @@ export const RoomPermissionDto = z.object({ * `PhotonAccessToken` is deliberately empty: the reference server signs it with a * secret/algorithm we don't have, and our Photon setup accepts an empty token. The * global (Role 0) maker pen is granted only to the hardcoded dev accounts. + * + * `Permissions` is the default table with the overrides stored on the subroom the caller + * is standing in merged over it (see + * `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions`): an override replaces the + * default with the same (`Permission`, `Role`), and one naming a new pair is appended. */ export const PhotonAccessTokenDto = z.object({ Permissions: z.array(RoomPermissionDto), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 4d51b74..4d6e72a 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { Accessibility, + areFriends, canManageRoom, cloneRoom, cloneSubRoom, @@ -25,6 +26,7 @@ import { getRoomsByCreator, getRoomsByIds, getSimilarRooms, + getSubRoomPermissions, getSubRoomSaves, getVisitedRooms, modifySubRoom, @@ -37,6 +39,7 @@ import { setRoomImage, setRoomName, setRoomRole, + setSubRoomPermissions, toggleCheer, toggleFavorite, toggleRoomTag, @@ -63,10 +66,12 @@ import { MissingLookupParam, ModifySubRoomRequest, NameRequest, + NOT_FRIENDS_RESPONSE, PagedRooms, pageParams, PhotonAccessTokenDto, PlayerDataDto, + playerIdParam, PublishSaveRequest, RestrictionsRequest, RoleRequest, @@ -81,6 +86,7 @@ import { stringQuery, SubRoomAccessibilityRequest, subRoomIdParam, + SubRoomPermissionsRequest, SubRoomSavesPage, TagRequest, UNAUTHORIZED_EMPTY, @@ -90,6 +96,7 @@ import { } from './openapi' import type { Context } from 'hono' +import type { RoomPermission } from '@repo/domain' import type { App } from './context' /** @@ -131,9 +138,14 @@ const DEFAULT_MAX_ROOMS_PER_ACCOUNT = 10 * hardcoded moderator/dev accounts. */ const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3]) -/** The slice of the shared presence row we read — the caller's current room instance. */ +/** + * The slice of the shared presence row we read — the caller's current room instance. + * `subRoomId` is what scopes the stored permission overrides: they belong to the subroom + * the player is standing in, not to the room. + */ interface PresenceView { roomInstanceId?: number + subRoomId?: number } /** @@ -143,16 +155,26 @@ interface PresenceView { * they aren't in one). `PhotonAccessToken` stays empty — the reference server * signs it via `ClientSecurity`, whose secret/algorithm we don't have; our * Photon setup accepts an empty token. + * + * `overrides` are the permissions the room's creator saved on the subroom the caller is + * in (see `PUT …/subrooms/{subRoomId}/permissions`). They are matched against the + * defaults by (`Permission`, `Role`) — the same pair the client addresses an entry by — + * and win, so a subroom that revokes the Role 0 maker pen revokes it for a dev account + * standing in it as well. */ -function photonAccessToken(accountId: number, roomInstanceId: number | null) { - const perm = (Permission: string, Role: number, Override: boolean) => ({ +function photonAccessToken( + accountId: number, + roomInstanceId: number | null, + overrides: RoomPermission[] = [] +) { + const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({ Override, Permission, Role, Type: 0, Value: 'True', }) - const permissions = [ + const permissions: RoomPermission[] = [ perm('CAN_USE_ROOM_RESET_BUTTON', 0, true), perm('CAN_USE_DELETE_ALL_BUTTON', 0, true), perm('CAN_SAVE_INVENTIONS', 0, true), @@ -165,9 +187,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) { perm('CAN_SPAWN_INVENTIONS', 30, true), perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true), ] + if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) { permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true)) } + + // The subroom's stored table wins, applied LAST and over the dev grant too: a + // (Permission, Role) the table already carries is replaced in place — so the order + // doesn't shift under the client, and no pair is ever listed twice with two values — + // and one it doesn't (e.g. CAN_INVITE) is appended. + for (const override of overrides) { + const i = permissions.findIndex( + (p) => p.Permission === override.Permission && p.Role === override.Role + ) + if (i === -1) permissions.push(override) + else permissions[i] = override + } return { Permissions: permissions, PhotonAccessToken: '', @@ -176,16 +211,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) { } /** - * Photon access-token handler (served bare and under `/roomserver`). Auth-gated: - * resolves the caller, reads their current room instance from the shared - * `presence` table (see @repo/domain), and returns the permissions + token. + * Photon access-token handler. Auth-gated: resolves the caller, reads their current + * room instance from the shared `presence` table (see @repo/domain), and returns the + * permissions + token. */ async function handlePhotonAccessToken(c: Context) { const accountId = await authedAccountId(c) if (accountId === null) return unauthorized(c) - const presence = await getPresence(c.env.DB, accountId) - const roomInstanceId = presence?.roomInstance?.roomInstanceId ?? null - return c.json(photonAccessToken(accountId, roomInstanceId)) + const instance = (await getPresence(c.env.DB, accountId))?.roomInstance + // The permission overrides are the ones saved on the subroom the caller is standing in. + // A player in no instance — sitting in the lobby, or an instance predating subroom + // tracking — gets the default table untouched. + const overrides = + typeof instance?.subRoomId === 'number' + ? await getSubRoomPermissions(c.env.DB, instance.subRoomId) + : [] + return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides)) } /** The Bearer token's account id (`sub`), or null when there's no valid token. */ @@ -214,6 +255,59 @@ function parseAccessibility(value: unknown): number | undefined { return named ? (named[1] as number) : undefined } +/** Parse an integer from the number or numeric string a JSON body may carry. */ +function parseInt10(value: unknown): number | undefined { + if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : undefined + if (typeof value !== 'string') return undefined + const n = Number.parseInt(value.trim(), 10) + return Number.isNaN(n) ? undefined : n +} + +/** + * The client's `Value`, kept as the STRING it sends. Usually `"True"`/`"False"` — the + * True/False picker beside the override checkbox — but a permission whose UI is something + * else carries a different value, so nothing here interprets it. A JSON boolean or number + * is rendered the way the client would have written it. + */ +function permissionValue(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'boolean') return value ? 'True' : 'False' + if (typeof value === 'number') return String(value) + return '' +} + +/** + * Parse the subroom-permissions PUT body: a JSON ARRAY of + * `{ Permission, Role, Override, Type, Value }` entries. + * + * `Override` is the client's checkbox, not data — see {@link setSubRoomPermissions}: true + * stores `Value` for that (`Permission`, `Role`), false clears any stored entry so the + * pair falls back to the default. It is carried through as sent. + * + * Entries without a permission name or a usable role are dropped rather than rejected — + * the client ignores the response either way, so half a table applied beats none. + */ +function parseRoomPermissions(body: unknown): RoomPermission[] { + if (!Array.isArray(body)) return [] + const permissions: RoomPermission[] = [] + for (const entry of body) { + if (typeof entry !== 'object' || entry === null) continue + const e = entry as Record + const permission = typeof e.Permission === 'string' ? e.Permission.trim() : '' + const role = parseInt10(e.Role) + if (permission === '' || role === undefined) continue + permissions.push({ + Permission: permission, + Role: role, + // Sent as a JSON boolean, unlike `Value` — accept the string form regardless. + Override: e.Override === true || String(e.Override).toLowerCase() === 'true', + Type: parseInt10(e.Type) ?? 0, + Value: permissionValue(e.Value), + }) + } + return permissions +} + /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' @@ -672,6 +766,45 @@ const app = new Hono() } ) + // Another player's visited rooms — what the client shows on a friend's profile. + // Auth-gated (401), and FRIENDS-ONLY: a valid token for someone who isn't that + // player and isn't a mutual friend of theirs is a 403, since where a player has + // been is not public. Registered after `visitedby/me` so the literal path wins. + // Paginated via skip/take (take defaults to 100) and, like `visitedby/me`, a bare + // array — the client's room-source loaders expect a plain list, not a page. + .get( + '/rooms/visitedby/:playerId{[0-9]+}', + describeRoute({ + tags: ['Rooms'], + summary: 'A friend’s visited rooms', + description: [ + 'The rooms another player has visited, as a bare array. Friends only: the caller must', + 'be that player or a mutual friend of theirs (403 otherwise) — visit history is not', + 'public.', + ].join(' '), + security: AUTHED, + parameters: [playerIdParam, ...pageParams(100)], + responses: { + 200: json(RoomDto.array(), 'That player’s visited rooms'), + 401: UNAUTHORIZED_RESPONSE, + 403: NOT_FRIENDS_RESPONSE, + }, + }), + async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + const playerId = Number.parseInt(c.req.param('playerId'), 10) + // Your own history is always readable (the client sometimes sends the id + // rather than `me`); anyone else's needs a mutual friendship. + if (playerId !== accountId && !(await areFriends(c.env.DB, accountId, playerId))) { + return c.body(null, 403) + } + const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 + return c.json(await getVisitedRooms(c.env.DB, playerId, skip, take)) + } + ) + // The current player's interaction state with a room (cheered/favorited/last // visited), read from the `interaction` table. Auth-gated. .get( @@ -1820,6 +1953,67 @@ const app = new Hono() } ) + // Set a subroom's permission overrides — what each role may do in that subroom. The + // body is a JSON ARRAY of the entries to change, keyed by (Permission, Role): `Override` + // is the client's checkbox, so true stores the entry for that pair and false clears it + // back to the default. The stored table then overwrites the matching defaults in + // `GET /photon_access_token`. Auth-gated (401) and creator-only (403), like the other + // subroom mutations. Answers an EMPTY 200 — the client fires this and re-reads nothing, + // so there is no envelope to match. + .put( + '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/permissions', + describeRoute({ + tags: ['Subrooms'], + summary: 'Set a subroom’s permissions', + description: [ + 'Stores the permission entries a room’s creator changed for one subroom — who may', + 'save inventions, invite players, use the delete-all button, and so on. The body is a', + 'JSON ARRAY; each entry is addressed by its (`Permission`, `Role`) pair, so re-sending', + 'a pair overwrites the stored entry rather than adding a second, and pairs that were', + 'never sent are left alone.', + '', + '`Override` is the checkbox the client draws beside each permission, not data:', + '`true` stores `Value` for that pair, and `false` means “fall back to the default”, so', + 'it DELETES any stored entry. Nothing is stored with `Override: false`, and reads', + 'always serve `true`. `Value` is a string — usually `True`/`False`, but it is kept', + 'verbatim, since not every permission’s UI is a True/False picker.', + '', + 'What this feeds is `GET /photon_access_token`: a stored entry replaces the default', + 'with the same (`Permission`, `Role`) in the table the client applies when it spawns,', + 'and one naming a pair the defaults don’t carry (e.g. `CAN_INVITE`) is added to it.', + 'The overrides apply to the subroom the caller is standing in, resolved from presence.', + '', + 'Creator-only — co-owners may build in a room but not decide what a role may do.', + 'The response body is EMPTY: the client doesn’t read one.', + ].join('\n'), + security: AUTHED, + parameters: [roomIdParam, subRoomIdParam], + requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'), + responses: { + 200: { description: 'Stored (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: { description: 'No such room or subroom' }, + }, + }), + async (c) => { + const accountId = await authedAccountId(c) + if (accountId === null) return unauthorized(c) + + const roomId = Number.parseInt(c.req.param('roomId'), 10) + const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10) + + // Scoped through the room so a subroom id from another room can't be written. + const room = await getRoomById(c.env.DB, roomId) + if (!room || !findSubRoom(room, subRoomId)) return c.notFound() + if (room.CreatorAccountId !== accountId) return c.body(null, 403) + + const permissions = parseRoomPermissions(await c.req.json().catch(() => null)) + await setSubRoomPermissions(c.env.DB, subRoomId, permissions) + return c.body(null, 200) + } + ) + // Clone a subroom into a new subroom of the same room (fresh SubRoomId, same // scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and // returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new @@ -2041,8 +2235,7 @@ const app = new Hono() } ) - // Photon access token + room permissions the client needs to spawn into a - // room. The client calls it on the rooms host both bare and under `/roomserver`. + // Photon access token + room permissions the client needs to spawn into a room. .get( '/photon_access_token', describeRoute({ @@ -2065,23 +2258,6 @@ const app = new Hono() }), handlePhotonAccessToken ) - .get( - '/roomserver/photon_access_token', - describeRoute({ - tags: ['Session'], - summary: 'Photon token + room permissions (legacy path)', - description: [ - 'Identical to `GET /photon_access_token` — the client calls it both bare and under the', - '`/roomserver` prefix, so both forms are registered.', - ].join(' '), - security: AUTHED, - responses: { - 200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'), - 401: UNAUTHORIZED_RESPONSE, - }, - }), - handlePhotonAccessToken - ) // The generated spec. Documentation only — no request is validated against it (see // openapi.ts). `hide: true` keeps this route out of its own output. diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 7da49c6..af54642 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -58,6 +58,25 @@ beforeAll(async () => { for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill). for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record) + + // Relationship table (owned by the api worker) — `visitedby/:playerId` reads it to + // check the caller is a friend of the player whose history they're asking for. + await env.DB.prepare( + `CREATE TABLE IF NOT EXISTS relationship ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + requester_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + relationship_type INTEGER NOT NULL DEFAULT 0 + )` + ).run() + const insertRel = env.DB.prepare( + 'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)' + ) + await env.DB.batch([ + insertRel.bind(791, 790, 3), // friends — the caller (791) is the requester + insertRel.bind(790, 792, 3), // friends — the caller (792) is the target + insertRel.bind(793, 790, 1), // request out, not accepted — 793 is NOT a friend + ]) }) describe('rooms endpoints', () => { @@ -260,6 +279,62 @@ describe('rooms endpoints', () => { expect(other).toEqual([]) }) + it('GET /rooms/visitedby/:playerId serves a friend’s visited rooms and 403s everyone else', async () => { + // Give 790 a visit history (cheering/favoriting stamps a last-visit). + const subject = await bearer('790') + await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, { + method: 'PUT', + headers: subject, + }) + await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { + method: 'PUT', + headers: subject, + }) + + // A mutual friend reads it — a bare array, regardless of which side of the + // relationship row the caller sits on. + for (const friend of ['791', '792']) { + const res = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, { + headers: await bearer(friend), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as Array<{ RoomId: number }> + expect(body.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([2, 12]) + } + + // Paginated via skip/take. + const page = (await ( + await SELF.fetch(`${ORIGIN}/rooms/visitedby/790?skip=0&take=1`, { + headers: await bearer('791'), + }) + ).json()) as unknown[] + expect(page.length).toBe(1) + + // Your own history is readable by id, not just via `me`. + const own = (await ( + await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, { headers: subject }) + ).json()) as unknown[] + expect(own.length).toBe(2) + + // A pending request is not a friendship, and a stranger is not either → 403. + for (const outsider of ['793', '794']) { + const res = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, { + headers: await bearer(outsider), + }) + expect(res.status).toBe(403) + } + + // No token at all → 401, never a fallback account. + const anon = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`) + expect(anon.status).toBe(401) + + // `visitedby/me` still routes to the literal handler, not the id pattern. + const me = (await ( + await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers: subject }) + ).json()) as unknown[] + expect(me.length).toBe(2) + }) + it('GET /rooms/hot returns a paginated { Results, TotalResults } of public rooms', async () => { const res = await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`) expect(res.status).toBe(200) @@ -1433,13 +1508,10 @@ describe('rooms endpoints', () => { }) it('GET /photon_access_token 401s without a token', async () => { - for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) { - const res = await SELF.fetch(`${ORIGIN}${path}`) - expect(res.status).toBe(401) - } + expect((await SELF.fetch(`${ORIGIN}/photon_access_token`)).status).toBe(401) }) - it('GET /photon_access_token (bare + /roomserver) returns permissions + presence instance', async () => { + it('GET /photon_access_token returns permissions + presence instance', async () => { // Seed the caller's presence so RoomInstanceId reflects their current instance. await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') .bind( @@ -1450,22 +1522,19 @@ describe('rooms endpoints', () => { }) ) .run() - const headers = await bearer('777') - for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) { - const res = await SELF.fetch(`${ORIGIN}${path}`, { headers }) - expect(res.status).toBe(200) - const body = (await res.json()) as { - Permissions: Array<{ Permission: string; Role: number }> - PhotonAccessToken: string - RoomInstanceId: number | null - } - expect(body.Permissions.length).toBe(11) - expect(body.RoomInstanceId).toBe(1000042) - // A non-dev account does NOT get the global (Role 0) maker pen. - expect( - body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0) - ).toBe(false) + const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('777') }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + Permissions: Array<{ Permission: string; Role: number }> + PhotonAccessToken: string + RoomInstanceId: number | null } + expect(body.Permissions.length).toBe(11) + expect(body.RoomInstanceId).toBe(1000042) + // A non-dev account does NOT get the global (Role 0) maker pen. + expect(body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toBe( + false + ) }) it('GET /photon_access_token returns null RoomInstanceId when the caller has no presence', async () => { @@ -1676,6 +1745,254 @@ describe('rooms endpoints', () => { expect(await accessibilityOf()).toBe(1) }) + // The permission table a room's creator saves on a subroom, and how it reaches the + // client: `PUT …/permissions` stores entries keyed by (Permission, Role), and + // `GET /photon_access_token` merges them over its defaults for whoever is standing in + // that subroom. Room 2 / subroom 2 is owned by account 1; account 743 is the visitor + // whose presence points at it. + describe('subroom permissions', () => { + type Permission = { Permission: string; Role: number; Override: boolean; Value: string } + + const putPermissions = async (path: string, body: unknown, sub?: string) => + SELF.fetch(`${ORIGIN}${path}`, { + method: 'PUT', + headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + // Put a player in an instance of the given subroom, then read the permission table + // the client would apply when it spawns there. + const permissionsIn = async (accountId: number, subRoomId: number): Promise => { + await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId, + roomInstance: { roomInstanceId: 1000900 + subRoomId, roomId: 2, subRoomId }, + expiresAt: Math.floor(Date.now() / 1000) + 900, + }) + ) + .run() + const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { + headers: await bearer(String(accountId)), + }) + expect(res.status).toBe(200) + return ((await res.json()) as { Permissions: Permission[] }).Permissions + } + + const entry = (list: Permission[], permission: string, role: number) => + list.find((p) => p.Permission === permission && p.Role === role) + + it('is auth-gated and creator-only', async () => { + const body = [ + { Permission: 'CAN_SAVE_INVENTIONS', Role: 30, Override: false, Type: 0, Value: 'True' }, + ] + // No token → 401. + expect((await putPermissions('/rooms/2/subrooms/2/permissions', body)).status).toBe(401) + // A valid token that isn't the room's creator → 403. + expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '999')).status).toBe( + 403 + ) + // Not even a co-owner: account 2 holds Role 30 on the seeded rooms. Co-owners may + // build in a room but don't decide what a role may do. + expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '2')).status).toBe(403) + // Unknown room / unknown subroom → 404. + expect((await putPermissions('/rooms/99999/subrooms/2/permissions', body, '1')).status).toBe( + 404 + ) + // A subroom id belonging to another room doesn't resolve either. + expect((await putPermissions('/rooms/2/subrooms/9999/permissions', body, '1')).status).toBe( + 404 + ) + }) + + it('answers an empty 200 — the client reads no body', async () => { + const res = await putPermissions( + '/rooms/2/subrooms/2/permissions', + [{ Permission: 'CAN_SPAWN_INVENTIONS', Role: 30, Override: true, Type: 0, Value: 'True' }], + '1' + ) + expect(res.status).toBe(200) + expect(await res.text()).toBe('') + }) + + it('a checked Override replaces the matching default in place', async () => { + const before = await permissionsIn(743, 2) + expect(before.length).toBe(11) + const at = before.findIndex((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 30) + // The default for this pair is an un-overridden grant. + expect(before[at]).toMatchObject({ Override: false, Value: 'True' }) + + expect( + ( + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [ + { + Permission: 'CAN_USE_MAKER_PEN', + Role: 30, + Override: true, + Type: 0, + Value: 'False', + }, + ], + '1' + ) + ).status + ).toBe(200) + + const after = await permissionsIn(743, 2) + // Replaced, not appended — and at the same index, so the table doesn't reshuffle. + expect(after.length).toBe(11) + expect(after[at]).toMatchObject({ + Permission: 'CAN_USE_MAKER_PEN', + Role: 30, + Override: true, + Value: 'False', + }) + + // Re-sending the same (Permission, Role) updates that entry rather than adding one. + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [{ Permission: 'CAN_USE_MAKER_PEN', Role: 30, Override: true, Type: 0, Value: 'True' }], + '1' + ) + const changed = await permissionsIn(743, 2) + expect(changed.length).toBe(11) + expect(changed[at]).toMatchObject({ Override: true, Value: 'True' }) + }) + + it('an unchecked Override erases the entry, back to the default', async () => { + const stored = async () => + (await env.DB.prepare( + `SELECT COUNT(*) AS n FROM subroom_permission + WHERE sub_room_id = 2 AND permission = 'CAN_USE_MAKER_PEN' AND role = 30` + ).first<{ n: number }>())!.n + + // The previous test left this pair overridden. + expect(await stored()).toBe(1) + + // `Override: false` means "fall back to the default" — the `Value` riding along is + // not stored, it's whatever the picker happened to show. + expect( + ( + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [ + { + Permission: 'CAN_USE_MAKER_PEN', + Role: 30, + Override: false, + Type: 0, + Value: 'True', + }, + ], + '1' + ) + ).status + ).toBe(200) + + // The row is gone, and the token serves the default for the pair again. + expect(await stored()).toBe(0) + const table = await permissionsIn(743, 2) + expect(table.length).toBe(11) + expect(entry(table, 'CAN_USE_MAKER_PEN', 30)).toMatchObject({ + Override: false, + Value: 'True', + }) + + // Clearing a pair that was never overridden is a no-op, not an insert. + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [{ Permission: 'CAN_INVITE', Role: 0, Override: false, Type: 0, Value: 'True' }], + '1' + ) + expect((await permissionsIn(743, 2)).length).toBe(11) + }) + + it('appends a permission the defaults do not carry, and scopes it to its subroom', async () => { + // CAN_INVITE is in none of the defaults, so it lands as a new entry. + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [{ Permission: 'CAN_INVITE', Role: 30, Override: true, Type: 0, Value: 'False' }], + '1' + ) + const inSubRoom2 = await permissionsIn(744, 2) + expect(inSubRoom2.length).toBe(12) + expect(entry(inSubRoom2, 'CAN_INVITE', 30)).toMatchObject({ + Override: true, + Value: 'False', + }) + + // A different subroom is untouched — the table is per-subroom, not per-room. + expect((await permissionsIn(744, 3)).length).toBe(11) + // And so is a player in no instance at all. + await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(744).run() + const lobby = await SELF.fetch(`${ORIGIN}/photon_access_token`, { + headers: await bearer('744'), + }) + expect(((await lobby.json()) as { Permissions: Permission[] }).Permissions.length).toBe(11) + }) + + it('keeps a Value that isn’t True/False verbatim', async () => { + // Not every permission's UI is the True/False picker, so nothing interprets the + // string — it goes to the client exactly as the creator set it. + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [{ Permission: 'MAX_SPAWNED_INVENTIONS', Role: 0, Override: true, Type: 0, Value: '25' }], + '1' + ) + expect(entry(await permissionsIn(747, 2), 'MAX_SPAWNED_INVENTIONS', 0)).toMatchObject({ + Override: true, + Value: '25', + }) + }) + + it('applies over the dev accounts’ global maker pen, without listing a pair twice', async () => { + await putPermissions( + '/rooms/2/subrooms/2/permissions', + [ + { Permission: 'CAN_USE_MAKER_PEN', Role: 0, Override: true, Type: 0, Value: 'False' }, + // The third sample body — a Role 0 grant the defaults already carry. + { + Permission: 'CAN_USE_DELETE_ALL_BUTTON', + Role: 0, + Override: true, + Type: 0, + Value: 'True', + }, + ], + '1' + ) + // Account 3 is one of the hardcoded dev accounts, so it gets the global (Role 0) + // maker pen prepended — which this subroom then revokes. The merge runs last and + // replaces it in place, so the pair appears exactly ONCE: a table listing it twice + // with two values would leave which one applies up to the client. + const devTable = await permissionsIn(3, 2) + expect(devTable.filter((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toEqual([ + { Override: true, Permission: 'CAN_USE_MAKER_PEN', Role: 0, Type: 0, Value: 'False' }, + ]) + expect(entry(devTable, 'CAN_USE_DELETE_ALL_BUTTON', 0)).toMatchObject({ Value: 'True' }) + + // A normal player in the same subroom sees the same revocation. + expect(entry(await permissionsIn(745, 2), 'CAN_USE_MAKER_PEN', 0)).toMatchObject({ + Value: 'False', + }) + }) + + it('a cloned subroom inherits the source’s permission table', async () => { + const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, { + method: 'POST', + headers: await bearer('1'), + }) + const room = (await res.json()) as { value: { SubRooms: Array<{ SubRoomId: number }> } } + const cloneId = Math.max(...room.value.SubRooms.map((s) => s.SubRoomId)) + + const inClone = await permissionsIn(746, cloneId) + expect(entry(inClone, 'CAN_INVITE', 30)).toMatchObject({ Value: 'False' }) + expect(entry(inClone, 'CAN_USE_MAKER_PEN', 0)).toMatchObject({ Value: 'False' }) + }) + }) + it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => { const clone = async (roomId: number, subRoomId: number, sub?: string) => SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, { @@ -1939,12 +2256,12 @@ describe('rooms endpoints', () => { 'GET /rooms/recommendations', 'GET /rooms/search', 'GET /rooms/visitedby/me', + 'GET /rooms/visitedby/{playerId}', 'GET /rooms/{roomId}', 'GET /rooms/{roomId}/interactionby/me', 'GET /rooms/{roomId}/playerdata/me', 'GET /rooms/{roomId}/similar', 'GET /rooms/{roomId}/subrooms/{subRoomId}/saves', - 'GET /roomserver/photon_access_token', 'GET /roomserver/rooms/createdby/me', 'POST /rooms/{roomId}/clone', 'POST /rooms/{roomId}/subrooms', @@ -1963,6 +2280,7 @@ describe('rooms endpoints', () => { 'PUT /rooms/{roomId}/roles/{accountId}', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility', 'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify', + 'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions', 'PUT /rooms/{roomId}/tags', 'PUT /rooms/{roomId}/warning', ]) diff --git a/apps/www/README.md b/apps/www/README.md index 0bb0dc1..86a88ee 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -25,8 +25,9 @@ Upstream hosts are derived from the shared base domain (`auth.`, | Method | Path | Upstream | | ------ | --------------- | -------------------------------------------------------- | +| GET | `/api/config` | none — whether signup is open, plus the Turnstile key | | POST | `/api/signup` | auth `POST /connect/token` (`grant_type=create_account`) | -| POST | `/api/login` | auth `POST /connect/token` (account id + password) | +| POST | `/api/login` | auth `POST /connect/token` (username + password) | | POST | `/api/logout` | clears the session cookie | | GET | `/api/me` | accounts `GET /account/me` | | POST | `/api/email` | accounts `POST /account/me/email` | @@ -35,6 +36,51 @@ Upstream hosts are derived from the shared base domain (`auth.`, On signup/login the access token returned by `auth` is stored in an httpOnly `rf_token` cookie; the other routes read it and forward it as a Bearer token. +`/api/signup` also takes an optional `email`, saved with a second call to accounts +`POST /account/me/email` once the session exists — `create_account` has no email +field, the accounts worker owns it. The address is format-checked before the +account is created, since a rejection afterwards would leave a player with an +account whose email silently didn't save; a failure of the save itself is logged +and does not fail the signup, because by then the account is real and a retry +would spend another slot against auth's per-IP cap. + +### Signup and Turnstile + +`POST /api/signup` creates an account with no platform identity (a password +account), so it's the one BFF route a bot could farm — `auth` binds no Steam id to +it and only its coarse per-IP cap applies. It therefore runs behind a +[Turnstile](https://developers.cloudflare.com/turnstile/) check: the browser posts +the widget's token, and the worker verifies it against Turnstile's `siteverify` +server-side before calling `auth`. The secret key never leaves the worker, and the +browser never talks to `siteverify` itself. + +Two Secrets Store secrets configure it, `TURNSTILE_SITE_KEY` and +`TURNSTILE_SECRET_KEY`, bound from the same account-level store every worker uses +for `JWT_SECRET` (see `wrangler.jsonc` and `src/turnstile.ts`) — the site key is +public, but keeping it with its secret makes the pair the single switch. Creating +both is what opens signup; if either fails to resolve, `/api/config` reports +`signupEnabled: false` (so the SPA shows sign-in only) and `/api/signup` returns +403, so an unconfigured worker serves no signup rather than an unprotected one. +A store read that throws is treated the same as a missing key — `/api/config` is on +the homepage's critical path and must not 500 when signup isn't set up. + +Because `.get()` caches per isolate, changing either value in the store needs a +`www` redeploy before a warm worker picks it up. + +For local dev, seed the two names into the **local** store (miniflare's, keyed by +the literal `local` store id — it is per-directory, so run these in `apps/www`) +with Turnstile's documented always-passes test keypair, which needs no widget and +no account: + +```sh +printf '1x00000000000000000000AA' | + wrangler secrets-store secret create local --name TURNSTILE_SITE_KEY --scopes workers +printf '1x0000000000000000000000000000000AA' | + wrangler secrets-store secret create local --name TURNSTILE_SECRET_KEY --scopes workers +``` + +The tests seed the same pair into their own local store in `beforeAll`. + ## Development ### Run in dev mode diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 4a1cf14..c7c18ce 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -1,6 +1,12 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' -import { DISCORD_INVITE, DOWNLOAD_URL, LICENSE_URL, SOURCE_REPO } from '../links' +import { + DISCORD_INVITE, + DOWNLOAD_URL, + LICENSE_URL, + QUEST_DOWNLOAD_URL, + SOURCE_REPO, +} from '../links' import type { ReactNode } from 'react' @@ -14,6 +20,16 @@ interface SelfAccount { isAdmin?: boolean } +/** + * Site config from the BFF (`/api/config`). `signupEnabled` is false when the operator + * has no Turnstile keypair configured — web signup runs behind that bot check, so + * without it the endpoint is closed and the UI must not offer the form. + */ +interface SiteConfig { + signupEnabled: boolean + turnstileSiteKey: string | null +} + /** * Call a www BFF endpoint. GET when no body is given, else POST JSON. Throws with * the upstream error message (auth uses `error`/`error_description`, the account @@ -85,12 +101,18 @@ function Link({ export function App() { // undefined = still checking the session; null = signed out. const [account, setAccount] = useState(undefined) + // undefined until the config lands. Signup is treated as closed until told otherwise, + // so a slow (or failed) config fetch can't flash a form the server would refuse. + const [config, setConfig] = useState(undefined) const { path, navigate } = useRouter() useEffect(() => { api('/api/me') .then((me) => setAccount(me)) .catch(() => setAccount(null)) + api('/api/config') + .then((c) => setConfig(c)) + .catch(() => setConfig({ signupEnabled: false, turnstileSiteKey: null })) }, []) const logout = useCallback(async () => { @@ -102,12 +124,22 @@ export function App() { return ( <> - {path === '/login' ? ( - + {path === '/login' || path === '/signup' ? ( + // One page, two doors. `/signup` exists so the homepage's create-account link + // lands on that tab instead of dropping people on sign-in to find it — and so + // the URL is linkable. Unknown paths fall back to index.html (see the assets + // config in wrangler.jsonc), so a cold load of /signup reaches the SPA. + ) : path === '/account' ? ( ) : ( - + )} @@ -205,12 +237,25 @@ function useSlideshow() { * on top of them. Everything about how the thing is built sits below, for whoever * scrolls looking for it. */ -function HomePage() { +function HomePage({ + account, + config, + navigate, +}: { + account: SelfAccount | null | undefined + config: SiteConfig | undefined + navigate: Navigate +}) { const feed = useSlideshow() + // The signup offer only makes sense to a signed-out visitor when the server would + // actually take one. `account === undefined` is still-checking, so it shows nothing + // rather than offering an account to someone who already has one. + const offerSignup = account === null && config?.signupEnabled === true + return (
- +
@@ -219,31 +264,36 @@ function HomePage() { } /** - * The hero: a rotating in-game photo with the headline and the way in over it. The - * photo is the backdrop, never the payload — when the feed is slow or down the stage - * still renders, so "Play now!" is reachable either way. + * The hero: the headline and the way in on the left, a rotating in-game photo on the + * right. The photo is proof, never the payload — when the feed is slow or down the + * frame holds its space and the left half reads the same, so "Play now!" is reachable + * either way. */ -function Stage({ slides }: { slides: Slide[] | null }) { +function Stage({ + slides, + offerSignup, + navigate, +}: { + slides: Slide[] | null + offerSignup: boolean + navigate: Navigate +}) { const [idx, setIdx] = useState(0) + const count = slides?.length ?? 0 + // A timeout keyed on the current slide rather than one long-lived interval: steering + // by hand re-arms it, so a photo you just picked gets its full six seconds. useEffect(() => { - if (!slides || slides.length < 2) return - const t = setInterval(() => setIdx((i) => (i + 1) % slides.length), 6000) - return () => clearInterval(t) - }, [slides]) + if (count < 2) return + const t = setTimeout(() => setIdx((i) => (i + 1) % count), 6000) + return () => clearTimeout(t) + }, [count, idx]) const slide = slides && slides.length > 0 ? slides[idx] : null + const step = (by: number) => setIdx((i) => (i + by + count) % count) return (
- {slide && ( - {`Photo - )}
{/* Deliberately doesn't name the game: this is a fan project, so the trademark stays out of the headline and appears lower down, in @@ -251,40 +301,90 @@ function Stage({ slides }: { slides: Slide[] | null }) {

Play like it's 2023.

+

+ The servers you remember, rebuilt and running — free, open source, and up right now. +

+ {/* A line rather than a fourth button: the download is the point of this page, + and launching the game makes an account by itself — signing up here is the + way in for someone who wants one first. Hidden entirely when signup is + closed, matching /login, which hides its create-account tab the same way. */} + {offerSignup && ( +

+ New here?{' '} + + Create an account + +

+ )}
- {slide && ( +
+
+ {slide && ( + {`Photo + )} +
+ {/* Always mounted, so the frame doesn't shift down when the feed lands. */}
- - Photo by @{slide.username} - {slide.roomName && ` in ${slide.roomName}`} - - {slides && slides.length > 1 && ( - - {slides.map((s, i) => ( - + + {idx + 1} / {count} + + )}
- )} +
) } +/** The slideshow's back/forward mark. Decorative — the buttons carry the label. */ +function Chevron({ next }: { next?: boolean }) { + return ( + + ) +} + /** What RecFlare is, under the fold, for whoever wants it. */ function About({ slides, error }: { slides: Slide[] | null; error: string }) { // The feed answering is proof the server replied, so the indicator can't claim @@ -297,8 +397,8 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {

An open source rebuild of the 2023 servers

A free fan project, made by players who missed it. Aiming to be{' '} - feature-complete and infinitely scalable — no gatekeeping, no basement - server. + feature-complete and infinitely scalable —{' '} + architected for the cloud, no gatekeeping, no basement server.

@@ -324,34 +424,85 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) { ) } -/** The sign-in page. Redirects to the account page once a session exists. */ +/** + * The sign-in page — sign in, plus create-account when the server says signup is open + * (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a + * session exists, however it was obtained. + */ function LoginPage({ account, + config, + initialTab, navigate, onAuthed, }: { account: SelfAccount | null | undefined + config: SiteConfig | undefined + initialTab: 'signup' | 'login' navigate: Navigate onAuthed: (a: SelfAccount) => void }) { + // The tab IS the route (`/login` vs `/signup`) rather than local state, so the two can + // never disagree — switching tabs pushes history, and back goes back to the other one. + const tab = initialTab + useEffect(() => { if (account) navigate('/account') }, [account, navigate]) + const authed = (a: SelfAccount) => { + onAuthed(a) + navigate('/account') + } + + const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null + return (
-

Sign in

-

- Launch the game first — that creates an account linked to your Steam ID. Once you set a - password, use your username and that password to sign in here. -

- { - onAuthed(a) - navigate('/account') - }} - /> + {siteKey && ( +
+ + +
+ )} + {siteKey && tab === 'signup' ? ( + <> +

Create account

+

+ A username is assigned for you — you'll see it on your account page. Choose a + password, and the two together sign you in here and in the game. +

+ + + ) : ( + <> +

Sign in

+

+ Use your username and password. Launching the game also creates an account, linked to + your Steam ID — set a password on it and it signs in here too. +

+ + {/* The tabs above already offer this; the line under the button is where + someone who just found out they have no account is actually looking. + Gated on the same key, so it can't point at a door that isn't there. */} + {siteKey && ( +

+ Don't have an account?{' '} + + Create one + +

+ )} + + )}
) @@ -409,9 +560,180 @@ function useAction() { return { pending, error, done, run } } -// Manual web signups are disabled for now, so only sign-in is exposed (accounts are -// created via the game/platform, not the website). To bring signups back, restore a -// SignupForm calling POST /api/signup and re-enable that endpoint in www.app.ts. +/** + * Turnstile's browser API, as much of it as the signup widget uses. Loaded from + * Cloudflare at runtime (see loadTurnstile) rather than bundled, so it isn't in + * node_modules and has no types of its own. + */ +interface TurnstileApi { + render: ( + el: HTMLElement, + opts: { + sitekey: string + action?: string + callback?: (token: string) => void + 'expired-callback'?: () => void + } + ) => string | undefined + reset: (widgetId?: string) => void + remove: (widgetId?: string) => void +} + +declare global { + interface Window { + turnstile?: TurnstileApi + } +} + +/** + * Load Turnstile's script, once per page, resolving when `window.turnstile` is ready. + * `render=explicit` stops it scanning the document for widgets: this is a SPA, so the + * container mounts and unmounts with the form and we render into it ourselves. + * + * The promise is cached at module scope, so switching tabs back and forth reuses the + * loaded script instead of appending another tag. A rejection is cached too — the retry + * is a page reload, which is what the error message asks for. + */ +let turnstileScript: Promise | null = null +function loadTurnstile(): Promise { + turnstileScript ??= new Promise((resolve, reject) => { + const el = document.createElement('script') + el.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit' + el.async = true + el.defer = true + el.onload = () => resolve() + el.onerror = () => reject(new Error('load failed')) + document.head.appendChild(el) + }) + return turnstileScript +} + +/** + * Mount a Turnstile widget and hand back the token it produces. No token means no + * submit: the BFF refuses a signup without one, so the form gates its button on it + * rather than letting the request fail. + * + * `reset` re-arms the widget for another attempt — a token is single-use, so a rejected + * signup can't be retried with the same one. + */ +function useTurnstile(siteKey: string) { + const container = useRef(null) + const widgetId = useRef(undefined) + const [token, setToken] = useState('') + const [error, setError] = useState('') + + useEffect(() => { + let live = true + loadTurnstile() + .then(() => { + // StrictMode mounts twice, and the cleanup below removes the first widget; bail + // if this effect is the stale one so we don't render into a detached container. + if (!live || !container.current || !window.turnstile) return + widgetId.current = window.turnstile.render(container.current, { + sitekey: siteKey, + // Marker Cloudflare uses to segment Turnstile integrations; carries no user data. + action: 'turnstile-spin-v1', + callback: (t) => setToken(t), + // Tokens expire after a few minutes; drop ours so the button locks again and + // Turnstile can hand us a fresh one. + 'expired-callback': () => setToken(''), + }) + }) + .catch(() => { + if (live) setError("Couldn't load the bot check — reload the page to try again.") + }) + + return () => { + live = false + if (widgetId.current) window.turnstile?.remove(widgetId.current) + widgetId.current = undefined + } + }, [siteKey]) + + const reset = useCallback(() => { + setToken('') + if (widgetId.current) window.turnstile?.reset(widgetId.current) + }, []) + + return { container, token, error, reset } +} + +/** + * Create an account from the website: a password, plus a Turnstile token proving a human + * filled the form. The username comes back auto-assigned from `auth` (players don't pick + * one), and the session is live on success — so this lands on the account page, where the + * username is shown. + */ +function SignupForm({ + siteKey, + onAuthed, +}: { + siteKey: string + onAuthed: (a: SelfAccount) => void +}) { + const [password, setPassword] = useState('') + const [email, setEmail] = useState('') + const { container, token, error: widgetError, reset } = useTurnstile(siteKey) + const { pending, error, run } = useAction() + + return ( +
{ + e.preventDefault() + void run(async () => { + try { + const { account } = await api<{ account: SelfAccount }>('/api/signup', { + password, + email, + turnstileToken: token, + }) + onAuthed(account) + return '' + } catch (err) { + // The token is spent either way, so re-arm the widget before they retry. + reset() + throw err + } + }) + }} + > + + {/* Optional, and the button doesn't wait on it — but it's the only contact detail + an account has, so the hint says plainly what it's for rather than leaving it + to be guessed. `type="email"` gets the right keyboard on mobile and a free + format check; the worker re-checks it before the account is created. */} + +
+ {widgetError &&

{widgetError}

} + {error &&

{error}

} + + + ) +} + function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) { const [username, setUsername] = useState('') const [password, setPassword] = useState('') diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index 820ebf3..cec1d41 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -84,7 +84,7 @@ body { max-width: 880px; } -/* The homepage: the stage runs full-bleed above this, so it brings its own top space. */ +/* The homepage: the stage above brings its own top space and shares this width. */ .shell.home { max-width: 1040px; padding-top: 0; @@ -153,19 +153,42 @@ body { /* ---- The stage (hero) --------------------------------------------------- */ /* - * Full-bleed, edge to edge under the nav: a photo somebody actually took in game, - * with the headline and the way in over it. The photo is the backdrop and never the - * payload — with no photo the stage is still a solid panel carrying the same words. + * Split down the middle: what this is and the way in on the left, a photo somebody + * actually took in game on the right. The photo is proof and never the payload — with + * no photo the frame holds its space and the left half carries the same words. */ .stage { - position: relative; + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + gap: 48px; + max-width: 1040px; + margin: 0 auto; + padding: 56px 20px 16px; +} + +/* min-width: 0 on both halves, or the split isn't one: a `1fr` track's automatic + minimum is its content's min-content width, so a wide child (the slideshow controls, + a long unbroken credit) grows its column past 50% and takes the space out of the + other one — which is how this last read as 30/70 with the buttons crushed. */ +.stage-show { display: flex; flex-direction: column; - justify-content: flex-end; - min-height: min(40vh, 320px); + gap: 12px; + min-width: 0; +} + +/* Fixed shape, filled by whatever lands: screenshots arrive at any aspect ratio, and a + frame that resized per photo would jog the headline beside it on every rotation. The + ratio is landscape rather than 4:3 so the photo doesn't tower over the column beside + it — equal columns still read unequal when one is half again as tall. */ +.stage-frame { + position: relative; + aspect-ratio: 3 / 2; overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); background: var(--surface-hi); - isolation: isolate; } .stage-photo { @@ -174,11 +197,6 @@ body { width: 100%; height: 100%; object-fit: cover; - z-index: -2; - /* Softened so the headline reads over any screenshot. Scaled up past the frame - because blur samples past the edges and would otherwise feather them. */ - filter: blur(5px) saturate(1.08); - transform: scale(1.06); animation: photo-in 0.7s ease; } @@ -188,40 +206,20 @@ body { } } -/* Scrim: enough weight at the bottom to hold white text over any screenshot. */ -.stage::before { - content: ''; - position: absolute; - inset: 0; - z-index: -1; - background: linear-gradient( - to top, - rgb(10 7 4 / 90%) 0%, - rgb(10 7 4 / 74%) 40%, - rgb(10 7 4 / 44%) 100% - ); -} - .stage-body { - max-width: 1040px; - width: 100%; - margin: 0 auto; - padding: 0 20px 28px; + min-width: 0; } +/* No width cap here, unlike the headings below: this one shares a row with the photo, + and a headline that stops short of its column makes the split read as 30/70. */ .stage-title { font-family: var(--display); font-weight: 800; - font-size: clamp(2rem, 5vw, 3.4rem); + font-size: clamp(2rem, 5vw, 3.5rem); line-height: 1; letter-spacing: -0.03em; - color: #fff; - margin: 0 0 20px; - max-width: 15ch; + margin: 0 0 16px; text-wrap: balance; - /* Bloom: a wide, soft shadow rather than a hard one, so it separates the type from - a bright screenshot without reading as a drop shadow. */ - text-shadow: 0 2px 30px rgb(8 5 2 / 55%); } /* The one place the orange carries meaning in the headline: the year it restores. */ @@ -230,55 +228,84 @@ body { color: var(--accent); } -/* Credit line and slide dots, sitting under the headline on the photo itself. */ +.stage-lede { + font-size: 1.05rem; + color: var(--muted); + margin: 0 0 28px; +} + +/* The signup offer under the hero buttons. Deliberately quieter than a CTA — it sits + below the downloads without competing with them — but the link itself carries the + accent so it reads as the action it is. */ +.stage-alt { + font-size: 0.925rem; + color: var(--muted); + margin: 18px 0 0; +} + +.stage-alt a { + color: var(--accent); + font-weight: 600; + text-decoration: none; +} + +.stage-alt a:hover { + text-decoration: underline; +} + +/* Credit line and slideshow controls, under the photo rather than on it. */ .stage-foot { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; - gap: 12px 20px; - max-width: 1040px; - width: 100%; - margin: 0 auto; - padding: 0 20px 22px; + gap: 8px 20px; + /* Reserved even while the feed is in flight, so nothing shifts when it lands. */ + min-height: 28px; font-size: 0.8rem; - color: rgb(255 255 255 / 72%); - text-shadow: 0 1px 12px rgb(8 5 2 / 60%); + color: var(--muted); } -/* The dots are the only way to steer the stage, so each one gets a 24px target - even though the mark itself is 7px. */ -.dots { +/* Long usernames and room names wrap instead of widening the column. */ +.credit { + min-width: 0; + overflow-wrap: anywhere; +} + +/* Back / forward, with the position between them. Fixed width whatever the feed + length is — see the note on .stage-show for what a per-photo control did here. */ +.steer { display: flex; - margin: -8px -6px; + align-items: center; + gap: 4px; + flex: none; } -.dots button { +.steer button { display: grid; place-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; padding: 0; - border: none; - background: none; + border: 1px solid var(--line); + border-radius: 8px; + background: transparent; + color: var(--muted); cursor: pointer; + transition: + color 0.15s ease, + border-color 0.15s ease; } -.dots button::after { - content: ''; - width: 7px; - height: 7px; - border-radius: 50%; - background: rgb(255 255 255 / 34%); - transition: background 0.2s ease; +.steer button:hover { + color: var(--text); + border-color: var(--muted); } -.dots button:hover::after { - background: rgb(255 255 255 / 65%); -} - -.dots button.on::after { - background: var(--accent); +/* Tabular figures so the frame doesn't twitch as the index rolls 9 → 10. */ +.count { + font-variant-numeric: tabular-nums; + padding: 0 6px; } /* ---- What it is (below the stage) --------------------------------------- */ @@ -544,6 +571,40 @@ h2 { color: var(--muted); } +/* Sign in / create account, at the top of the auth card. Two of a kind, so they read as + one control rather than as two buttons competing with the orange submit below. */ +.tabs { + display: flex; + gap: 6px; + margin-bottom: 20px; + padding: 4px; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 8px; +} + +.tabs button { + flex: 1; + background: transparent; + border: none; + color: var(--muted); + padding: 8px; + border-radius: 6px; + cursor: pointer; + font-family: var(--body); + font-size: 0.9rem; +} + +.tabs button:hover { + color: var(--text); +} + +.tabs button.active { + background: var(--surface-hi); + color: var(--text); + font-weight: 600; +} + /* ---- Forms -------------------------------------------------------------- */ label { @@ -572,6 +633,27 @@ textarea { min-height: 76px; } +/* Marks a field the form will submit without. Quiet, but next to the label rather than + inside the input, so it survives the field being filled in. */ +.optional { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + opacity: 0.7; + margin-left: 4px; +} + +/* Why a field is worth filling in, under the input it belongs to. Sits inside the label, + so it's read out with the field rather than as loose text after it. */ +.hint { + display: block; + margin-top: 6px; + font-size: 0.8rem; + line-height: 1.45; + color: var(--muted); +} + input:focus, textarea:focus { outline: 2px solid var(--accent); @@ -579,6 +661,14 @@ textarea:focus { border-color: transparent; } +/* Where the Turnstile iframe mounts. It brings its own chrome, so this only reserves the + space (the widget is 300×65 at normal size) — otherwise the submit button jumps down + the moment the check finishes loading. */ +.turnstile { + min-height: 65px; + margin-bottom: 14px; +} + button[type='submit'] { border: none; border-radius: 8px; @@ -602,6 +692,22 @@ button[type='submit']:disabled { cursor: default; } +/* The cross-link under an auth form ("Don't have an account? Create one"). Sits under + the submit button, which hugs its label, so it needs its own separation from it. */ +.swap { + margin: 16px 0 0; +} + +.swap a { + color: var(--accent); + font-weight: 600; + text-decoration: none; +} + +.swap a:hover { + text-decoration: underline; +} + /* ---- Utilities ---------------------------------------------------------- */ .big { @@ -633,6 +739,15 @@ button[type='submit']:disabled { /* ---- Responsive --------------------------------------------------------- */ +@media (max-width: 860px) { + /* One column: the words lead, the photo follows. */ + .stage { + grid-template-columns: 1fr; + gap: 32px; + padding: 40px 20px 8px; + } +} + @media (max-width: 760px) { /* One column: the copy first, then the links and the status under it. */ .about { @@ -643,8 +758,9 @@ button[type='submit']:disabled { } @media (max-width: 620px) { - .stage { - min-height: min(38vh, 300px); + .stage-actions .cta { + flex: 1 1 auto; + text-align: center; } .about-links .cta { diff --git a/apps/www/src/context.ts b/apps/www/src/context.ts index 24023a8..3bb45b0 100644 --- a/apps/www/src/context.ts +++ b/apps/www/src/context.ts @@ -6,6 +6,23 @@ export type Env = SharedHonoEnv & { DOMAIN: string /** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */ ASSETS: Fetcher + /** + * The Turnstile widget's public site key. Public by design — it ships to the browser so + * the widget can render — but it lives in the Secrets Store beside its secret, so one + * place configures signup and there's a single place to look. + * + * Resolve the value with `await env.TURNSTILE_SITE_KEY.get()`. + */ + TURNSTILE_SITE_KEY: SecretsStoreSecret + /** + * The Turnstile widget's secret key — the one that turns a widget token into a verdict. + * Same shared account-level store as JWT_SECRET; the store id is spliced into + * wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE). + * + * Store values survive a deploy, so both are created once and left alone. Either one + * failing to resolve closes web signup — see src/turnstile.ts. + */ + TURNSTILE_SECRET_KEY: SecretsStoreSecret } /** Variables can be extended */ diff --git a/apps/www/src/links.ts b/apps/www/src/links.ts index 86375ef..f3e7e8e 100644 --- a/apps/www/src/links.ts +++ b/apps/www/src/links.ts @@ -13,6 +13,9 @@ export const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz' /** Where the stage's "Download for PC" button goes: the client's release listing. */ export const DOWNLOAD_URL = 'https://github.com/djdevin/recflare-client/releases' +/** The stage's "Download for Quest" button: the build's listing on the Meta store. */ +export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/6w1HPL3j2' + /** The public source repo, linked from the homepage and footer. */ export const SOURCE_REPO = 'https://github.com/djdevin/recflare' diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index eebcddd..5501645 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -1,8 +1,27 @@ -import { SELF } from 'cloudflare:test' -import { expect, it } from 'vitest' +import { adminSecretsStore, env, SELF } from 'cloudflare:test' +import { beforeAll, expect, it } from 'vitest' import { DOCUMENTED_SERVICES } from '../../docs' import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links' +import { turnstileKeys } from '../../turnstile' + +import type { Env } from '../../context' + +declare module 'cloudflare:test' { + interface ProvidedEnv extends Env {} +} + +// Turnstile's documented always-passes test keypair, seeded into the LOCAL Secrets Store +// so the bindings resolve — the same way every other worker's tests seed JWT_SECRET. It +// stands in for the two account-level secrets a deployed www reads, and it's what OPENS +// signup (see src/turnstile.ts): without it every signup test would test the closed door. +const TEST_SITE_KEY = '1x00000000000000000000AA' +const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA' + +beforeAll(async () => { + await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY) + await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY) +}) it('rejects unauthenticated account reads', async () => { const res = await SELF.fetch('https://example.com/api/me') @@ -10,14 +29,85 @@ it('rejects unauthenticated account reads', async () => { expect(await res.json()).toEqual({ error: 'not signed in' }) }) -it('refuses manual signups (disabled)', 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). +it('advertises signup with the Turnstile site key the widget needs', async () => { + const res = await SELF.fetch('https://example.com/api/config') + expect(res.status).toBe(200) + // Read through the Secrets Store binding, from the value seeded above. + expect(await res.json()).toEqual({ + signupEnabled: true, + turnstileSiteKey: TEST_SITE_KEY, + }) +}) + +// The keypair is the on/off switch for signup, so a www whose keys don't resolve must +// report it closed — that's the state a fresh deploy starts in, before the operator +// creates the two secrets. Checked directly because the real bindings are seeded for the +// fetch tests above. +// +// A store read that THROWS (secret absent, store unreachable) has to close the door the +// same way rather than surface as an error: /api/config is on the homepage's critical +// path, and a 500 there costs the whole page, not just the signup form. +it('treats an unresolvable or half-configured keypair as signup being off', async () => { + const stub = (value: string | null): SecretsStoreSecret => + ({ get: async () => value ?? '' }) as SecretsStoreSecret + const throws = (): SecretsStoreSecret => + ({ + get: async () => { + throw new Error('secret not found') + }, + }) as unknown as SecretsStoreSecret + + const withKeys = (site: SecretsStoreSecret, secret: SecretsStoreSecret) => + ({ + ENVIRONMENT: 'development', + TURNSTILE_SITE_KEY: site, + TURNSTILE_SECRET_KEY: secret, + }) as Env + + await expect(turnstileKeys(withKeys(throws(), throws()))).resolves.toBeNull() + await expect(turnstileKeys(withKeys(stub('0xsite'), throws()))).resolves.toBeNull() + await expect(turnstileKeys(withKeys(throws(), stub('0xsecret')))).resolves.toBeNull() + await expect(turnstileKeys(withKeys(stub(''), stub('0xsecret')))).resolves.toBeNull() + await expect(turnstileKeys(withKeys(stub('0xsite'), stub('0xsecret')))).resolves.toEqual({ + siteKey: '0xsite', + secretKey: '0xsecret', + }) +}) + +it('refuses a signup with no Turnstile token', async () => { const res = await SELF.fetch('https://example.com/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password: 'whatever' }), }) - expect(res.status).toBe(403) - expect(await res.json()).toEqual({ error: 'Account creation is currently disabled.' }) + // Rejected before any upstream call, so a bot can't reach create_account by omitting it. + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Please complete the bot check.' }) +}) + +// The email is optional, but a malformed one is rejected BEFORE the account is created — +// the accounts worker would refuse to store it, and by then the account exists and the +// player would be left with an account whose email silently didn't save. +it('refuses a signup whose email could not be stored', async () => { + const res = await SELF.fetch('https://example.com/api/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password: 'whatever', email: 'not-an-address', turnstileToken: 'x' }), + }) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'That email address looks wrong.' }) +}) + +it('refuses a signup with no password', async () => { + const res = await SELF.fetch('https://example.com/api/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ turnstileToken: 'dummy' }), + }) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'A password is required.' }) }) it('requires credentials to log in', async () => { diff --git a/apps/www/src/turnstile.ts b/apps/www/src/turnstile.ts new file mode 100644 index 0000000..7f5c880 --- /dev/null +++ b/apps/www/src/turnstile.ts @@ -0,0 +1,129 @@ +import { logger } from '@repo/hono-helpers' + +import type { Env } from './context' + +/** + * Cloudflare Turnstile, the bot check in front of web signup. Two keys make a widget: + * the SITE key, which is public (it ships in the page markup so the browser can render + * the widget), and the SECRET key, which stays on the worker and is the only thing that + * can turn a widget token into a verdict. Both are held in the shared Secrets Store. + * + * The verdict is fetched server-side, here in the BFF — never from the browser, which + * would hand the secret to anyone who viewed source. The browser's only job is to carry + * the widget's token to `POST /api/signup`. + * + * Turnstile is what makes web signup safe to leave open: `auth`'s per-IP cap is the only + * other thing standing in front of the password/anonymous account path (it has no + * platform identity to count), and that cap is coarse enough that it can't be the whole + * defence. So the keypair IS the switch — no keypair, no signup (see `turnstileKeys`). + * Nothing is ever inferred from the environment, so a worker can't end up with signup + * open and no bot check behind it. + */ + +/** Turnstile's verdict endpoint. Called from the worker only; the secret never leaves it. */ +const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' + +/** + * The keypair web signup runs on, or null when there isn't one — which is what closes + * signup (`/api/config` reports it, `/api/signup` refuses). Both keys come from the + * account-level Secrets Store the whole monorepo shares (see context.ts), so they're + * resolved per request rather than read off `env` as strings. + * + * Both must resolve. Half a configuration (a site key whose secret is missing from the + * store) counts as unconfigured rather than as a widget whose token nobody can check, and + * says so in the log — it's otherwise indistinguishable from signup being deliberately + * off. A `.get()` that throws (secret absent from the store, binding not deployed, store + * unreachable) is treated the same way, so a Worker that can't read its keys closes the + * door instead of 500ing on the homepage. + * + * `.get()` caches per isolate, so changing a value in the store needs a `www` redeploy to + * take effect on a warm worker — the same caveat the shared JWT_SECRET carries. + * + * For local dev, seed the two names into the LOCAL store (miniflare's, not your account's) + * with Turnstile's documented always-passes test keypair — see apps/www/README.md. That + * pair belongs to no account and passes without a human. Deliberately not a built-in + * fallback: the same code path then runs everywhere. + */ +export async function turnstileKeys( + env: Env +): Promise<{ siteKey: string; secretKey: string } | null> { + const [siteKey, secretKey] = await Promise.all([ + readSecret(env.TURNSTILE_SITE_KEY, 'TURNSTILE_SITE_KEY'), + readSecret(env.TURNSTILE_SECRET_KEY, 'TURNSTILE_SECRET_KEY'), + ]) + if (siteKey !== '' && secretKey !== '') return { siteKey, secretKey } + if (siteKey !== '' || secretKey !== '') { + logger.error('turnstile is half-configured, so web signup is closed', { + hasSiteKey: siteKey !== '', + hasSecretKey: secretKey !== '', + }) + } + return null +} + +/** + * One Secrets Store value as a string, or '' when it can't be read. The binding is + * declared in wrangler.jsonc, so it's always present on `env`; what varies is whether the + * store actually holds the secret — a missing one throws here rather than resolving empty. + */ +async function readSecret(secret: SecretsStoreSecret, name: string): Promise { + try { + return (await secret.get()) ?? '' + } catch (err) { + logger.error('failed to read a turnstile key from the secrets store', { + secret: name, + error: String(err), + }) + return '' + } +} + +/** Turnstile's siteverify response, narrowed to the fields we act on. */ +interface SiteVerifyResponse { + success?: boolean + 'error-codes'?: string[] +} + +/** + * Ask Turnstile whether a widget token is good. `remoteIp` is the client's real IP per + * Cloudflare (`CF-Connecting-IP`), which Turnstile cross-checks against the one that + * solved the challenge; it's omitted when absent rather than sent empty. + * + * A token is single-use, so a failed verdict means the widget has to be reset before the + * player can retry — the client does that (see the signup form). + * + * Any failure to reach Turnstile is a rejection, not a pass: this is the only bot check + * in front of signup, so a broken verdict path must not open the door. + */ +export async function verifyTurnstile( + secretKey: string, + token: string, + remoteIp?: string +): Promise { + const fields: Record = { secret: secretKey, response: token } + if (remoteIp) fields.remoteip = remoteIp + + try { + const res = await fetch(SITEVERIFY_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(fields).toString(), + }) + if (!res.ok) { + logger.error('turnstile siteverify failed', { status: res.status }) + return false + } + const verdict = (await res.json()) as SiteVerifyResponse + if (verdict.success !== true) { + // The codes name the reason (`invalid-input-response`, `timeout-or-duplicate`, + // `invalid-input-secret`, …) — the last of those is a misconfiguration, not a bot, + // and this log line is the only place it shows up. + logger.info('turnstile rejected a signup', { codes: verdict['error-codes'] ?? [] }) + return false + } + return true + } catch (err) { + logger.error('turnstile siteverify threw', { error: String(err) }) + return false + } +} diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index 98daaeb..2d25b7e 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -2,11 +2,12 @@ import { Hono } from 'hono' import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { useWorkersLogger } from 'workers-tagged-logger' -import { withOnError } from '@repo/hono-helpers' +import { logger, withOnError } from '@repo/hono-helpers' import { NotificationType } from '../../notify/src/notification-types' import { docsPage, fetchSpec } from './docs' import { privacyPage } from './privacy' +import { turnstileKeys, verifyTurnstile } from './turnstile' import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream' import type { Context } from 'hono' @@ -87,8 +88,12 @@ async function relay(c: Context, res: Response) { * Exchange an auth `/connect/token` response for a session: persist the returned * access token in the httpOnly cookie, then return the caller's self account * (fetched from the accounts worker with the fresh token). + * + * `email`, when given, is saved onto the new account before that fetch, so the account + * comes back already carrying it. `create_account` takes no email — the accounts worker + * owns that field — which is why this is a second call rather than another grant field. */ -async function establishSession(c: Context, tokenResponse: Response) { +async function establishSession(c: Context, tokenResponse: Response, email?: string) { if (!tokenResponse.ok) return relay(c, tokenResponse) const token = (await tokenResponse.json()) as { access_token?: string; expires_in?: number } @@ -103,6 +108,25 @@ async function establishSession(c: Context, tokenResponse: Response) { sessionCookieOptions(c, token.expires_in ?? 3600) ) + // Deliberately not fatal: the account exists and the session is live by now, so failing + // the request would leave the player holding an account they think they don't have — + // and a retry would burn another slot against auth's per-IP signup cap. They land on + // the account page instead, where the email field is the same one call away. The + // address is validated before signup starts, so reaching here means something upstream + // went wrong, not that the input was bad. + if (email) { + const res = await postForm( + `${accountsBase(c.env)}/account/me/email`, + { email }, + token.access_token + ) + if (!res.ok) { + logger.error('failed to save the signup email; the account was still created', { + status: res.status, + }) + } + } + const me = await fetch(`${accountsBase(c.env)}/account/me`, { headers: { authorization: `Bearer ${token.access_token}` }, }) @@ -126,12 +150,63 @@ const app = new Hono() // ---- BFF API ------------------------------------------------------------ - // Manual web signups are disabled for now — accounts are created via the game / - // platform, not the website. Kept as an explicit closed endpoint (rather than - // removed) so a direct POST is refused too, not just hidden in the UI. To reopen, - // forward a platform-less `grant_type=create_account` to auth and start a session - // (see git history), and restore the SignupForm in the client. - .post('/api/signup', (c) => c.json({ error: 'Account creation is currently disabled.' }, 403)) + // What the SPA has to know before it can render the sign-in page: whether web signup + // is open, and the Turnstile site key to mount its widget with. The site key is public + // (it ships in the widget markup either way); the secret never leaves the worker. + // Served rather than baked into the client build so one build works for any operator. + .get('/api/config', async (c) => { + const keys = await turnstileKeys(c.env) + return c.json({ signupEnabled: keys !== null, turnstileSiteKey: keys?.siteKey ?? null }) + }) + + // Create an account from the website, behind a Turnstile bot check. The check is what + // makes this safe to leave open: `auth` binds no platform identity to a web account, so + // its per-IP cap is the only other thing in front of this path. + // + // Deliberately passes NO `platform`: create_account treats an asserted platform as one + // to verify against Steam and would reject RecNet (see WEB_PLATFORM), so this is the + // platform-less password-account path. The username is auto-assigned by auth — players + // don't pick one — and the new session is established from the token response. + .post('/api/signup', async (c) => { + // No usable keypair means signup is closed rather than unprotected (see turnstile.ts). + const keys = await turnstileKeys(c.env) + if (!keys) return c.json({ error: 'Account creation is currently disabled.' }, 403) + + type SignupBody = { password?: string; email?: string; turnstileToken?: string } + const { password, email, turnstileToken } = await c.req + .json() + .catch(() => ({}) as SignupBody) + if (!password) return c.json({ error: 'A password is required.' }, 400) + if (!turnstileToken) return c.json({ error: 'Please complete the bot check.' }, 400) + + // Optional — an account works without one; it's the address a locked-out player + // would be reached at. Checked HERE, before anything is created, because the + // accounts worker rejects an address with no `@` and by then the account exists: + // better to fail the form than to hand back an account whose email silently didn't + // save. Same rule the accounts worker applies, deliberately no stricter — this is + // a contact address, not an identity, and nothing is sent to it to prove it. + const signupEmail = typeof email === 'string' ? email.trim() : '' + if (signupEmail !== '' && !signupEmail.includes('@')) { + return c.json({ error: 'That email address looks wrong.' }, 400) + } + + // The IP Turnstile cross-checks the token against — set by the edge, so the client + // can't spoof it (unlike X-Forwarded-For). `auth` records the same header as the + // account's signup IP. + const verified = await verifyTurnstile( + keys.secretKey, + turnstileToken, + c.req.header('cf-connecting-ip') + ) + // A token is single-use, so the client resets its widget before letting them retry. + if (!verified) return c.json({ error: 'Bot check failed. Please try again.' }, 403) + + const res = await postForm(`${authBase(c.env)}/connect/token`, { + grant_type: 'create_account', + password, + }) + return establishSession(c, res, signupEmail || undefined) + }) // Log in with a username + password, then start a session. The auth password grant // resolves the account by `username` (case-insensitive) — web players sign in with diff --git a/apps/www/vitest.config.ts b/apps/www/vitest.config.ts index de0d903..08a9356 100644 --- a/apps/www/vitest.config.ts +++ b/apps/www/vitest.config.ts @@ -8,6 +8,10 @@ export default defineConfig({ miniflare: { bindings: { ENVIRONMENT: 'VITEST', + // The Turnstile keypair is NOT bound here: both keys come from the Secrets + // Store now, and the tests seed the local store with the test pair (see + // src/test/integration/api.test.ts). A plain binding of the same name would + // shadow the store binding with a string. }, }, }), diff --git a/apps/www/wrangler.jsonc b/apps/www/wrangler.jsonc index 4ffd299..6a0e501 100644 --- a/apps/www/wrangler.jsonc +++ b/apps/www/wrangler.jsonc @@ -28,6 +28,32 @@ "not_found_handling": "single-page-application", "run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy"] }, + // The Turnstile keypair guarding web signup, out of the same account-level Secrets + // Store every other worker binds for JWT_SECRET — values live there, never in this + // file. The "local" store_id placeholder is replaced with RECFLARE_SECRETS_STORE at + // deploy time, exactly as it is for the other workers. + // + // wrangler secrets-store secret create --name TURNSTILE_SITE_KEY \ + // --scopes workers --remote + // wrangler secrets-store secret create --name TURNSTILE_SECRET_KEY \ + // --scopes workers --remote + // + // Creating both is what OPENS signup; if either can't be resolved it stays closed, so + // an operator who skips this gets no signup rather than an unprotected one. The SITE + // key is public (it ships to the browser to render the widget) and is kept here beside + // its secret so one place configures signup. See src/turnstile.ts. + "secrets_store_secrets": [ + { + "binding": "TURNSTILE_SITE_KEY", + "store_id": "local", + "secret_name": "TURNSTILE_SITE_KEY" + }, + { + "binding": "TURNSTILE_SECRET_KEY", + "store_id": "local", + "secret_name": "TURNSTILE_SECRET_KEY" + } + ], "upload_source_maps": true, "observability": { "logs": { diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 6799ebf..567098b 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -82,6 +82,26 @@ export const SUBROOM_SCHEMA_DDL: string[] = [ data TEXT NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`, + // Per-subroom permission overrides (migrations/0009_subroom_permissions.sql). The room + // owner's permission table for one subroom, keyed by (permission, role) — that pair is + // what the client's PUT addresses, and re-sending it overwrites the stored row rather + // than appending a second one. + // + // A row IS an override, which is why the client's `Override` flag is not a column: it's + // the checkbox next to the permission, so clearing it deletes the row and the pair falls + // back to its default. `value` is the client's string, stored verbatim. + // + // Deliberately NOT in the subroom's `data` blob: that blob is served to the client + // verbatim as part of the room, and these overrides are read on one path only + // (`GET /photon_access_token`, where they overwrite the matching default entries). + `CREATE TABLE IF NOT EXISTS subroom_permission ( + sub_room_id INTEGER NOT NULL, + permission TEXT NOT NULL, + role INTEGER NOT NULL, + type INTEGER NOT NULL DEFAULT 0, + value TEXT NOT NULL, + PRIMARY KEY (sub_room_id, permission, role) + )`, ] /** A stored room — the parsed JSON blob (full client-facing room response). */ @@ -695,6 +715,9 @@ export async function deleteSubRoom( // The saves go with it — nothing can reference them once the subroom is gone. // The blobs they point at are left in R2, like a deleted room's images. db.prepare('DELETE FROM subroom_save WHERE sub_room_id = ?1').bind(subRoomId), + // So do its permission overrides — subroom ids are minted from one global + // sequence, but leaving orphans would still be dead rows nothing can reach. + db.prepare('DELETE FROM subroom_permission WHERE sub_room_id = ?1').bind(subRoomId), ]) const room = await getRoomById(db, roomId) @@ -942,6 +965,119 @@ export async function getSubRoomSaveById( return row ? parseSubRoomSaveRow(row) : null } +// ---- Subroom permissions -------------------------------------------------- + +/** + * One entry of a subroom's permission table, in the client's own shape. `Value` is a + * STRING, not a boolean — usually `"True"`/`"False"`, but a permission whose UI isn't a + * True/False picker carries something else, so it is stored and served verbatim. `Role` + * is the tier the entry applies to (0 = everyone, 30 = co-owner, …). `Permission` + `Role` + * identify an entry: the client PUTs the pair it wants changed, and the same pair + * overwrites the matching default in the photon access token's table. + * + * `Override` is the row's own existence, not data: the client's UI is a checkbox ("is + * this permission overridden in this subroom?") plus a True/False picker for the value. + * Unchecking it means "fall back to the default", so an entry arriving with + * `Override: false` DELETES the stored row rather than storing anything. Every stored + * entry is therefore an override, and reads always serve `Override: true`. + */ +export interface RoomPermission { + Permission: string + Role: number + Override: boolean + Type: number + Value: string +} + +interface RoomPermissionRow { + permission: string + role: number + type: number + value: string +} + +const toRoomPermission = (row: RoomPermissionRow): RoomPermission => ({ + // A stored row IS the override — the table holds nothing else (see RoomPermission). + Override: true, + Permission: row.permission, + Role: row.role, + Type: row.type, + Value: row.value, +}) + +/** The permission columns, in the order the read/copy statements use. */ +const PERMISSION_COLUMNS = 'permission, role, type, value' + +/** + * A subroom's stored permission overrides, in the order they were first set. Empty for a + * subroom whose owner has never overridden a permission — the photon access token then + * serves its defaults untouched. + */ +export async function getSubRoomPermissions( + db: D1Database, + subRoomId: number +): Promise { + const { results } = await db + .prepare( + `SELECT ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1 ORDER BY rowid` + ) + .bind(subRoomId) + .all() + return results.map(toRoomPermission) +} + +/** + * Apply permission changes to a subroom, keyed by (`Permission`, `Role`). Only the pairs + * supplied are touched; every other stored entry is left alone. + * + * `Override` decides which way an entry goes, mirroring the checkbox the client draws + * next to each permission: true STORES the `Value` for that pair (overwriting whatever + * was there), false CLEARS it, so the pair falls back to the photon access token's + * default. Clearing a pair that was never overridden is a no-op. + */ +export async function setSubRoomPermissions( + db: D1Database, + subRoomId: number, + permissions: RoomPermission[] +): Promise { + if (permissions.length === 0) return + const upsert = db.prepare( + `INSERT INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS}) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT (sub_room_id, permission, role) + DO UPDATE SET type = excluded.type, value = excluded.value` + ) + const clear = db.prepare( + 'DELETE FROM subroom_permission WHERE sub_room_id = ?1 AND permission = ?2 AND role = ?3' + ) + await db.batch( + permissions.map((p) => + p.Override + ? upsert.bind(subRoomId, p.Permission, p.Role, p.Type, p.Value) + : clear.bind(subRoomId, p.Permission, p.Role) + ) + ) +} + +/** + * Copy a subroom's permission overrides onto another subroom — a clone inherits the + * source's permission table along with its scene and settings. Replaces any entry the + * destination already holds for the same (permission, role). + */ +async function copySubRoomPermissions( + db: D1Database, + fromSubRoomId: number, + toSubRoomId: number +): Promise { + await db + .prepare( + `INSERT OR REPLACE INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS}) + SELECT ?2, ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1` + ) + .bind(fromSubRoomId, toSubRoomId) + .run() +} + /** * Insert a subroom for a room, minting a fresh globally-unique SubRoomId from the * table's autoincrement sequence. Returns the created subroom (with its new id). @@ -963,6 +1099,12 @@ export async function insertSubRoom( CurrentSave: null, StagedSubRoomDataSaveId: null, } + // The permission overrides follow the copy too — they live in their own table (keyed by + // the id the caller is cloning FROM), so unlike the rest of the settings they aren't + // carried by the blob. A fresh subroom (`createSubRoom`) passes no id and copies nothing. + if (typeof sub.SubRoomId === 'number') { + await copySubRoomPermissions(db, sub.SubRoomId, subRoomId) + } // A copied subroom (room clone, subroom clone) carries the source's save. It gets its // OWN row — a save belongs to exactly one subroom, so sharing the source's id would // make the copy's content follow the source's future saves. @@ -1037,12 +1179,18 @@ export async function deleteRoom(db: D1Database, roomId: number): Promise await db.batch([ db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId), db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId), - // Saves first — they're keyed by subroom, so they'd be unreachable afterwards. + // Saves and permission overrides first — both are keyed by subroom, so they'd be + // unreachable once the subrooms themselves are gone. db .prepare( 'DELETE FROM subroom_save WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)' ) .bind(roomId), + db + .prepare( + 'DELETE FROM subroom_permission WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)' + ) + .bind(roomId), db.prepare('DELETE FROM subroom WHERE room_id = ?1').bind(roomId), ]) } diff --git a/packages/tools/bin/run-wrangler-deploy b/packages/tools/bin/run-wrangler-deploy index 5e27297..fb22994 100755 --- a/packages/tools/bin/run-wrangler-deploy +++ b/packages/tools/bin/run-wrangler-deploy @@ -48,16 +48,61 @@ CONFIG="wrangler.jsonc" # (main: index.js), the resolved assets.directory, and no_bundle. The committed # wrangler.jsonc leaves assets.directory out on purpose — the plugin fills it in — # so deploying the source config fails with "assets ... missing the required -# directory property". Prefer the generated config when it exists. These workers -# have no D1/KV/Secrets bindings, so the id-splicing below is skipped. +# directory property". Prefer the generated config when it exists. +# +# The plugin copies the bindings across verbatim, placeholders and all, so these +# configs need the same id-splicing as the rest — www binds the Secrets Store for its +# Turnstile keys. It gets its own branch below because the emitted file is minified +# single-line JSON, which the line-oriented sed/awk passes can't edit correctly. VITE_CONFIG="dist/$DIR/wrangler.json" +IS_VITE="" if [ -f "$VITE_CONFIG" ]; then CONFIG="$VITE_CONFIG" + IS_VITE=1 fi -NEEDS_D1=$(grep -q '"database_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true) -NEEDS_KV=$(grep -q '"id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true) -NEEDS_STORE=$(grep -q '"store_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true) +NEEDS_D1=$(grep -q '"database_id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true) +NEEDS_KV=$(grep -q '"id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true) +NEEDS_STORE=$(grep -q '"store_id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true) + +# Vite-built config: plain JSON, so jq does the splicing structurally (by binding +# name for KV) rather than by line. Written beside the original so its relative +# paths (main, assets.directory) still resolve. Gitignored; removed on exit. +if [ "$CONFIG" = "$VITE_CONFIG" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then + GENERATED="dist/$DIR/wrangler.generated.json" + trap 'rm -f "$GENERATED"' EXIT + + if [ -n "$NEEDS_D1" ] && [ -z "${RECFLARE_D1:-}" ]; then + echo "error: RECFLARE_D1 is not set — add the recflare D1 id to .env (see .env.example)" >&2 + exit 1 + fi + if [ -n "$NEEDS_STORE" ] && [ -z "${RECFLARE_SECRETS_STORE:-}" ]; then + echo "error: RECFLARE_SECRETS_STORE is not set — add the secrets store id to .env (see .env.example)" >&2 + exit 1 + fi + + KV_JSON=${RECFLARE_KV:-} + [ -n "$KV_JSON" ] || KV_JSON='{}' + + jq \ + --arg db "${RECFLARE_D1:-}" \ + --arg store "${RECFLARE_SECRETS_STORE:-}" \ + --argjson kv "$KV_JSON" ' + (.d1_databases // []) |= map( + if .database_id == "local" then .database_id = $db else . end + ) + | (.kv_namespaces // []) |= map( + if .id == "local" then + .id = ($kv[.binding] // + error("no KV id for binding [" + .binding + "] in RECFLARE_KV — add it to .env (see .env.example)")) + else . end + ) + | (.secrets_store_secrets // []) |= map( + if .store_id == "local" then .store_id = $store else . end + ) + ' "$VITE_CONFIG" >"$GENERATED" || exit 1 + CONFIG="$GENERATED" +fi if [ "$CONFIG" = "wrangler.jsonc" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then CONFIG="wrangler.generated.jsonc" @@ -131,8 +176,10 @@ EXTRA_VARS=$(recflare_vars) # Vite-built configs set no_bundle (vite already bundled and minified), which is # incompatible with --minify. Only pass --minify when wrangler does the bundling. +# Keyed on IS_VITE, not on $CONFIG: the splicing above may have swapped $CONFIG for +# the generated copy, which is just as no_bundle as the file it came from. MINIFY="--minify" -[ "$CONFIG" = "$VITE_CONFIG" ] && MINIFY="" +[ -n "$IS_VITE" ] && MINIFY="" # Deploy with wrangler using the extracted values as binding variables echo "Deploying worker $NAME version $VERSION to $HOST"