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:
devin
2026-08-03 15:20:10 -04:00
committed by GitHub
parent a46f6db9d7
commit 339a91735b
19 changed files with 1850 additions and 200 deletions
+205 -29
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
Accessibility,
areFriends,
canManageRoom,
cloneRoom,
cloneSubRoom,
@@ -25,6 +26,7 @@ import {
getRoomsByCreator,
getRoomsByIds,
getSimilarRooms,
getSubRoomPermissions,
getSubRoomSaves,
getVisitedRooms,
modifySubRoom,
@@ -37,6 +39,7 @@ import {
setRoomImage,
setRoomName,
setRoomRole,
setSubRoomPermissions,
toggleCheer,
toggleFavorite,
toggleRoomTag,
@@ -63,10 +66,12 @@ import {
MissingLookupParam,
ModifySubRoomRequest,
NameRequest,
NOT_FRIENDS_RESPONSE,
PagedRooms,
pageParams,
PhotonAccessTokenDto,
PlayerDataDto,
playerIdParam,
PublishSaveRequest,
RestrictionsRequest,
RoleRequest,
@@ -81,6 +86,7 @@ import {
stringQuery,
SubRoomAccessibilityRequest,
subRoomIdParam,
SubRoomPermissionsRequest,
SubRoomSavesPage,
TagRequest,
UNAUTHORIZED_EMPTY,
@@ -90,6 +96,7 @@ import {
} from './openapi'
import type { Context } from 'hono'
import type { RoomPermission } from '@repo/domain'
import type { App } from './context'
/**
@@ -131,9 +138,14 @@ const DEFAULT_MAX_ROOMS_PER_ACCOUNT = 10
* hardcoded moderator/dev accounts. */
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
/** The slice of the shared presence row we read — the caller's current room instance. */
/**
* The slice of the shared presence row we read — the caller's current room instance.
* `subRoomId` is what scopes the stored permission overrides: they belong to the subroom
* the player is standing in, not to the room.
*/
interface PresenceView {
roomInstanceId?: number
subRoomId?: number
}
/**
@@ -143,16 +155,26 @@ interface PresenceView {
* they aren't in one). `PhotonAccessToken` stays empty — the reference server
* signs it via `ClientSecurity`, whose secret/algorithm we don't have; our
* Photon setup accepts an empty token.
*
* `overrides` are the permissions the room's creator saved on the subroom the caller is
* in (see `PUT …/subrooms/{subRoomId}/permissions`). They are matched against the
* defaults by (`Permission`, `Role`) — the same pair the client addresses an entry by —
* and win, so a subroom that revokes the Role 0 maker pen revokes it for a dev account
* standing in it as well.
*/
function photonAccessToken(accountId: number, roomInstanceId: number | null) {
const perm = (Permission: string, Role: number, Override: boolean) => ({
function photonAccessToken(
accountId: number,
roomInstanceId: number | null,
overrides: RoomPermission[] = []
) {
const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({
Override,
Permission,
Role,
Type: 0,
Value: 'True',
})
const permissions = [
const permissions: RoomPermission[] = [
perm('CAN_USE_ROOM_RESET_BUTTON', 0, true),
perm('CAN_USE_DELETE_ALL_BUTTON', 0, true),
perm('CAN_SAVE_INVENTIONS', 0, true),
@@ -165,9 +187,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
perm('CAN_SPAWN_INVENTIONS', 30, true),
perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true),
]
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true))
}
// The subroom's stored table wins, applied LAST and over the dev grant too: a
// (Permission, Role) the table already carries is replaced in place — so the order
// doesn't shift under the client, and no pair is ever listed twice with two values —
// and one it doesn't (e.g. CAN_INVITE) is appended.
for (const override of overrides) {
const i = permissions.findIndex(
(p) => p.Permission === override.Permission && p.Role === override.Role
)
if (i === -1) permissions.push(override)
else permissions[i] = override
}
return {
Permissions: permissions,
PhotonAccessToken: '',
@@ -176,16 +211,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
}
/**
* Photon access-token handler (served bare and under `/roomserver`). Auth-gated:
* resolves the caller, reads their current room instance from the shared
* `presence` table (see @repo/domain), and returns the permissions + token.
* Photon access-token handler. Auth-gated: resolves the caller, reads their current
* room instance from the shared `presence` table (see @repo/domain), and returns the
* permissions + token.
*/
async function handlePhotonAccessToken(c: Context<App>) {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const presence = await getPresence<PresenceView>(c.env.DB, accountId)
const roomInstanceId = presence?.roomInstance?.roomInstanceId ?? null
return c.json(photonAccessToken(accountId, roomInstanceId))
const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
// The permission overrides are the ones saved on the subroom the caller is standing in.
// A player in no instance — sitting in the lobby, or an instance predating subroom
// tracking — gets the default table untouched.
const overrides =
typeof instance?.subRoomId === 'number'
? await getSubRoomPermissions(c.env.DB, instance.subRoomId)
: []
return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides))
}
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
@@ -214,6 +255,59 @@ function parseAccessibility(value: unknown): number | undefined {
return named ? (named[1] as number) : undefined
}
/** Parse an integer from the number or numeric string a JSON body may carry. */
function parseInt10(value: unknown): number | undefined {
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : undefined
if (typeof value !== 'string') return undefined
const n = Number.parseInt(value.trim(), 10)
return Number.isNaN(n) ? undefined : n
}
/**
* The client's `Value`, kept as the STRING it sends. Usually `"True"`/`"False"` — the
* True/False picker beside the override checkbox — but a permission whose UI is something
* else carries a different value, so nothing here interprets it. A JSON boolean or number
* is rendered the way the client would have written it.
*/
function permissionValue(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'boolean') return value ? 'True' : 'False'
if (typeof value === 'number') return String(value)
return ''
}
/**
* Parse the subroom-permissions PUT body: a JSON ARRAY of
* `{ Permission, Role, Override, Type, Value }` entries.
*
* `Override` is the client's checkbox, not data — see {@link setSubRoomPermissions}: true
* stores `Value` for that (`Permission`, `Role`), false clears any stored entry so the
* pair falls back to the default. It is carried through as sent.
*
* Entries without a permission name or a usable role are dropped rather than rejected —
* the client ignores the response either way, so half a table applied beats none.
*/
function parseRoomPermissions(body: unknown): RoomPermission[] {
if (!Array.isArray(body)) return []
const permissions: RoomPermission[] = []
for (const entry of body) {
if (typeof entry !== 'object' || entry === null) continue
const e = entry as Record<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). */
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 friends 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 players visited rooms'),
401: UNAUTHORIZED_RESPONSE,
403: NOT_FRIENDS_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const playerId = Number.parseInt(c.req.param('playerId'), 10)
// Your own history is always readable (the client sometimes sends the id
// rather than `me`); anyone else's needs a mutual friendship.
if (playerId !== accountId && !(await areFriends(c.env.DB, accountId, playerId))) {
return c.body(null, 403)
}
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getVisitedRooms(c.env.DB, playerId, skip, take))
}
)
// The current player's interaction state with a room (cheered/favorited/last
// visited), read from the `interaction` table. Auth-gated.
.get(
@@ -1820,6 +1953,67 @@ const app = new Hono<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 subrooms permissions',
description: [
'Stores the permission entries a rooms 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 permissions 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 dont 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 doesnt read one.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, subRoomIdParam],
requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'),
responses: {
200: { description: 'Stored (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: { description: 'No such room or subroom' },
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
// Scoped through the room so a subroom id from another room can't be written.
const room = await getRoomById(c.env.DB, roomId)
if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
if (room.CreatorAccountId !== accountId) return c.body(null, 403)
const permissions = parseRoomPermissions(await c.req.json().catch(() => null))
await setSubRoomPermissions(c.env.DB, subRoomId, permissions)
return c.body(null, 200)
}
)
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
// returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new
@@ -2041,8 +2235,7 @@ const app = new Hono<App>()
}
)
// Photon access token + room permissions the client needs to spawn into a
// room. The client calls it on the rooms host both bare and under `/roomserver`.
// Photon access token + room permissions the client needs to spawn into a room.
.get(
'/photon_access_token',
describeRoute({
@@ -2065,23 +2258,6 @@ const app = new Hono<App>()
}),
handlePhotonAccessToken
)
.get(
'/roomserver/photon_access_token',
describeRoute({
tags: ['Session'],
summary: 'Photon token + room permissions (legacy path)',
description: [
'Identical to `GET /photon_access_token` — the client calls it both bare and under the',
'`/roomserver` prefix, so both forms are registered.',
].join(' '),
security: AUTHED,
responses: {
200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'),
401: UNAUTHORIZED_RESPONSE,
},
}),
handlePhotonAccessToken
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.