From 339a91735bd7a353286328a8c2cf9bcffefd00a2 Mon Sep 17 00:00:00 2001 From: devin Date: Mon, 3 Aug 2026 15:20:10 -0400 Subject: [PATCH] Add account signup and turnstile, subroom perms (#24) * turnstile * require turnstile * homepage refresh * implemented rooms visited endpoint for friends * update default profile pic * add a meta download button * add subroom permissions * enable signup --- .env.example | 13 + DEPLOYING.md | 46 ++ apps/img/static/DefaultProfileImage.jpg | Bin 17717 -> 16924 bytes .../migrations/0009_subroom_permissions.sql | 29 ++ apps/rooms/src/openapi.ts | 49 +- apps/rooms/src/rooms.app.ts | 234 ++++++++-- apps/rooms/src/test/integration/api.test.ts | 360 ++++++++++++++- apps/www/README.md | 48 +- apps/www/src/client/App.tsx | 436 +++++++++++++++--- apps/www/src/client/styles.css | 256 +++++++--- apps/www/src/context.ts | 17 + apps/www/src/links.ts | 3 + apps/www/src/test/integration/api.test.ts | 100 +++- apps/www/src/turnstile.ts | 129 ++++++ apps/www/src/www.app.ts | 91 +++- apps/www/vitest.config.ts | 4 + apps/www/wrangler.jsonc | 26 ++ packages/domain/src/rooms-db.ts | 150 +++++- packages/tools/bin/run-wrangler-deploy | 59 ++- 19 files changed, 1850 insertions(+), 200 deletions(-) create mode 100644 apps/rooms/migrations/0009_subroom_permissions.sql create mode 100644 apps/www/src/turnstile.ts 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 8d6c83a0b20f3b43c2f7279eedd63bd57ddd21a5..9afb3bb80079acb01377bc5f1c6bbe185b4434fc 100644 GIT binary patch literal 16924 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 literal 17717 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 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"