mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
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
This commit is contained in:
@@ -64,3 +64,16 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# 0 means players start broke. Applies only to players who haven't been granted yet —
|
# 0 means players start broke. Applies only to players who haven't been granted yet —
|
||||||
# raising it later does NOT top up existing players.
|
# raising it later does NOT top up existing players.
|
||||||
# RECFLARE_STARTING_TOKENS=10000
|
# 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 <store-id> --name TURNSTILE_SITE_KEY \
|
||||||
|
# --scopes workers --remote
|
||||||
|
# wrangler secrets-store secret create <store-id> --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.
|
||||||
|
|||||||
@@ -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
|
> `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.
|
> 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 <store-id> --name TURNSTILE_SITE_KEY \
|
||||||
|
--scopes workers --remote
|
||||||
|
wrangler secrets-store secret create <store-id> --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 <store-id> --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
|
## Repository Structure
|
||||||
|
|
||||||
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
|
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
@@ -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)
|
||||||
|
);
|
||||||
@@ -64,6 +64,12 @@ export const FORBIDDEN_RESPONSE = {
|
|||||||
description: 'A valid token, but not the room’s creator or a co-owner (empty body)',
|
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 ------------------------------------------------------------
|
// ---- Parameters ------------------------------------------------------------
|
||||||
|
|
||||||
/** A digits-only id path parameter (the route patterns constrain these to `[0-9]+`). */
|
/** 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. */
|
/** The `:subRoomId` path parameter. */
|
||||||
export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)')
|
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. */
|
/** An optional string query parameter. */
|
||||||
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||||
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
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.
|
* `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.
|
* 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. */
|
/** One entry of the permission table the client applies when it spawns into a room. */
|
||||||
export const RoomPermissionDto = z.object({
|
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`'),
|
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)'),
|
Role: z.int().describe('The role tier the permission applies to (0 = everyone)'),
|
||||||
Type: z.int(),
|
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
|
* `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
|
* 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.
|
* 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({
|
export const PhotonAccessTokenDto = z.object({
|
||||||
Permissions: z.array(RoomPermissionDto),
|
Permissions: z.array(RoomPermissionDto),
|
||||||
|
|||||||
+205
-29
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Accessibility,
|
Accessibility,
|
||||||
|
areFriends,
|
||||||
canManageRoom,
|
canManageRoom,
|
||||||
cloneRoom,
|
cloneRoom,
|
||||||
cloneSubRoom,
|
cloneSubRoom,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
getRoomsByCreator,
|
getRoomsByCreator,
|
||||||
getRoomsByIds,
|
getRoomsByIds,
|
||||||
getSimilarRooms,
|
getSimilarRooms,
|
||||||
|
getSubRoomPermissions,
|
||||||
getSubRoomSaves,
|
getSubRoomSaves,
|
||||||
getVisitedRooms,
|
getVisitedRooms,
|
||||||
modifySubRoom,
|
modifySubRoom,
|
||||||
@@ -37,6 +39,7 @@ import {
|
|||||||
setRoomImage,
|
setRoomImage,
|
||||||
setRoomName,
|
setRoomName,
|
||||||
setRoomRole,
|
setRoomRole,
|
||||||
|
setSubRoomPermissions,
|
||||||
toggleCheer,
|
toggleCheer,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
toggleRoomTag,
|
toggleRoomTag,
|
||||||
@@ -63,10 +66,12 @@ import {
|
|||||||
MissingLookupParam,
|
MissingLookupParam,
|
||||||
ModifySubRoomRequest,
|
ModifySubRoomRequest,
|
||||||
NameRequest,
|
NameRequest,
|
||||||
|
NOT_FRIENDS_RESPONSE,
|
||||||
PagedRooms,
|
PagedRooms,
|
||||||
pageParams,
|
pageParams,
|
||||||
PhotonAccessTokenDto,
|
PhotonAccessTokenDto,
|
||||||
PlayerDataDto,
|
PlayerDataDto,
|
||||||
|
playerIdParam,
|
||||||
PublishSaveRequest,
|
PublishSaveRequest,
|
||||||
RestrictionsRequest,
|
RestrictionsRequest,
|
||||||
RoleRequest,
|
RoleRequest,
|
||||||
@@ -81,6 +86,7 @@ import {
|
|||||||
stringQuery,
|
stringQuery,
|
||||||
SubRoomAccessibilityRequest,
|
SubRoomAccessibilityRequest,
|
||||||
subRoomIdParam,
|
subRoomIdParam,
|
||||||
|
SubRoomPermissionsRequest,
|
||||||
SubRoomSavesPage,
|
SubRoomSavesPage,
|
||||||
TagRequest,
|
TagRequest,
|
||||||
UNAUTHORIZED_EMPTY,
|
UNAUTHORIZED_EMPTY,
|
||||||
@@ -90,6 +96,7 @@ import {
|
|||||||
} from './openapi'
|
} from './openapi'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
import type { RoomPermission } from '@repo/domain'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,9 +138,14 @@ const DEFAULT_MAX_ROOMS_PER_ACCOUNT = 10
|
|||||||
* hardcoded moderator/dev accounts. */
|
* hardcoded moderator/dev accounts. */
|
||||||
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
|
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 {
|
interface PresenceView {
|
||||||
roomInstanceId?: number
|
roomInstanceId?: number
|
||||||
|
subRoomId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,16 +155,26 @@ interface PresenceView {
|
|||||||
* they aren't in one). `PhotonAccessToken` stays empty — the reference server
|
* they aren't in one). `PhotonAccessToken` stays empty — the reference server
|
||||||
* signs it via `ClientSecurity`, whose secret/algorithm we don't have; our
|
* signs it via `ClientSecurity`, whose secret/algorithm we don't have; our
|
||||||
* Photon setup accepts an empty token.
|
* 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) {
|
function photonAccessToken(
|
||||||
const perm = (Permission: string, Role: number, Override: boolean) => ({
|
accountId: number,
|
||||||
|
roomInstanceId: number | null,
|
||||||
|
overrides: RoomPermission[] = []
|
||||||
|
) {
|
||||||
|
const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({
|
||||||
Override,
|
Override,
|
||||||
Permission,
|
Permission,
|
||||||
Role,
|
Role,
|
||||||
Type: 0,
|
Type: 0,
|
||||||
Value: 'True',
|
Value: 'True',
|
||||||
})
|
})
|
||||||
const permissions = [
|
const permissions: RoomPermission[] = [
|
||||||
perm('CAN_USE_ROOM_RESET_BUTTON', 0, true),
|
perm('CAN_USE_ROOM_RESET_BUTTON', 0, true),
|
||||||
perm('CAN_USE_DELETE_ALL_BUTTON', 0, true),
|
perm('CAN_USE_DELETE_ALL_BUTTON', 0, true),
|
||||||
perm('CAN_SAVE_INVENTIONS', 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_SPAWN_INVENTIONS', 30, true),
|
||||||
perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true),
|
perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true),
|
||||||
]
|
]
|
||||||
|
|
||||||
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
|
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
|
||||||
permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true))
|
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 {
|
return {
|
||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
PhotonAccessToken: '',
|
PhotonAccessToken: '',
|
||||||
@@ -176,16 +211,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Photon access-token handler (served bare and under `/roomserver`). Auth-gated:
|
* Photon access-token handler. Auth-gated: resolves the caller, reads their current
|
||||||
* resolves the caller, reads their current room instance from the shared
|
* room instance from the shared `presence` table (see @repo/domain), and returns the
|
||||||
* `presence` table (see @repo/domain), and returns the permissions + token.
|
* permissions + token.
|
||||||
*/
|
*/
|
||||||
async function handlePhotonAccessToken(c: Context<App>) {
|
async function handlePhotonAccessToken(c: Context<App>) {
|
||||||
const accountId = await authedAccountId(c)
|
const accountId = await authedAccountId(c)
|
||||||
if (accountId === null) return unauthorized(c)
|
if (accountId === null) return unauthorized(c)
|
||||||
const presence = await getPresence<PresenceView>(c.env.DB, accountId)
|
const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
|
||||||
const roomInstanceId = presence?.roomInstance?.roomInstanceId ?? null
|
// The permission overrides are the ones saved on the subroom the caller is standing in.
|
||||||
return c.json(photonAccessToken(accountId, roomInstanceId))
|
// 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. */
|
/** 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
|
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<string, unknown>
|
||||||
|
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). */
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
@@ -672,6 +766,45 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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
|
// The current player's interaction state with a room (cheered/favorited/last
|
||||||
// visited), read from the `interaction` table. Auth-gated.
|
// visited), read from the `interaction` table. Auth-gated.
|
||||||
.get(
|
.get(
|
||||||
@@ -1820,6 +1953,67 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// 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
|
// returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new
|
||||||
@@ -2041,8 +2235,7 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Photon access token + room permissions the client needs to spawn into a
|
// Photon access token + room permissions the client needs to spawn into a room.
|
||||||
// room. The client calls it on the rooms host both bare and under `/roomserver`.
|
|
||||||
.get(
|
.get(
|
||||||
'/photon_access_token',
|
'/photon_access_token',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -2065,23 +2258,6 @@ const app = new Hono<App>()
|
|||||||
}),
|
}),
|
||||||
handlePhotonAccessToken
|
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
|
// The generated spec. Documentation only — no request is validated against it (see
|
||||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||||
|
|||||||
@@ -58,6 +58,25 @@ beforeAll(async () => {
|
|||||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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).
|
// 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<string, unknown>)
|
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||||
|
|
||||||
|
// 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', () => {
|
describe('rooms endpoints', () => {
|
||||||
@@ -260,6 +279,62 @@ describe('rooms endpoints', () => {
|
|||||||
expect(other).toEqual([])
|
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 () => {
|
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`)
|
const res = await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -1433,13 +1508,10 @@ describe('rooms endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('GET /photon_access_token 401s without a token', async () => {
|
it('GET /photon_access_token 401s without a token', async () => {
|
||||||
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
|
expect((await SELF.fetch(`${ORIGIN}/photon_access_token`)).status).toBe(401)
|
||||||
const res = await SELF.fetch(`${ORIGIN}${path}`)
|
|
||||||
expect(res.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.
|
// Seed the caller's presence so RoomInstanceId reflects their current instance.
|
||||||
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||||
.bind(
|
.bind(
|
||||||
@@ -1450,22 +1522,19 @@ describe('rooms endpoints', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
const headers = await bearer('777')
|
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('777') })
|
||||||
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
|
expect(res.status).toBe(200)
|
||||||
const res = await SELF.fetch(`${ORIGIN}${path}`, { headers })
|
const body = (await res.json()) as {
|
||||||
expect(res.status).toBe(200)
|
Permissions: Array<{ Permission: string; Role: number }>
|
||||||
const body = (await res.json()) as {
|
PhotonAccessToken: string
|
||||||
Permissions: Array<{ Permission: string; Role: number }>
|
RoomInstanceId: number | null
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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 () => {
|
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)
|
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<Permission[]> => {
|
||||||
|
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 () => {
|
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) =>
|
const clone = async (roomId: number, subRoomId: number, sub?: string) =>
|
||||||
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
|
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
|
||||||
@@ -1939,12 +2256,12 @@ describe('rooms endpoints', () => {
|
|||||||
'GET /rooms/recommendations',
|
'GET /rooms/recommendations',
|
||||||
'GET /rooms/search',
|
'GET /rooms/search',
|
||||||
'GET /rooms/visitedby/me',
|
'GET /rooms/visitedby/me',
|
||||||
|
'GET /rooms/visitedby/{playerId}',
|
||||||
'GET /rooms/{roomId}',
|
'GET /rooms/{roomId}',
|
||||||
'GET /rooms/{roomId}/interactionby/me',
|
'GET /rooms/{roomId}/interactionby/me',
|
||||||
'GET /rooms/{roomId}/playerdata/me',
|
'GET /rooms/{roomId}/playerdata/me',
|
||||||
'GET /rooms/{roomId}/similar',
|
'GET /rooms/{roomId}/similar',
|
||||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
|
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
|
||||||
'GET /roomserver/photon_access_token',
|
|
||||||
'GET /roomserver/rooms/createdby/me',
|
'GET /roomserver/rooms/createdby/me',
|
||||||
'POST /rooms/{roomId}/clone',
|
'POST /rooms/{roomId}/clone',
|
||||||
'POST /rooms/{roomId}/subrooms',
|
'POST /rooms/{roomId}/subrooms',
|
||||||
@@ -1963,6 +2280,7 @@ describe('rooms endpoints', () => {
|
|||||||
'PUT /rooms/{roomId}/roles/{accountId}',
|
'PUT /rooms/{roomId}/roles/{accountId}',
|
||||||
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
|
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
|
||||||
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
|
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
|
||||||
|
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',
|
||||||
'PUT /rooms/{roomId}/tags',
|
'PUT /rooms/{roomId}/tags',
|
||||||
'PUT /rooms/{roomId}/warning',
|
'PUT /rooms/{roomId}/warning',
|
||||||
])
|
])
|
||||||
|
|||||||
+47
-1
@@ -25,8 +25,9 @@ Upstream hosts are derived from the shared base domain (`auth.<DOMAIN>`,
|
|||||||
|
|
||||||
| Method | Path | Upstream |
|
| 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/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 |
|
| POST | `/api/logout` | clears the session cookie |
|
||||||
| GET | `/api/me` | accounts `GET /account/me` |
|
| GET | `/api/me` | accounts `GET /account/me` |
|
||||||
| POST | `/api/email` | accounts `POST /account/me/email` |
|
| POST | `/api/email` | accounts `POST /account/me/email` |
|
||||||
@@ -35,6 +36,51 @@ Upstream hosts are derived from the shared base domain (`auth.<DOMAIN>`,
|
|||||||
On signup/login the access token returned by `auth` is stored in an httpOnly
|
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.
|
`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
|
## Development
|
||||||
|
|
||||||
### Run in dev mode
|
### Run in dev mode
|
||||||
|
|||||||
+379
-57
@@ -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'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -14,6 +20,16 @@ interface SelfAccount {
|
|||||||
isAdmin?: boolean
|
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
|
* 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
|
* the upstream error message (auth uses `error`/`error_description`, the account
|
||||||
@@ -85,12 +101,18 @@ function Link({
|
|||||||
export function App() {
|
export function App() {
|
||||||
// undefined = still checking the session; null = signed out.
|
// undefined = still checking the session; null = signed out.
|
||||||
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
|
const [account, setAccount] = useState<SelfAccount | null | undefined>(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<SiteConfig | undefined>(undefined)
|
||||||
const { path, navigate } = useRouter()
|
const { path, navigate } = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<SelfAccount>('/api/me')
|
api<SelfAccount>('/api/me')
|
||||||
.then((me) => setAccount(me))
|
.then((me) => setAccount(me))
|
||||||
.catch(() => setAccount(null))
|
.catch(() => setAccount(null))
|
||||||
|
api<SiteConfig>('/api/config')
|
||||||
|
.then((c) => setConfig(c))
|
||||||
|
.catch(() => setConfig({ signupEnabled: false, turnstileSiteKey: null }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
@@ -102,12 +124,22 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
|
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
|
||||||
{path === '/login' ? (
|
{path === '/login' || path === '/signup' ? (
|
||||||
<LoginPage account={account} navigate={navigate} onAuthed={setAccount} />
|
// 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.
|
||||||
|
<LoginPage
|
||||||
|
account={account}
|
||||||
|
config={config}
|
||||||
|
initialTab={path === '/signup' ? 'signup' : 'login'}
|
||||||
|
navigate={navigate}
|
||||||
|
onAuthed={setAccount}
|
||||||
|
/>
|
||||||
) : path === '/account' ? (
|
) : path === '/account' ? (
|
||||||
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
|
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
|
||||||
) : (
|
) : (
|
||||||
<HomePage />
|
<HomePage account={account} config={config} navigate={navigate} />
|
||||||
)}
|
)}
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
</>
|
</>
|
||||||
@@ -205,12 +237,25 @@ function useSlideshow() {
|
|||||||
* on top of them. Everything about how the thing is built sits below, for whoever
|
* on top of them. Everything about how the thing is built sits below, for whoever
|
||||||
* scrolls looking for it.
|
* scrolls looking for it.
|
||||||
*/
|
*/
|
||||||
function HomePage() {
|
function HomePage({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
account: SelfAccount | null | undefined
|
||||||
|
config: SiteConfig | undefined
|
||||||
|
navigate: Navigate
|
||||||
|
}) {
|
||||||
const feed = useSlideshow()
|
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 (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<Stage slides={feed.slides} />
|
<Stage slides={feed.slides} offerSignup={offerSignup} navigate={navigate} />
|
||||||
<div className="shell home">
|
<div className="shell home">
|
||||||
<About slides={feed.slides} error={feed.error} />
|
<About slides={feed.slides} error={feed.error} />
|
||||||
</div>
|
</div>
|
||||||
@@ -219,31 +264,36 @@ function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The hero: a rotating in-game photo with the headline and the way in over it. The
|
* The hero: the headline and the way in on the left, a rotating in-game photo on the
|
||||||
* photo is the backdrop, never the payload — when the feed is slow or down the stage
|
* right. The photo is proof, never the payload — when the feed is slow or down the
|
||||||
* still renders, so "Play now!" is reachable either way.
|
* 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 [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(() => {
|
useEffect(() => {
|
||||||
if (!slides || slides.length < 2) return
|
if (count < 2) return
|
||||||
const t = setInterval(() => setIdx((i) => (i + 1) % slides.length), 6000)
|
const t = setTimeout(() => setIdx((i) => (i + 1) % count), 6000)
|
||||||
return () => clearInterval(t)
|
return () => clearTimeout(t)
|
||||||
}, [slides])
|
}, [count, idx])
|
||||||
|
|
||||||
const slide = slides && slides.length > 0 ? slides[idx] : null
|
const slide = slides && slides.length > 0 ? slides[idx] : null
|
||||||
|
const step = (by: number) => setIdx((i) => (i + by + count) % count)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stage">
|
<section className="stage">
|
||||||
{slide && (
|
|
||||||
<img
|
|
||||||
className="stage-photo"
|
|
||||||
key={slide.url}
|
|
||||||
src={slide.url}
|
|
||||||
alt={`Photo taken in game by ${slide.username}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="stage-body">
|
<div className="stage-body">
|
||||||
{/* Deliberately doesn't name the game: this is a fan project, so the
|
{/* Deliberately doesn't name the game: this is a fan project, so the
|
||||||
trademark stays out of the headline and appears lower down, in
|
trademark stays out of the headline and appears lower down, in
|
||||||
@@ -251,40 +301,90 @@ function Stage({ slides }: { slides: Slide[] | null }) {
|
|||||||
<h1 className="stage-title">
|
<h1 className="stage-title">
|
||||||
Play like it's <em>2023</em>.
|
Play like it's <em>2023</em>.
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="stage-lede">
|
||||||
|
The servers you remember, rebuilt and running — free, open source, and up right now.
|
||||||
|
</p>
|
||||||
<div className="stage-actions">
|
<div className="stage-actions">
|
||||||
<a className="cta" href={DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
<a className="cta" href={DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||||
Download for PC
|
Download for PC
|
||||||
</a>
|
</a>
|
||||||
|
<a className="cta" href={QUEST_DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||||
|
Download for Quest
|
||||||
|
</a>
|
||||||
<a className="cta discord" href={DISCORD_INVITE} target="_blank" rel="noreferrer">
|
<a className="cta discord" href={DISCORD_INVITE} target="_blank" rel="noreferrer">
|
||||||
Join the Discord
|
Join the Discord
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 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 && (
|
||||||
|
<p className="stage-alt">
|
||||||
|
New here?{' '}
|
||||||
|
<Link to="/signup" navigate={navigate}>
|
||||||
|
Create an account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{slide && (
|
<div className="stage-show">
|
||||||
|
<div className="stage-frame">
|
||||||
|
{slide && (
|
||||||
|
<img
|
||||||
|
className="stage-photo"
|
||||||
|
key={slide.url}
|
||||||
|
src={slide.url}
|
||||||
|
alt={`Photo taken in game by ${slide.username}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Always mounted, so the frame doesn't shift down when the feed lands. */}
|
||||||
<div className="stage-foot">
|
<div className="stage-foot">
|
||||||
<span className="credit">
|
{slide && (
|
||||||
Photo by @{slide.username}
|
<span className="credit">
|
||||||
{slide.roomName && ` in ${slide.roomName}`}
|
Photo by @{slide.username}
|
||||||
</span>
|
{slide.roomName && ` in ${slide.roomName}`}
|
||||||
{slides && slides.length > 1 && (
|
</span>
|
||||||
<span className="dots">
|
)}
|
||||||
{slides.map((s, i) => (
|
{/* Arrows and a count, not a dot per photo: the feed runs to SLIDESHOW_LIMIT
|
||||||
<button
|
(130) images, and a dot each is both unusable and wide enough to shove
|
||||||
key={s.url}
|
the headline's half of the split off the page. */}
|
||||||
className={i === idx ? 'on' : ''}
|
{count > 1 && (
|
||||||
onClick={() => setIdx(i)}
|
<span className="steer">
|
||||||
aria-label={`Show photo ${i + 1} of ${slides.length}`}
|
<button onClick={() => step(-1)} aria-label="Previous photo">
|
||||||
aria-current={i === idx}
|
<Chevron />
|
||||||
/>
|
</button>
|
||||||
))}
|
<span className="count">
|
||||||
|
{idx + 1} / {count}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => step(1)} aria-label="Next photo">
|
||||||
|
<Chevron next />
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The slideshow's back/forward mark. Decorative — the buttons carry the label. */
|
||||||
|
function Chevron({ next }: { next?: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||||
|
<path
|
||||||
|
d={next ? 'M9 5l7 7-7 7' : 'M15 5l-7 7 7 7'}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** What RecFlare is, under the fold, for whoever wants it. */
|
/** What RecFlare is, under the fold, for whoever wants it. */
|
||||||
function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
||||||
// The feed answering is proof the server replied, so the indicator can't claim
|
// 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 }) {
|
|||||||
<h2 className="about-title">An open source rebuild of the 2023 servers</h2>
|
<h2 className="about-title">An open source rebuild of the 2023 servers</h2>
|
||||||
<p className="about-lede">
|
<p className="about-lede">
|
||||||
A free fan project, made by players who missed it. Aiming to be{' '}
|
A free fan project, made by players who missed it. Aiming to be{' '}
|
||||||
<strong>feature-complete</strong> and infinitely scalable — no gatekeeping, no basement
|
<strong>feature-complete</strong> and infinitely scalable —{' '}
|
||||||
server.
|
<strong>architected for the cloud</strong>, no gatekeeping, no basement server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="about-side">
|
<div className="about-side">
|
||||||
@@ -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({
|
function LoginPage({
|
||||||
account,
|
account,
|
||||||
|
config,
|
||||||
|
initialTab,
|
||||||
navigate,
|
navigate,
|
||||||
onAuthed,
|
onAuthed,
|
||||||
}: {
|
}: {
|
||||||
account: SelfAccount | null | undefined
|
account: SelfAccount | null | undefined
|
||||||
|
config: SiteConfig | undefined
|
||||||
|
initialTab: 'signup' | 'login'
|
||||||
navigate: Navigate
|
navigate: Navigate
|
||||||
onAuthed: (a: SelfAccount) => void
|
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(() => {
|
useEffect(() => {
|
||||||
if (account) navigate('/account')
|
if (account) navigate('/account')
|
||||||
}, [account, navigate])
|
}, [account, navigate])
|
||||||
|
|
||||||
|
const authed = (a: SelfAccount) => {
|
||||||
|
onAuthed(a)
|
||||||
|
navigate('/account')
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Sign in</h2>
|
{siteKey && (
|
||||||
<p className="muted">
|
<div className="tabs">
|
||||||
Launch the game first — that creates an account linked to your Steam ID. Once you set a
|
<button className={tab === 'login' ? 'active' : ''} onClick={() => navigate('/login')}>
|
||||||
password, use your username and that password to sign in here.
|
Sign in
|
||||||
</p>
|
</button>
|
||||||
<LoginForm
|
<button
|
||||||
onAuthed={(a) => {
|
className={tab === 'signup' ? 'active' : ''}
|
||||||
onAuthed(a)
|
onClick={() => navigate('/signup')}
|
||||||
navigate('/account')
|
>
|
||||||
}}
|
Create account
|
||||||
/>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{siteKey && tab === 'signup' ? (
|
||||||
|
<>
|
||||||
|
<h2>Create account</h2>
|
||||||
|
<p className="muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<SignupForm siteKey={siteKey} onAuthed={authed} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2>Sign in</h2>
|
||||||
|
<p className="muted">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<LoginForm onAuthed={authed} />
|
||||||
|
{/* 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 && (
|
||||||
|
<p className="muted swap">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/signup" navigate={navigate}>
|
||||||
|
Create one
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -409,9 +560,180 @@ function useAction() {
|
|||||||
return { pending, error, done, run }
|
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
|
* Turnstile's browser API, as much of it as the signup widget uses. Loaded from
|
||||||
// SignupForm calling POST /api/signup and re-enable that endpoint in www.app.ts.
|
* 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<void> | null = null
|
||||||
|
function loadTurnstile(): Promise<void> {
|
||||||
|
turnstileScript ??= new Promise<void>((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<HTMLDivElement | null>(null)
|
||||||
|
const widgetId = useRef<string | undefined>(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 (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete="new-password"
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/* 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. */}
|
||||||
|
<label>
|
||||||
|
Email <span className="optional">optional</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
autoComplete="email"
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="hint">
|
||||||
|
How you get back in if you forget your password — there's no other way to reach you.
|
||||||
|
You can add it later on your account page.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="turnstile" ref={container} />
|
||||||
|
{widgetError && <p className="error">{widgetError}</p>}
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="submit" disabled={pending || token === ''}>
|
||||||
|
{pending ? 'Creating…' : 'Create account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
|||||||
+186
-70
@@ -84,7 +84,7 @@ body {
|
|||||||
max-width: 880px;
|
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 {
|
.shell.home {
|
||||||
max-width: 1040px;
|
max-width: 1040px;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
@@ -153,19 +153,42 @@ body {
|
|||||||
/* ---- The stage (hero) --------------------------------------------------- */
|
/* ---- The stage (hero) --------------------------------------------------- */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Full-bleed, edge to edge under the nav: a photo somebody actually took in game,
|
* Split down the middle: what this is and the way in on the left, a photo somebody
|
||||||
* with the headline and the way in over it. The photo is the backdrop and never the
|
* actually took in game on the right. The photo is proof and never the payload — with
|
||||||
* payload — with no photo the stage is still a solid panel carrying the same words.
|
* no photo the frame holds its space and the left half carries the same words.
|
||||||
*/
|
*/
|
||||||
.stage {
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
gap: 12px;
|
||||||
min-height: min(40vh, 320px);
|
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;
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
background: var(--surface-hi);
|
background: var(--surface-hi);
|
||||||
isolation: isolate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-photo {
|
.stage-photo {
|
||||||
@@ -174,11 +197,6 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
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;
|
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 {
|
.stage-body {
|
||||||
max-width: 1040px;
|
min-width: 0;
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 20px 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
.stage-title {
|
||||||
font-family: var(--display);
|
font-family: var(--display);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
color: #fff;
|
margin: 0 0 16px;
|
||||||
margin: 0 0 20px;
|
|
||||||
max-width: 15ch;
|
|
||||||
text-wrap: balance;
|
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. */
|
/* The one place the orange carries meaning in the headline: the year it restores. */
|
||||||
@@ -230,55 +228,84 @@ body {
|
|||||||
color: var(--accent);
|
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 {
|
.stage-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px 20px;
|
gap: 8px 20px;
|
||||||
max-width: 1040px;
|
/* Reserved even while the feed is in flight, so nothing shifts when it lands. */
|
||||||
width: 100%;
|
min-height: 28px;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 20px 22px;
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: rgb(255 255 255 / 72%);
|
color: var(--muted);
|
||||||
text-shadow: 0 1px 12px rgb(8 5 2 / 60%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The dots are the only way to steer the stage, so each one gets a 24px target
|
/* Long usernames and room names wrap instead of widening the column. */
|
||||||
even though the mark itself is 7px. */
|
.credit {
|
||||||
.dots {
|
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;
|
display: flex;
|
||||||
margin: -8px -6px;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dots button {
|
.steer button {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
width: 24px;
|
width: 28px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: 1px solid var(--line);
|
||||||
background: none;
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dots button::after {
|
.steer button:hover {
|
||||||
content: '';
|
color: var(--text);
|
||||||
width: 7px;
|
border-color: var(--muted);
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgb(255 255 255 / 34%);
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dots button:hover::after {
|
/* Tabular figures so the frame doesn't twitch as the index rolls 9 → 10. */
|
||||||
background: rgb(255 255 255 / 65%);
|
.count {
|
||||||
}
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 0 6px;
|
||||||
.dots button.on::after {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- What it is (below the stage) --------------------------------------- */
|
/* ---- What it is (below the stage) --------------------------------------- */
|
||||||
@@ -544,6 +571,40 @@ h2 {
|
|||||||
color: var(--muted);
|
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 -------------------------------------------------------------- */
|
/* ---- Forms -------------------------------------------------------------- */
|
||||||
|
|
||||||
label {
|
label {
|
||||||
@@ -572,6 +633,27 @@ textarea {
|
|||||||
min-height: 76px;
|
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,
|
input:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
@@ -579,6 +661,14 @@ textarea:focus {
|
|||||||
border-color: transparent;
|
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'] {
|
button[type='submit'] {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -602,6 +692,22 @@ button[type='submit']:disabled {
|
|||||||
cursor: default;
|
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 ---------------------------------------------------------- */
|
/* ---- Utilities ---------------------------------------------------------- */
|
||||||
|
|
||||||
.big {
|
.big {
|
||||||
@@ -633,6 +739,15 @@ button[type='submit']:disabled {
|
|||||||
|
|
||||||
/* ---- Responsive --------------------------------------------------------- */
|
/* ---- 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) {
|
@media (max-width: 760px) {
|
||||||
/* One column: the copy first, then the links and the status under it. */
|
/* One column: the copy first, then the links and the status under it. */
|
||||||
.about {
|
.about {
|
||||||
@@ -643,8 +758,9 @@ button[type='submit']:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.stage {
|
.stage-actions .cta {
|
||||||
min-height: min(38vh, 300px);
|
flex: 1 1 auto;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-links .cta {
|
.about-links .cta {
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ export type Env = SharedHonoEnv & {
|
|||||||
DOMAIN: string
|
DOMAIN: string
|
||||||
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
||||||
ASSETS: Fetcher
|
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 */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -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. */
|
/** 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'
|
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. */
|
/** The public source repo, linked from the homepage and footer. */
|
||||||
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
|
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
import { SELF } from 'cloudflare:test'
|
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||||
import { expect, it } from 'vitest'
|
import { beforeAll, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
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 () => {
|
it('rejects unauthenticated account reads', async () => {
|
||||||
const res = await SELF.fetch('https://example.com/api/me')
|
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' })
|
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', {
|
const res = await SELF.fetch('https://example.com/api/signup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ password: 'whatever' }),
|
body: JSON.stringify({ password: 'whatever' }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(403)
|
// Rejected before any upstream call, so a bot can't reach create_account by omitting it.
|
||||||
expect(await res.json()).toEqual({ error: 'Account creation is currently disabled.' })
|
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 () => {
|
it('requires credentials to log in', async () => {
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
const fields: Record<string, string> = { 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
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
-8
@@ -2,11 +2,12 @@ import { Hono } from 'hono'
|
|||||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
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 { NotificationType } from '../../notify/src/notification-types'
|
||||||
import { docsPage, fetchSpec } from './docs'
|
import { docsPage, fetchSpec } from './docs'
|
||||||
import { privacyPage } from './privacy'
|
import { privacyPage } from './privacy'
|
||||||
|
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
||||||
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
|
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
@@ -87,8 +88,12 @@ async function relay(c: Context<App>, res: Response) {
|
|||||||
* Exchange an auth `/connect/token` response for a session: persist the returned
|
* 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
|
* access token in the httpOnly cookie, then return the caller's self account
|
||||||
* (fetched from the accounts worker with the fresh token).
|
* (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<App>, tokenResponse: Response) {
|
async function establishSession(c: Context<App>, tokenResponse: Response, email?: string) {
|
||||||
if (!tokenResponse.ok) return relay(c, tokenResponse)
|
if (!tokenResponse.ok) return relay(c, tokenResponse)
|
||||||
|
|
||||||
const token = (await tokenResponse.json()) as { access_token?: string; expires_in?: number }
|
const token = (await tokenResponse.json()) as { access_token?: string; expires_in?: number }
|
||||||
@@ -103,6 +108,25 @@ async function establishSession(c: Context<App>, tokenResponse: Response) {
|
|||||||
sessionCookieOptions(c, token.expires_in ?? 3600)
|
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`, {
|
const me = await fetch(`${accountsBase(c.env)}/account/me`, {
|
||||||
headers: { authorization: `Bearer ${token.access_token}` },
|
headers: { authorization: `Bearer ${token.access_token}` },
|
||||||
})
|
})
|
||||||
@@ -126,12 +150,63 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
// ---- BFF API ------------------------------------------------------------
|
// ---- BFF API ------------------------------------------------------------
|
||||||
|
|
||||||
// Manual web signups are disabled for now — accounts are created via the game /
|
// What the SPA has to know before it can render the sign-in page: whether web signup
|
||||||
// platform, not the website. Kept as an explicit closed endpoint (rather than
|
// is open, and the Turnstile site key to mount its widget with. The site key is public
|
||||||
// removed) so a direct POST is refused too, not just hidden in the UI. To reopen,
|
// (it ships in the widget markup either way); the secret never leaves the worker.
|
||||||
// forward a platform-less `grant_type=create_account` to auth and start a session
|
// Served rather than baked into the client build so one build works for any operator.
|
||||||
// (see git history), and restore the SignupForm in the client.
|
.get('/api/config', async (c) => {
|
||||||
.post('/api/signup', (c) => c.json({ error: 'Account creation is currently disabled.' }, 403))
|
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<SignupBody>()
|
||||||
|
.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
|
// 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
|
// resolves the account by `username` (case-insensitive) — web players sign in with
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export default defineConfig({
|
|||||||
miniflare: {
|
miniflare: {
|
||||||
bindings: {
|
bindings: {
|
||||||
ENVIRONMENT: 'VITEST',
|
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.
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -28,6 +28,32 @@
|
|||||||
"not_found_handling": "single-page-application",
|
"not_found_handling": "single-page-application",
|
||||||
"run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy"]
|
"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 <store-id> --name TURNSTILE_SITE_KEY \
|
||||||
|
// --scopes workers --remote
|
||||||
|
// wrangler secrets-store secret create <store-id> --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,
|
"upload_source_maps": true,
|
||||||
"observability": {
|
"observability": {
|
||||||
"logs": {
|
"logs": {
|
||||||
|
|||||||
@@ -82,6 +82,26 @@ export const SUBROOM_SCHEMA_DDL: string[] = [
|
|||||||
data TEXT NOT NULL
|
data TEXT NOT NULL
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`,
|
`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). */
|
/** 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 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.
|
// 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),
|
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)
|
const room = await getRoomById(db, roomId)
|
||||||
@@ -942,6 +965,119 @@ export async function getSubRoomSaveById(
|
|||||||
return row ? parseSubRoomSaveRow(row) : null
|
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<RoomPermission[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1 ORDER BY rowid`
|
||||||
|
)
|
||||||
|
.bind(subRoomId)
|
||||||
|
.all<RoomPermissionRow>()
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
* 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).
|
* table's autoincrement sequence. Returns the created subroom (with its new id).
|
||||||
@@ -963,6 +1099,12 @@ export async function insertSubRoom(
|
|||||||
CurrentSave: null,
|
CurrentSave: null,
|
||||||
StagedSubRoomDataSaveId: 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
|
// 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
|
// 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.
|
// make the copy's content follow the source's future saves.
|
||||||
@@ -1037,12 +1179,18 @@ export async function deleteRoom(db: D1Database, roomId: number): Promise<void>
|
|||||||
await db.batch([
|
await db.batch([
|
||||||
db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
|
db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
|
||||||
db.prepare('DELETE FROM interaction 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
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
'DELETE FROM subroom_save WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
|
'DELETE FROM subroom_save WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
|
||||||
)
|
)
|
||||||
.bind(roomId),
|
.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),
|
db.prepare('DELETE FROM subroom WHERE room_id = ?1').bind(roomId),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,16 +48,61 @@ CONFIG="wrangler.jsonc"
|
|||||||
# (main: index.js), the resolved assets.directory, and no_bundle. The committed
|
# (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 —
|
# wrangler.jsonc leaves assets.directory out on purpose — the plugin fills it in —
|
||||||
# so deploying the source config fails with "assets ... missing the required
|
# so deploying the source config fails with "assets ... missing the required
|
||||||
# directory property". Prefer the generated config when it exists. These workers
|
# directory property". Prefer the generated config when it exists.
|
||||||
# have no D1/KV/Secrets bindings, so the id-splicing below is skipped.
|
#
|
||||||
|
# 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"
|
VITE_CONFIG="dist/$DIR/wrangler.json"
|
||||||
|
IS_VITE=""
|
||||||
if [ -f "$VITE_CONFIG" ]; then
|
if [ -f "$VITE_CONFIG" ]; then
|
||||||
CONFIG="$VITE_CONFIG"
|
CONFIG="$VITE_CONFIG"
|
||||||
|
IS_VITE=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
NEEDS_D1=$(grep -q '"database_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"' wrangler.jsonc 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"' wrangler.jsonc 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
|
if [ "$CONFIG" = "wrangler.jsonc" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then
|
||||||
CONFIG="wrangler.generated.jsonc"
|
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
|
# Vite-built configs set no_bundle (vite already bundled and minified), which is
|
||||||
# incompatible with --minify. Only pass --minify when wrangler does the bundling.
|
# 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"
|
MINIFY="--minify"
|
||||||
[ "$CONFIG" = "$VITE_CONFIG" ] && MINIFY=""
|
[ -n "$IS_VITE" ] && MINIFY=""
|
||||||
|
|
||||||
# Deploy with wrangler using the extracted values as binding variables
|
# Deploy with wrangler using the extracted values as binding variables
|
||||||
echo "Deploying worker $NAME version $VERSION to $HOST"
|
echo "Deploying worker $NAME version $VERSION to $HOST"
|
||||||
|
|||||||
Reference in New Issue
Block a user