Compare commits

...

19 Commits

Author SHA1 Message Date
devin 619b4c772c [playersettings] Drop Growth.YIR telemetry and MakerPenSessionCount on write (#145)
* [playersettings] drop Growth.YIR telemetry on write

The client posts its Year-In-Review counters (Growth.YIR.UGC.TubeDistance,
Growth.YIR.DistanceTraveled, …) through PUT /playersettings with a new value every time,
so the change-detection dedupe never skips them. A two-minute tail at ~500 CCU showed
them as about half of all KV writes (3.7 writes/s overall, 448 writes from 448 PUTs).
Nothing server-side reads them and the client has none for a new player, so a PUT naming
one is answered 200 and nothing is stored. Keys stay unseeded for a player whose only
posts are telemetry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* [playersettings] also drop MakerPenSessionCount

A per-session counter the client bumps on every post, like the Growth.YIR telemetry; nothing
reads it. The maker pen PREFERENCES (MakerPen_SnappingMode and the other MakerPen_* keys) are
kept — the client reads those back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 23:47:04 -04:00
dukedotdev f6214fb824 [www] Manage Avatar Studio and screen-sharing access (#144) 2026-10-26 20:05:27 -04:00
Devin Zuczek 44704fec51 [www] better room/player search 2026-10-26 19:33:21 -04:00
Devin Zuczek fc6cf31591 [www] update year 2026-10-26 19:10:33 -04:00
Devin Zuczek 4709c390fd [econ] fix room consumable consume 2026-10-26 18:51:49 -04:00
Devin Zuczek eeb652fef7 [www] banner upload 2026-10-26 18:44:18 -04:00
Devin Zuczek d819bdbe3d [www] add instance clear button for staff 2026-10-26 18:27:59 -04:00
Devin Zuczek 207138ef97 [rooms] keep invited roles on the roles endpoint for the permissions list 2026-10-26 18:25:06 -04:00
Devin Zuczek f2c16f198a [rooms] only show confirmed role invites 2026-10-26 18:17:13 -04:00
Devin Zuczek d14d96f489 [www] add discord delink 2026-10-26 18:17:13 -04:00
dukedotdev 95538e89ae [rooms] Preserve Studio export manifests on live saves (#143) 2026-10-26 11:22:36 -04:00
dukedotdev 5f34028267 [rooms] load Studio export dependencies (#142) 2026-10-25 17:01:42 -04:00
Devin Zuczek b02a646892 [sql] improvements 2026-10-25 13:35:18 -04:00
Devin Zuczek 8397997234 [studio] rollout support 2026-10-24 15:00:49 -04:00
Devin Zuczek 4e2cdd87eb [studio] move cloud_build_key to secrets 2026-10-24 01:16:49 -04:00
Devin Zuczek d1e69bfd14 [auth] discord login 2026-10-24 00:28:10 -04:00
Devin Zuczek 09e40af6b7 [api] fix slow rooms badge query 2026-10-23 22:57:53 -04:00
Jake Vice f9600c1836 Pumple Evil Edition >:) (#138)
* Add Halloween overlay and walking pumpkin

* Add Pumple chaos Halloween minigame

---------

Co-authored-by: Mrmadman1 <fusionthemadman@gmail.com>
Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
2026-10-23 17:45:12 -04:00
dukedotdev 3da35a8692 [studio] avatar authoring, managed prefab builds (#140) 2026-10-23 15:25:31 -04:00
75 changed files with 5904 additions and 458 deletions
+39 -3
View File
@@ -227,9 +227,12 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
#
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
# Also add https://<your domain>/claim AND https://<your domain>/login/discord to the app's
# Redirects in the Discord developer portal, or the exchange is refused. The second is the
# login page's "Sign in with Discord" (same switch): it signs into the account the Discord is
# linked to, or CREATES one — the player then sets a password on the website to sign in to
# the game, including on a Quest with no Meta account. Granting Plus takes effect on the
# player's NEXT sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
#
# OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a
# bot token, so `platform_account.role` tracks the guild instead of the day they claimed.
@@ -276,6 +279,18 @@ RECFLARE_DOMAIN=rec.example.com
# link Discord at /claim, and come back.
# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002
# OPTIONAL: a staged rollout of Rec Room Studio to Discord roles (`auth`, `studio`, `www`).
# The Discord ROLE ids whose holders get Studio upload access — `auth` stamps the
# `betastudio` role into their token exactly as it does for an account staff flagged
# `hasStudio` on the website, and `studio`'s access lookups and the website's Studio
# settings page say "granted" for them too. The flag keeps working beside this, so staff
# can still add individuals. Same rules as ROLE_IDS above: snowflakes, not names; commas, no
# spaces; ANY one qualifies; roles come from the snapshot the benefits claim and the daily
# sweep keep, so a newly granted role counts once the sweep runs (or the player re-claims),
# and the player's Studio token picks it up on their next sign-in or refresh. UNSET — or
# parsing to no roles — grants nobody: the flag alone decides.
# RECFLARE_STUDIO_ROLLOUT=1077000000000000001,1077000000000000002
# OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the
# RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
@@ -289,3 +304,24 @@ RECFLARE_DOMAIN=rec.example.com
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
# Rec Room Studio (`studio`). Cloud builds are configured OUTSIDE this file: the credential
# the build host (recflare/cloud-building, its STUDIO_WORKER_KEY) presents to claim jobs
# lives in the shared Secrets Store, like JWT_SECRET above — not as a var, since every var
# here reaches every worker in plaintext. The binding has to resolve for `studio` to
# deploy, so create it even if you run no build host: the literal `off` disables cloud
# builds (the editor is told "Cloud building is not configured").
#
# wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
# --scopes workers --remote
#
# The knobs ARE vars. TARGETS is the Unity build targets a job is built for (0 Windows,
# 2 Android), commas, no spaces; the build host needs the matching editor modules.
# TEMPLATE_VERSION is the Studio template a job is built against, and the build host
# claims ONLY jobs for its own configured version (171 by default there, 169 here), so
# a mismatch leaves jobs queued forever with no error on either side — set this to the
# template the host has installed. DISTRIBUTION_MANIFEST is the R2 key of the template
# catalog the editor downloads from; unset disables downloads (see apps/studio/README.md).
# RECFLARE_CLOUD_BUILD_TARGETS=0,2
# RECFLARE_CLOUD_BUILD_TEMPLATE_VERSION=169
# RECFLARE_STUDIO_DISTRIBUTION_MANIFEST=roomunityasset/studio-distribution/catalog-v171.json
+2 -1
View File
@@ -56,7 +56,8 @@ bun runx admin grant-moderator --username alice --remote
Sets the account's `hasStudio` flag, which `auth` stamps into the token as the
`betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
normally manage this from the website (`/settings/recroomstudio`); this is the
operator's way in. Takes effect on the player's next Studio sign-in or refresh.
operator's way in. Takes effect on the player's next Studio sign-in or refresh. To open
Studio to a whole Discord role instead, name it in `RECFLARE_STUDIO_ROLLOUT` (`.env`).
```sh
bun runx admin grant-studio --username alice
+11
View File
@@ -140,6 +140,17 @@ wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
> the secret per request.
The `studio` worker binds one more, `CLOUD_BUILD_KEY`: the credential the Rec Room
Studio cloud-building host ([recflare/cloud-building](https://github.com/recflare/cloud-building),
its `STUDIO_WORKER_KEY`) presents to claim queued builds. Same rule — it must exist for
`studio` to deploy. Create it as the literal `off` to run without cloud builds; put a
long random string in later when you set up a build host, and give that host the same
string.
```bash
wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY --scopes workers --remote
```
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
```bash
+47 -1
View File
@@ -9,6 +9,7 @@ import {
getAccount,
getAccountByUsername,
getAccountsByIds,
SEARCH_LIMIT,
searchAccounts,
updateAccount,
} from '@repo/domain'
@@ -337,16 +338,61 @@ const app = new Hono<App>()
description: 'Username prefix; empty matches nothing meaningful',
schema: { type: 'string' },
},
{
name: 'take',
in: 'query',
required: false,
description: `How many to return (default and max ${SEARCH_LIMIT}); fewer reads fewer rows`,
schema: { type: 'integer' },
},
],
responses: { 200: json(AccountDto.array(), 'Matching public accounts') },
}),
async (c) => {
const name = c.req.query('name') ?? ''
const accounts = await searchAccounts(c.env.DB, name)
// The seek stops at the LIMIT, so a caller that draws five (the site's header
// search) is billed five rows rather than the default twenty.
const take = Number.parseInt(c.req.query('take') ?? '', 10)
const limit = Number.isNaN(take) ? SEARCH_LIMIT : Math.max(1, Math.min(take, SEARCH_LIMIT))
const accounts = await searchAccounts(c.env.DB, name, limit)
return c.json(accounts.map(toAccountDto))
}
)
// Exact lookup by username — what the site's `/user/<name>` profile pages and the staff
// `@username` fields resolve a player through. An indexed equality on `username_lower`;
// before this existed they went through the prefix search above and filtered the
// exact hit client-side, which cost a 20-row index walk per profile view. Same path
// as the official API's `GET /account/username/{username}`.
.get(
'/account/username/:name',
describeRoute({
tags: ['Lookup'],
summary: 'A single public account, by exact username',
description:
'Case-insensitive exact match; a leading `@` is ignored. 404 when no account has the name.',
parameters: [
{
name: 'name',
in: 'path',
required: true,
description: 'The username, exactly (case-insensitive)',
schema: { type: 'string' },
},
],
responses: {
200: json(AccountDto, 'The public account'),
404: { description: 'No account has that username' },
},
}),
async (c) => {
const name = c.req.param('name').trim().replace(/^@/, '')
const account = name === '' ? null : await getAccountByUsername(c.env.DB, name)
if (!account) return c.json({ error: `There's no player called @${name}.` }, 404)
return c.json(toAccountDto(account))
}
)
// ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route.
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
@@ -153,6 +153,24 @@ describe('public endpoints', () => {
expect(await empty.json()).toEqual([])
})
test('GET /account/username/:name is an exact, case-insensitive lookup', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/username/coach`)
expect(res.status).toBe(200)
const account = (await res.json()) as { accountId: number; username: string }
expect(account).toMatchObject({ accountId: 1, username: 'Coach' })
// A leading `@` is tolerated — staff fields take names in that form.
const at = await exports.default.fetch(
`${ORIGIN}/account/username/${encodeURIComponent('@Coach')}`
)
expect(((await at.json()) as { accountId: number }).accountId).toBe(1)
// A prefix is NOT a match: that's what `/account/search` is for.
const prefix = await exports.default.fetch(`${ORIGIN}/account/username/coa`)
expect(prefix.status).toBe(404)
const missing = await exports.default.fetch(`${ORIGIN}/account/username/zzzznope`)
expect(missing.status).toBe(404)
expect(await missing.json()).toEqual({ error: "There's no player called @zzzznope." })
})
test('GET /account/:id/bio returns an empty bio', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
expect(await res.json()).toEqual({ accountId: 7, bio: '' })
@@ -689,6 +707,7 @@ describe('auth-gated endpoints', () => {
'GET /account/bulk',
'GET /account/me',
'GET /account/search',
'GET /account/username/{name}',
'GET /account/{id}',
'GET /account/{id}/bio',
'GET /accountprivacysettings/{id}',
@@ -0,0 +1,18 @@
-- Index the room-badge lookup that hydrates every room list (`attachBadges` in
-- packages/domain/src/rooms-db.ts). Every room page — feeds, search, the owned list, a single
-- room read — asks this table for the published badges of the rooms on the page, and without an
-- index that was a full scan of every custom avatar item (thousands of first-party rows) per
-- page. At the time of writing it was half of the database's row reads.
--
-- PARTIAL, over only the badge rows (`OutfitType` 1000), keyed by the badge's room. SQLite uses
-- a partial index only when the query's WHERE contains its term as written, and an expression
-- index only when the query names the same expression, so the query must keep BOTH
-- `outfit_type = 1000` and `json_extract(data, '$.CustomBadgeMetadata.RoomId')` exactly —
-- change either and the index silently stops being used. The key is an expression rather than
-- a new generated column so the query reads the same whether or not this migration has run yet
-- (the `rooms` worker can deploy ahead of `api`'s migrations).
-- Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room
ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId'))
WHERE outfit_type = 1000;
+2
View File
@@ -6,6 +6,7 @@ import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo
import { accountRoutes } from './routes/account'
import { avatarRoutes } from './routes/avatar'
import { avatarStudioRoutes } from './routes/avatar-studio'
import { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events'
import { gameplayRoutes } from './routes/gameplay'
@@ -56,6 +57,7 @@ const app = new Hono<App>({ strict: false })
.route('/', configRoutes)
.route('/', socialRoutes)
.route('/', progressionRoutes)
.route('/', avatarStudioRoutes)
.route('/', avatarRoutes)
.route('/', gameplayRoutes)
.route('/', eventRoutes)
+9 -1
View File
@@ -29,7 +29,7 @@
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql and 0035_custom_avatar_item_badge_room.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
data TEXT NOT NULL,
@@ -45,6 +45,9 @@ export const SCHEMA_DDL: string[] = [
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
// The room-badge lookup (`attachBadges`, packages/domain/src/rooms-db.ts) runs on every room
// page; partial over the badge rows, keyed by the badge's room (migrations/0035).
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId')) WHERE outfit_type = 1000`,
]
/**
@@ -72,6 +75,9 @@ export interface CustomAvatarItemSave {
UnityAssetHash: string
UnityAsset2: string | null
UnityAsset2Hash: string | null
/** Studio builds persist separate target hashes; legacy imports omit these. */
QuestUnityAssetHash?: string | null
QuestUnityAsset2Hash?: string | null
}
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
@@ -356,6 +362,8 @@ export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarIte
...save,
UnityAsset: questAsset(save.UnityAsset),
UnityAsset2: questAsset(save.UnityAsset2),
UnityAssetHash: save.QuestUnityAssetHash ?? save.UnityAssetHash,
UnityAsset2Hash: save.QuestUnityAsset2Hash ?? save.UnityAsset2Hash,
})),
}
}
+390
View File
@@ -0,0 +1,390 @@
import { Hono } from 'hono'
import {
AVATAR_STUDIO_SLOTS,
avatarItemAccessBit,
avatarSaveDto,
avatarStudioFlags,
avatarStudioItem,
avatarStudioSave,
canManageRoomById,
publishAvatarSave,
} from '@repo/domain'
import { parseMultipart } from '../../../studio/src/multipart'
import { getCustomAvatarItem, toQuestCustomAvatarItem } from '../custom-avatar-items-db'
import { authedId, authedRoles } from '../http'
import type { Context } from 'hono'
import type { AvatarStudioSave } from '@repo/domain'
import type { App } from '../context'
const uuid = '[0-9a-fA-F-]{36}'
const ok = (value: unknown) => ({ success: true, error: null, value })
const fail = (error: string) => ({ success: false, error, value: null })
const slots = new Set(AVATAR_STUDIO_SLOTS)
function validTags(value: unknown): boolean {
return (
value == null ||
(Array.isArray(value) &&
value.length <= 64 &&
value.every(
(t) =>
t &&
Number.isInteger(t.TagType) &&
t.TagType >= 0 &&
t.TagType <= 255 &&
typeof t.Value === 'string' &&
t.Value.length > 0 &&
t.Value.length <= 64
))
)
}
async function allowed(c: Context<App>, accountId: number, slot: number) {
const roles = await authedRoles(c)
return (
roles?.includes('betastudio') &&
(![104, 203].includes(slot) || roles.includes('developer')) &&
((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(slot)) !== 0
)
}
const safeName = (value: unknown): value is string =>
typeof value === 'string' &&
/^[a-zA-Z0-9_./-]+$/.test(value) &&
!value.split('/').some((p) => p === '..' || p === '.' || p === '')
export const avatarStudioRoutes = new Hono<App>()
.get('/api/customAvatarItems/v1/me', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const rows = await c.env.DB.prepare(
'SELECT data FROM custom_avatar_item WHERE creator_account_id=? ORDER BY created_at DESC,custom_avatar_item_id'
)
.bind(id)
.all<{ data: string }>()
const all = rows.results.map((r) => JSON.parse(r.data))
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
return c.json({ Results: all.slice(skip, skip + take), TotalResults: all.length })
})
.get(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c) => {
const item = await getCustomAvatarItem(c.env.DB, c.req.param('id'))
if (!item) return c.notFound()
if (item.Accessibility === 0 && item.CreatorAccountId !== (await authedId(c)))
return c.body(null, 403)
return c.json(c.req.query('unityAssetTarget') === '2' ? toQuestCustomAvatarItem(item) : item)
})
.post('/api/customAvatarItems/v1', async (c, next) => {
// Preserve the existing in-game custom-shirt contract, including its errors.
const form = await parseMultipart(c.req.raw.clone())
let body: Record<string, unknown>
try {
body = JSON.parse(String(form.metadata))
} catch {
return next()
}
if (
!body ||
typeof body !== 'object' ||
body.BaseAvatarItemId !== undefined ||
form.design ||
form.thumbnailImage
)
return next()
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const slot = Number(body.OutfitType)
if (!Number.isInteger(slot) || !slots.has(slot))
return c.json(fail('Unsupported avatar item type.'), 400)
if (!(await allowed(c, id, slot)))
return c.json(fail('Avatar Studio authorization is required.'), 403)
const badgeRoomId = Number(body.BadgeRoomId ?? 0)
if (
slot === 1000 &&
(!Number.isSafeInteger(badgeRoomId) ||
badgeRoomId < 0 ||
(badgeRoomId > 0 && !(await canManageRoomById(c.env.DB, badgeRoomId, id))))
)
return c.json(fail('Badge room is not managed by this account.'), 403)
if (
typeof body.Name !== 'string' ||
!body.Name.trim() ||
body.Name.length > 128 ||
(body.Description != null &&
(typeof body.Description !== 'string' || body.Description.length > 4000))
)
return c.json(fail('Invalid item name or description.'), 400)
if (
body.Price !== undefined &&
body.Price !== null &&
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)
)
return c.json(fail('Invalid item price.'), 400)
if (body.Accessibility !== undefined && ![0, 1, 2].includes(Number(body.Accessibility)))
return c.json(fail('Invalid accessibility.'), 400)
if (body.Accessibility && body.Accessibility !== 0)
return c.json(fail('Create the item privately and publish a built save first.'), 400)
if (!validTags(body.Tags)) return c.json(fail('Invalid avatar tags.'), 400)
const now = new Date().toISOString(),
itemId = crypto.randomUUID()
const item = {
CustomAvatarItemId: itemId,
CreatorAccountId: id,
Name: body.Name.trim(),
Description: body.Description ?? '',
Price: body.Price ?? 0,
Accessibility: body.Accessibility ?? 0,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: false,
BaseAvatarItemId: null,
BaseAvatarItemColor: null,
DesignFilename: null,
ThumbnailImageFilename: null,
CreatedAt: now,
ModifiedAt: now,
PreviewOrientation: body.PreviewOrientation ?? 0,
RankingContext: null,
OutfitType: slot,
CurrentSaves: [],
Tags: body.Tags ?? [],
IsStudioAuthored: true,
CustomBadgeMetadata: slot === 1000 ? { RoomId: badgeRoomId } : null,
RankedEntityId: itemId,
PurchaseInfo: null,
}
await c.env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
.bind(JSON.stringify(item))
.run()
return c.json(ok(item))
})
.put(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c, next) => {
const itemId = c.req.param('id')
const existing = await avatarStudioItem(c.env.DB, itemId)
if (!existing?.IsStudioAuthored) return next()
const id = await authedId(c)
if (id === null) return c.body(null, 401)
if (existing.CreatorAccountId !== id || !(await allowed(c, id, existing.OutfitType)))
return c.body(null, 403)
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
if (
!body ||
(body.Name != null &&
(typeof body.Name !== 'string' || !body.Name.trim() || body.Name.length > 128)) ||
(body.Description != null &&
(typeof body.Description !== 'string' || body.Description.length > 4000)) ||
(body.Price != null &&
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)) ||
(body.Accessibility != null && ![0, 1, 2].includes(Number(body.Accessibility))) ||
!validTags(body.Tags)
)
return c.json(fail('Invalid avatar metadata.'), 400)
for (let attempt = 0; attempt < 5; attempt++) {
const item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (body.Accessibility && !item.CurrentSaves?.length)
return c.json(fail('Publish a built save before making the item public.'), 400)
const prior = JSON.stringify(item)
for (const key of ['Name', 'Description', 'Price', 'Accessibility', 'Tags'])
if (body[key] != null) item[key] = key === 'Name' ? String(body[key]).trim() : body[key]
item.ModifiedAt = new Date().toISOString()
const result = await c.env.DB.prepare(
'UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)'
)
.bind(JSON.stringify(item), itemId, prior)
.run()
if (result.meta.changes) return c.json(ok(item))
}
return c.json(fail('The item changed concurrently. Retry the edit.'), 409)
})
.get(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const itemId = c.req.param('id'),
item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (item.CreatorAccountId !== id) return c.body(null, 403)
const rows = await c.env.DB.prepare(
'SELECT * FROM avatar_studio_save WHERE item_id=? ORDER BY id DESC'
)
.bind(itemId)
.all<AvatarStudioSave>()
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
return c.json(
ok({
Results: await Promise.all(
rows.results.slice(skip, skip + take).map((r) => avatarSaveDto(c.env.DB, r))
),
TotalResults: rows.results.length,
})
)
})
.post(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const itemId = c.req.param('id'),
item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
return c.body(null, 403)
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
if (
!body ||
!Number.isInteger(body.BodyType) ||
![0, 1, 2, 3, 4].includes(Number(body.BodyType)) ||
!safeName(body.ThumbnailFileName) ||
typeof body.AdditionalConfiguration !== 'string' ||
body.AdditionalConfiguration.length > 1024 * 1024 ||
(body.SaveDescription != null &&
(typeof body.SaveDescription !== 'string' || body.SaveDescription.length > 4000))
)
return c.json(fail('Invalid avatar save.'), 400)
try {
JSON.parse(body.AdditionalConfiguration)
} catch {
return c.json(fail('Invalid additional configuration JSON.'), 400)
}
let filename: string, hash: string, unityId: string
if (body.NewUnityAssetFile && typeof body.NewUnityAssetFile === 'object') {
const file = body.NewUnityAssetFile as Record<string, unknown>
if (!safeName(file.Filename)) return c.json(fail('Invalid source filename.'), 400)
const object = await c.env.CDN_ASSETS.head('avatarunityasset/' + file.Filename)
if (!object || object.customMetadata?.ownerId !== String(id))
return c.json(fail('Source upload does not belong to this account.'), 403)
const checksum = object.checksums.sha256
hash = checksum ? btoa(String.fromCharCode(...new Uint8Array(checksum))) : ''
if (!hash || file.Hash !== hash)
return c.json(fail('Source hash does not match uploaded bytes.'), 400)
filename = file.Filename
unityId = crypto.randomUUID()
} else if (typeof body.ExistingUnityAssetId === 'string') {
const prior = await c.env.DB.prepare(
'SELECT * FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
)
.bind(itemId, body.ExistingUnityAssetId)
.first<AvatarStudioSave>()
if (!prior) return c.json(fail('No matching source revision on this item.'), 400)
filename = prior.source_filename
hash = prior.source_hash
// A source can be reused across builds, but compiled outputs belong to
// one body/configuration. Only metadata-only revisions share their output.
unityId =
prior.body_type === body.BodyType && prior.configuration === body.AdditionalConfiguration
? prior.unity_asset_id
: crypto.randomUUID()
} else return c.json(fail('A new or existing source asset is required.'), 400)
if (item.OutfitType === 1000) {
const config = JSON.parse(body.AdditionalConfiguration as string)
const badgeRoom = config?.d?.d?.r ?? 0
if (
config?.v !== 1 ||
config?.d?.o !== 1000 ||
!Number.isSafeInteger(badgeRoom) ||
badgeRoom < 0
)
return c.json(fail('Invalid badge configuration.'), 400)
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
return c.json(fail('Badge room is not managed by this account.'), 403)
if (body.BodyType !== 4)
return c.json(fail('Badge images require the body-agnostic body type.'), 400)
const source = await c.env.CDN_ASSETS.get('avatarunityasset/' + filename)
if (!source || source.size > 8 * 1024 * 1024)
return c.json(fail('Missing or oversized badge source.'), 400)
const manifest = await source
.json<{
Version: number
MainAssetProjectRelativePath: string
Files: Array<{ p: string; n: string }>
}>()
.catch(() => null)
if (
!manifest ||
manifest.Version !== 3 ||
!Array.isArray(manifest.Files) ||
!manifest.Files.length ||
manifest.Files.length > 100000 ||
!safeName(manifest.MainAssetProjectRelativePath) ||
!manifest.MainAssetProjectRelativePath.startsWith('Assets/') ||
!/\.(png|jpe?g)$/i.test(manifest.MainAssetProjectRelativePath) ||
manifest.Files.some(
(f) =>
!f ||
!safeName(f.p) ||
!/^(Assets|Packages)\//.test(f.p) ||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
!/^[a-z0-9]{40}$/.test(f.n)
) ||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
)
return c.json(fail('Invalid badge image source manifest.'), 400)
const main = manifest.Files.find((f) => f.p === manifest.MainAssetProjectRelativePath)
const image = main ? await c.env.CDN_ASSETS.get('unityasset/' + main.n) : null
if (!image || image.size > 16 * 1024 * 1024)
return c.json(fail('Badge source image is missing or oversized.'), 400)
const signature = new Uint8Array(await image.arrayBuffer()).subarray(0, 8)
if (
!(
signature[0] === 137 &&
signature[1] === 80 &&
signature[2] === 78 &&
signature[3] === 71
) &&
!(signature[0] === 255 && signature[1] === 216 && signature[2] === 255)
)
return c.json(fail('Badge source must be a PNG or JPEG image.'), 400)
}
for (const name of [body.ThumbnailFileName, body.ModerationImageFileName].filter(
(v) => v !== undefined && v !== null
)) {
if (!safeName(name)) return c.json(fail('Invalid image filename.'), 400)
const image = await c.env.CDN_ASSETS.get('image/' + name)
if (!image || image.customMetadata?.ownerId !== String(id))
return c.json(fail('Image upload does not belong to this account.'), 403)
if (image.size > 16 * 1024 * 1024) return c.json(fail('Avatar image exceeds limit.'), 413)
// Storage's image upload and the Images service use different buckets.
// Keep the SDK filename unchanged when publishing creator-owned thumbnails.
await c.env.IMAGES.put(name, image.body, { httpMetadata: image.httpMetadata })
}
const row = await c.env.DB.prepare(
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,moderation_image,configuration,description,update_current,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) RETURNING id'
)
.bind(
itemId,
body.BodyType,
unityId,
filename,
hash,
body.ThumbnailFileName,
typeof body.ModerationImageFileName === 'string' ? body.ModerationImageFileName : null,
body.AdditionalConfiguration,
body.SaveDescription ?? null,
body.UpdateCurrentSaveIfPrivate === true ? 1 : 0,
new Date().toISOString()
)
.first<{ id: number }>()
if (body.UpdateCurrentSaveIfPrivate === true && item.Accessibility === 0)
await publishAvatarSave(c.env.DB, itemId, row!.id, true)
return c.json(ok(row!.id))
})
.post(`/api/customAvatarItems/:id{${uuid}}/saves/:saveId{[1-9][0-9]*}/publish`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, c.req.param('id'))
if (!item) return c.notFound()
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
return c.body(null, 403)
const save = await avatarStudioSave(
c.env.DB,
item.CustomAvatarItemId,
Number(c.req.param('saveId'))
)
if (item.OutfitType === 1000 && save) {
const badgeRoom = JSON.parse(save.configuration)?.d?.d?.r ?? 0
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
return c.body(null, 403)
}
if (!save || !(await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id)))
return c.json(fail('Save has not completed a build.'), 400)
return c.json(ok(null))
})
@@ -0,0 +1,338 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import {
AVATAR_STUDIO_SCHEMA,
avatarSaveDto,
avatarStudioSave,
publishAvatarSave,
ROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { generateToken } from '@repo/jwt'
import { SCHEMA_DDL } from '../../custom-avatar-items-db'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const base = 'https://example.com/api/customAvatarItems'
async function headers(id = 42, roles = ['betastudio']) {
return {
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
for (const sql of [
...SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
...UNITY_ASSET_SCHEMA_DDL,
...ROOM_SCHEMA_DDL,
])
await env.DB.prepare(sql).run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
})
beforeEach(async () => {
await env.DB.prepare('DELETE FROM avatar_studio_save').run()
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
await env.DB.prepare('UPDATE avatar_studio_access SET flags=1 WHERE account_id=42').run()
})
it('saves and publishes image badges directly, while enforcing badge grants and room ownership', async () => {
expect((await create(42, ['betastudio'], 1000)).status).toBe(403)
await env.DB.prepare('UPDATE avatar_studio_access SET flags=5 WHERE account_id=42').run()
const foreign = new FormData()
foreign.set(
'metadata',
JSON.stringify({ Name: 'Foreign badge', OutfitType: 1000, BadgeRoomId: 98765 })
)
expect(
(await SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(), body: foreign }))
.status
).toBe(403)
const { value: item } = await (
await create(42, ['betastudio'], 1000)
).json<{ value: { CustomAvatarItemId: string } }>()
const png = Uint8Array.from(
atob(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='
),
(c) => c.charCodeAt(0)
)
const main = 'b'.repeat(40)
const bytes = new TextEncoder().encode(
JSON.stringify({
Version: 3,
MainAssetProjectRelativePath: 'Assets/Badge.png',
Files: [{ n: main, p: 'Assets/Badge.png' }],
})
)
const digest = await crypto.subtle.digest('SHA-256', bytes)
await env.CDN_ASSETS.put('avatarunityasset/badge-source', bytes, {
sha256: digest,
customMetadata: { ownerId: '42' },
})
await env.CDN_ASSETS.put('unityasset/' + main, png)
await env.CDN_ASSETS.put('image/badge.png', png, {
customMetadata: { ownerId: '42' },
httpMetadata: { contentType: 'image/png' },
})
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const save = await SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({
NewUnityAssetFile: {
Filename: 'badge-source',
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
},
BodyType: 4,
ThumbnailFileName: 'badge.png',
AdditionalConfiguration: JSON.stringify({
v: 1,
d: { n: 'Badge', o: 1000, d: { t: '000000' } },
}),
UpdateCurrentSaveIfPrivate: true,
}),
})
expect(save.status).toBe(200)
const { value: saveId } = await save.json<{ value: number }>()
const detail = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
headers: await headers(),
})
expect(await detail.json()).toMatchObject({
ThumbnailImageFilename: 'badge.png',
CurrentSaves: [{ CustomAvatarItemSaveId: saveId, BodyType: 4, UnityAsset: null }],
})
expect(
(
await SELF.fetch(url + '/' + saveId + '/publish', {
method: 'POST',
headers: await headers(),
})
).status
).toBe(200)
expect(await (await env.IMAGES.get('badge.png'))!.arrayBuffer()).toEqual(png.buffer)
const published = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
method: 'PUT',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({ Accessibility: 1 }),
})
expect(published.status).toBe(200)
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(200)
})
async function create(id = 42, roles = ['betastudio'], outfitType = 0) {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'Studio Hat', OutfitType: outfitType, Accessibility: 0, Price: 0 })
)
return SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(id, roles), body: form })
}
it('accepts the SDK create request with nullable metadata fields', async () => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({
Name: 'SDK item',
OutfitType: 101,
BaseAvatarItemColor: '#101',
BadgeRoomId: null,
PreviewOrientation: null,
Description: null,
Tags: null,
Price: null,
Accessibility: null,
})
)
const response = await SELF.fetch(base + '/v1', {
method: 'POST',
headers: await headers(),
body: form,
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
value: { Description: '', Tags: [], Price: 0, Accessibility: 0, CurrentSaves: [] },
})
})
it('keeps grants per-account, rejects forbidden item categories, and creates metadata-only Studio items', async () => {
expect((await create(43)).status).toBe(403)
expect((await create(42, [])).status).toBe(403)
expect((await create(42, ['betastudio'], 500)).status).toBe(403)
const response = await create()
expect(response.status).toBe(200)
const { value: item } = await response.json<{
value: { CustomAvatarItemId: string; OutfitType: number; CurrentSaves: unknown[] }
}>()
expect(item.OutfitType).toBe(0)
expect(item.CurrentSaves).toEqual([])
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(403)
expect(
(await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, { headers: await headers() })).status
).toBe(200)
const page = await SELF.fetch(base + '/v1/me', { headers: await headers() })
expect(await page.json()).toMatchObject({
TotalResults: 1,
Results: [{ CustomAvatarItemId: item.CustomAvatarItemId }],
})
})
it('protects source history, verifies upload ownership and hash, and never publishes unbuilt revisions', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const bytes = new TextEncoder().encode('{"Version":3}'),
digest = await crypto.subtle.digest('SHA-256', bytes)
await env.CDN_ASSETS.put('avatarunityasset/2026-10-18/source', bytes, {
sha256: digest,
customMetadata: { ownerId: '42' },
})
await env.CDN_ASSETS.put('image/thumb.png', 'thumbnail', {
customMetadata: { ownerId: '42' },
httpMetadata: { contentType: 'image/png' },
})
const body = {
NewUnityAssetFile: {
Filename: '2026-10-18/source',
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
},
BodyType: 1,
ThumbnailFileName: 'thumb.png',
AdditionalConfiguration: '{}',
UpdateCurrentSaveIfPrivate: true,
}
const post = async (value: unknown, id = 42) =>
SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
body: JSON.stringify(value),
})
expect((await post(body, 43)).status).toBe(403)
expect(
(await post({ ...body, NewUnityAssetFile: { ...body.NewUnityAssetFile, Hash: 'wrong' } }))
.status
).toBe(400)
const response = await post(body)
expect(response.status).toBe(200)
const { value: saveId } = await response.json<{ value: number }>()
expect(await (await env.IMAGES.get('thumb.png'))?.text()).toBe('thumbnail')
expect((await SELF.fetch(url, { headers: await headers(43) })).status).toBe(403)
const history = await SELF.fetch(url, { headers: await headers() })
expect(await history.json()).toMatchObject({
success: true,
value: {
TotalResults: 1,
Results: [
{
CustomAvatarItemSaveId: saveId,
SourceUnityAssetFilename: body.NewUnityAssetFile.Filename,
UnityAsset: null,
},
],
},
})
expect(
(
await SELF.fetch(url + '/' + saveId + '/publish', {
method: 'POST',
headers: await headers(),
})
).status
).toBe(400)
expect(
(await post({ ...body, NewUnityAssetFile: null, ExistingUnityAssetId: crypto.randomUUID() }))
.status
).toBe(400)
})
it('allows creator metadata edits without exposing unbuilt items or changing ownership', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const edit = async (body: unknown, id = 42) =>
SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
method: 'PUT',
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
expect((await edit({ Name: 'Other owner' }, 43)).status).toBe(403)
expect((await edit({ Accessibility: 1 })).status).toBe(400)
expect((await edit({ Tags: [{ TagType: 1, Value: '' }] })).status).toBe(400)
const response = await edit({
Name: ' Updated hat ',
Description: 'Edited in Studio',
Tags: [{ TagType: 1, Value: 'hat' }],
CreatorAccountId: 43,
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
value: {
Name: 'Updated hat',
Description: 'Edited in Studio',
CreatorAccountId: 42,
Accessibility: 0,
Tags: [{ TagType: 1, Value: 'hat' }],
},
})
})
it('isolates outputs across reused-source bodies/configurations and preserves original restore', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const asset = crypto.randomUUID()
await env.DB.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) VALUES (100,?,1,?,'same-source','same-hash','thumb.png','{}','now')"
)
.bind(item.CustomAvatarItemId, asset)
.run()
await env.DB.prepare(
"INSERT INTO unity_asset VALUES (?,0,5,'main','original.bundle','original-hash')"
)
.bind(asset)
.run()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
await env.CDN_ASSETS.put('image/thumb.png', 'fixture', { customMetadata: { ownerId: '42' } })
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const save = async (bodyType: number, config: string) => {
const response = await SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({
ExistingUnityAssetId: asset,
BodyType: bodyType,
AdditionalConfiguration: config,
ThumbnailFileName: 'thumb.png',
UpdateCurrentSaveIfPrivate: true,
}),
})
expect(response.status).toBe(200)
const { value: id } = await response.json<{ value: number }>()
return (await avatarStudioSave(env.DB, item.CustomAvatarItemId, id))!
}
const metadata = await save(1, '{}')
expect(metadata.unity_asset_id).toBe(asset)
const otherBody = await save(2, '{}'),
otherConfig = await save(1, '{"variant":2}')
for (const revision of [otherBody, otherConfig]) {
expect(revision.unity_asset_id).not.toBe(asset)
expect(revision.source_filename).toBe('same-source')
expect(revision.source_hash).toBe('same-hash')
expect((await avatarSaveDto(env.DB, revision)).UnityAsset).toBeNull()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, revision.id)).toBe(false)
}
expect(otherBody.unity_asset_id).not.toBe(otherConfig.unity_asset_id)
await env.DB.prepare(
"INSERT INTO unity_asset VALUES (?,0,5,'main','other-body.bundle','other-hash')"
)
.bind(otherBody.unity_asset_id)
.run()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, otherBody.id)).toBe(true)
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
const stored = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
.bind(item.CustomAvatarItemId)
.first<{ data: string }>())!.data
)
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 1).UnityAsset).toBe(
'original.bundle'
)
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 2).UnityAsset).toBe(
'other-body.bundle'
)
})
+5 -2
View File
@@ -63,8 +63,11 @@ claim, so developer/moderator powers refresh on every login and every refresh gr
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload,
and developer does not include it. It is stamped from the account's `hasStudio` flag.
Staff add and remove people at `/settings/recroomstudio` on the website (or
and developer does not include it. It is stamped from the account's `hasStudio` flag,
or from a Discord link holding a role named in `STUDIO_ROLLOUT`
(`RECFLARE_STUDIO_ROLLOUT` in the root `.env`) — a staged Studio rollout to a Discord
role, read from the same role snapshot as the build rollout gate. Staff add and remove
individual people at `/settings/recroomstudio` on the website (or
`runx admin grant-studio`). A player already signed into Studio keeps the old token
until the next sign-in or refresh.
+178 -39
View File
@@ -37,7 +37,12 @@ import {
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import {
generateToken,
TOKEN_TTL_SECONDS,
validateAndGetAccountId,
validateIdentityAssertion,
} from '@repo/jwt'
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
@@ -65,12 +70,15 @@ import {
TokenResponse,
} from './openapi'
import {
accountHoldsDiscordRole,
countAccountsForPlatformIdentity,
getLinksForAccount,
getLinksForIdentity,
getLinksForPlatformId,
getLinksForPlatformIdentity,
hasStudioAccess,
isPlatformIdentityLinked,
linkPlatformIdentity,
parseRoleIdList,
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
@@ -108,6 +116,21 @@ const TOKEN_SCOPE =
*/
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
/**
* The website's "Sign in with Discord". `www` completes the Discord OAuth2 exchange (it
* alone holds the client secret), then posts an `assertion` — an HS256 token signed with
* the shared JWT_SECRET naming the platform identity it just proved (see `@repo/jwt`'s
* `generateIdentityAssertion`). This grant trusts the assertion the way `cached_login`
* trusts a Steam ticket, resolves it through the link table, and issues the ordinary
* session. No account linked to the identity means a NEW one is made and linked, so a
* Discord account is a way to join without Steam or Meta; the player then sets a password
* on the website and signs in with it anywhere — a sideloaded Quest included.
*/
const IDENTITY_ASSERTION_GRANT = 'identity_assertion'
/** What the grant answers when one identity opens several accounts; never true today. */
const AMBIGUOUS_IDENTITY_DESCRIPTION = 'platform identity is linked to more than one account'
/**
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
@@ -209,16 +232,11 @@ const UNSUPPORTED_BUILD_DESCRIPTION =
/**
* `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or
* whitespace (the same grammar `www`'s parseRoleIds reads DISCORD_BENEFITS_ROLE_IDS with).
* Unset or blank parses to an empty list, which `accountMayUseRolloutBuild` treats as a
* gate closed to everyone but staff.
* whitespace (`parseRoleIdList`, the grammar `www`'s parseRoleIds reads
* DISCORD_BENEFITS_ROLE_IDS with). Unset or blank parses to an empty list, which
* `accountMayUseRolloutBuild` treats as a gate closed to everyone but staff.
*/
function rolloutRoleIds(raw: string | undefined): string[] {
return (raw ?? '')
.split(/[\s,]+/)
.map((id) => id.trim())
.filter((id) => id !== '')
}
const rolloutRoleIds = parseRoleIdList
/**
* Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator
@@ -239,11 +257,7 @@ async function accountMayUseRolloutBuild(
): Promise<boolean> {
const account = await getAccount(db, accountId)
if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true
if (roleIds.length === 0) return false
const links = await getLinksForAccount(db, accountId)
return links.some(
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
)
return accountHoldsDiscordRole(db, accountId, roleIds)
}
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
@@ -317,8 +331,8 @@ async function authedId(c: Context<App>): Promise<number | null> {
/** Whether one account row holds a role. Absent/false both mean no role. */
function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean {
// Matches accountTokenRoles: screenshare is a feature gate on every account token.
if (role === 'screenshare') return true
// Match the same staff override used when minting account tokens.
if (role === 'screenshare') return account.hasScreenshare === true
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
}
@@ -368,30 +382,31 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
/**
* The role names beyond `gameClient` for an account's token `role` claim. Base roles
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
* client gates the screen-share feature on it and nothing grants it per-account, so it
* is unconditional (even with no account resolved). The rest are the operator-granted
* extras, plus `junior` off the account's own `isJunior` flag.
* (gameClient) are added by generateToken. `screenshare` requires an explicit staff
* grant via `hasScreenshare`. The other roles come from account flags and Studio access.
*
* `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it.
* RecFlare Studio treats that exact claim as Full access, which is what lets the editor
* upload; staff set the flag from the website. `influencer` rides on `isInfluencer`
* (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup
* answers it. Order is stable so tokens are deterministic: screenshare, developer,
* moderator, junior, betastudio, then influencer.
* `betastudio` rides on `studio` — the account's Studio access as `hasStudioAccess`
* resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding
* a role in the `STUDIO_ROLLOUT` var — and developer does not imply it. RecFlare Studio
* treats that exact claim as Full access, which is what lets the editor upload. The
* caller resolves it (it costs a link read) so this stays a pure projection of the
* account. `influencer` rides on `isInfluencer` (`runx admin grant-influencer`) and lives
* only in the token — no `/role/*` lookup answers it. Order is stable so tokens are
* deterministic: screenshare, developer, moderator, junior, betastudio, then influencer.
*/
function accountRoles(
account: Pick<
Account,
'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer'
> | null
'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer' | 'hasScreenshare'
> | null,
studio: boolean
): string[] {
const roles = ['screenshare']
const roles: string[] = account?.hasScreenshare === true ? ['screenshare'] : []
if (!account) return roles
if (account.isDeveloper) roles.push('developer')
if (account.isModerator) roles.push('moderator')
if (account.isJunior) roles.push('junior')
if (account.hasStudio) roles.push('betastudio')
if (studio) roles.push('betastudio')
if (account.isInfluencer) roles.push('influencer')
return roles
}
@@ -443,7 +458,13 @@ function oauthError(
* already been authenticated — here, because the player approved a Studio code
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
*/
async function issueSession(c: Context<App>, accountId: number, grantType: string) {
async function issueSession(
c: Context<App>,
accountId: number,
grantType: string,
/** Extra top-level fields on the token body — `created: true` on an identity signup. */
extra: Record<string, unknown> = {}
) {
const clientIp = c.req.header('cf-connecting-ip') ?? ''
const ban = await resolveBan(c.env.DB, accountId, {
identity: { ip: clientIp },
@@ -473,7 +494,7 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
roleAccount.platformId ?? '',
accountPlatform(roleAccount),
jwtSecret,
accountRoles(roleAccount),
accountRoles(roleAccount, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)),
accountPrivileges(roleAccount),
GAME_VERSION,
roleAccount.hasPlus === true
@@ -516,9 +537,109 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
refresh_token: refreshToken,
scope: TOKEN_SCOPE,
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
...extra,
})
}
/**
* The `identity_assertion` grant: sign into — or create — the account behind a platform
* identity another worker has verified and signed for. See IDENTITY_ASSERTION_GRANT.
*
* Handled before the password fallback and outside the version/rollout gates, like the
* Studio device grant: nothing here posts a `ver`, and the credential is the assertion
* alone. The assertion names an IDENTITY, not an account; which account that is (or that
* there is none yet) is decided here, from the same table `cached_login` reads, so the
* website can never choose an account id for itself.
*
* Creating an account when the identity opens none is deliberate — it is the Discord
* sign-up — and takes the same guards `create_account` does for a verified platform
* identity: the evasion arms (a banned player's next move is a new account), the per-IP
* cap (`www` forwards the browser's real address over the service binding) and the
* per-identity cap. NOT the PASSWORD_SIGNUP switch: that exists because a password account
* has no identity but its address, and this one has a Discord user Discord itself vouched
* for, one account per user. The new account carries no `platform`/`platformId` on its
* blob — only the link — so its DTO and token read like a password account's and the
* Discord snowflake stays off the wire, exactly as the benefits claim keeps it.
*/
async function identityAssertionGrant(c: Context<App>, body: Record<string, unknown>) {
const assertion = typeof body.assertion === 'string' ? body.assertion : ''
const jwtSecret = await c.env.JWT_SECRET.get()
if (jwtSecret === '') {
logger.error('refusing an identity assertion: JWT_SECRET is empty')
return oauthError(c, 'server_error', 'token signing is not configured', 500)
}
const identity = assertion ? await validateIdentityAssertion(assertion, jwtSecret) : null
if (identity === null) {
return oauthError(c, 'invalid_grant', 'invalid or expired identity assertion')
}
const links = await getLinksForIdentity(c.env.DB, identity.platform, identity.platformId)
if (links.length > 1) {
// The benefits claim keeps a Discord identity on one account, so this is a data
// problem rather than a player's choice to make; refused rather than guessed.
logger.error('identity assertion names an identity linked to several accounts', {
platform: identity.platform,
accountIds: links.map((l) => l.accountId),
})
return oauthError(c, 'invalid_grant', AMBIGUOUS_IDENTITY_DESCRIPTION)
}
if (links.length === 1) {
return issueSession(c, links[0].accountId, IDENTITY_ASSERTION_GRANT)
}
// Nobody yet: the sign-up. Everything below mirrors create_account's platform path.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
const blocked = await resolveBan(c.env.DB, null, {
identity: { ip: clientIp, platform: identity.platform, platformId: identity.platformId },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (blocked) {
logger.info('identity signup refused: player banned', {
via: blocked.via,
bannedAccountId: blocked.bannedAccountId,
ip: clientIp,
platform: identity.platform,
})
return oauthError(c, 'invalid_grant', BLOCKED_DESCRIPTION)
}
const maxPerPlatformId = intVar(
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
)
if (
maxPerPlatformId > 0 &&
(await countAccountsForPlatformIdentity(c.env.DB, identity.platform, identity.platformId)) >=
maxPerPlatformId
) {
logger.info('identity signup rejected: platform account limit', { platform: identity.platform })
return oauthError(c, 'invalid_grant', 'account limit reached for this platform account')
}
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerIp > 0 &&
clientIp !== '' &&
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
) {
logger.info('identity signup rejected: per-IP account limit', { ip: clientIp })
return oauthError(c, 'invalid_grant', 'too many accounts created from this network')
}
const account = await createAccount(c.env.DB, {
platforms: 0,
lastLoginTime: new Date().toISOString(),
signupIp: clientIp || undefined,
lastLoginIp: clientIp || undefined,
})
await linkPlatformIdentity(c.env.DB, account.accountId, identity.platform, identity.platformId)
await placeNewPlayerInOrientation(c.env, account.accountId, 0)
logger.info('created an account from an identity assertion', {
accountId: account.accountId,
platform: identity.platform,
ip: clientIp,
})
return issueSession(c, account.accountId, IDENTITY_ASSERTION_GRANT, { created: true })
}
async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
@@ -922,7 +1043,7 @@ const app = new Hono<App>()
tags: ['Token'],
summary: 'OAuth token endpoint — issues a JWT',
description: [
'Issues an access token (plus a single-use refresh token) for one of four grants,',
'Issues an access token (plus a single-use refresh token) for one of six grants,',
'selected by `grant_type`. Every grant returns the same body on success.',
'',
'**`create_account`** — mints a new account with an auto-assigned random username',
@@ -945,6 +1066,17 @@ const app = new Hono<App>()
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
'platform and platform id come from what was stored at issue time, not the body.',
'',
'**`identity_assertion`** — the website’s "Sign in with Discord". `www` completes the',
'Discord OAuth2 code exchange (it alone holds the client secret) and posts an',
'`assertion`: an HS256 token signed with the shared JWT_SECRET naming the platform',
'(`101`) and platform id it proved, good for a minute. The identity is resolved through',
'the same link table as `cached_login`: one linked account is signed into; none means a',
'NEW account is created and linked (the body then carries `created: true`), under the',
'evasion check and the per-IP and per-identity signup caps but NOT the PASSWORD_SIGNUP',
'switch — the Discord user is the identity a password account lacks. The new account',
'has no password until its owner sets one on the website; with one, it signs in',
'anywhere the `password` grant does. Several linked accounts is refused.',
'',
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
'identifies the account by `username` or numeric `account_id` and requires the',
'matching `password`. An account with no stored hash cannot be logged into at all,',
@@ -972,9 +1104,10 @@ const app = new Hono<App>()
'',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant. `junior` rides along for an',
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
'from the account’s `hasStudio` flag (staff set it on the website). RecFlare Studio',
'account flagged `isJunior`. `screenshare` requires a staff grant on the Studio Access',
'page. `betastudio` is stamped',
'from the account’s `hasStudio` flag (staff set it on the website) or from a Discord',
'link holding a role in the `STUDIO_ROLLOUT` var. RecFlare Studio',
'treats that claim as permission to upload; `developer` does not confer it. A junior',
'also carries',
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
@@ -1072,6 +1205,9 @@ const app = new Hono<App>()
}
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT)
}
// The website's Discord sign-in, likewise before the fallback — and before the
// version gates, which it never posts a `ver` for. See identityAssertionGrant.
if (grantType === IDENTITY_ASSERTION_GRANT) return identityAssertionGrant(c, body)
// `platform`/`platform_id` come from the body for a fresh login; a refresh
// grant overrides them below with what was stored when the token was issued.
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
@@ -1565,7 +1701,10 @@ const app = new Hono<App>()
platformId,
platform,
jwtSecret,
accountRoles(roleAccount),
accountRoles(
roleAccount,
await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)
),
accountPrivileges(roleAccount),
version,
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
+12
View File
@@ -64,6 +64,18 @@ export type Env = SharedHonoEnv & {
* Logins on every other build are never touched by this.
*/
DISCORD_ROLLOUT_ROLE_IDS?: string
/**
* The Discord role ids whose holders get Rec Room Studio upload access — stamped into the
* token as `betastudio` exactly as the account's `hasStudio` flag is, so a whole Discord
* role can be let into Studio without touching anyone's account. Same grammar as
* DISCORD_ROLLOUT_ROLE_IDS above (snowflakes, commas and/or whitespace; ANY one qualifies),
* read from the same Discord link snapshot. Set for every worker from the root `.env`
* (`RECFLARE_STUDIO_ROLLOUT`): `studio` and `www` read it through the same
* `hasStudioAccess`, so what the token says, what the editor's access lookup says and
* what the website's `/api/studio-access` says agree. Unset, or naming no role, grants
* nobody — the flag alone decides. The flag still works beside it.
*/
STUDIO_ROLLOUT?: string
/**
* The client builds a `/connect/token` grant may post as `ver` and still get a token:
* a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means
+11 -2
View File
@@ -143,10 +143,12 @@ export const TokenRequest = z.object({
'refresh_token',
'password',
'urn:ietf:params:oauth:grant-type:device_code',
'identity_assertion',
])
.describe(
'Anything unrecognised (including absent) is treated as a password grant, except the ' +
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization'
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization, ' +
'and identity_assertion, the website’s Discord sign-in'
),
account_id: z.string().optional().describe('Numeric account id, as a string'),
username: z
@@ -183,6 +185,13 @@ export const TokenRequest = z.object({
.string()
.optional()
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
assertion: z
.string()
.optional()
.describe(
'identity_assertion grant: the HS256 assertion `www` minted with the shared JWT_SECRET ' +
'after completing a Discord OAuth2 exchange, naming the platform and platform_id it proved'
),
device_id: z
.string()
.optional()
@@ -292,7 +301,7 @@ export function roleLookup(role: 'developer' | 'moderator' | 'screenshare') {
summary: `Whether a player has the ${role} role`,
description:
role === 'screenshare'
? 'Returns a bare JSON boolean. Screenshare is enabled for every existing account, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
? 'Returns a bare JSON boolean. Screenshare is disabled by default and requires an explicit staff grant, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
: `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
+175
View File
@@ -314,6 +314,147 @@ export async function getLinksForPlatformId(
return results.map(toLink)
}
/**
* Every account a platform identity is linked to, on ANY platform — including the ones
* {@link CACHED_LOGIN_PLATFORMS} keeps out of the picker.
*
* This is the unfiltered twin of {@link getLinksForPlatformIdentity}, and it must stay off
* every public route: the filter there exists so nobody can learn which account a Discord
* user owns by naming their snowflake. The one caller is `auth`'s `identity_assertion`
* grant, where the identity being asked about has just been PROVEN by the worker that
* minted the assertion — the website's Discord sign-in — so the answer goes to the person
* it is about and nobody else. Add a caller only under the same condition.
*/
export async function getLinksForIdentity(
db: D1Database,
platform: number,
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
const { results } = await db
.prepare(
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
)
.bind(platform, platformId)
.all<LinkRow>()
return results.map(toLink)
}
/**
* Whether any of the account's links on `platform` has roles RECORDED — a non-null `role`
* column, which only the website's benefits claim (and the sweep behind it) ever writes.
*
* This is what "has this account claimed its Discord benefits" means now that a Discord
* link can exist WITHOUT a claim: signing in with Discord (`auth`'s `identity_assertion`
* grant) links the identity on a brand-new account with `role` NULL. The claim's one-time
* gift and its page both ask this rather than "is there a link", so a player who made
* their account from Discord is still owed, and still shown, their first claim.
* An EMPTY role list counts as recorded: that is a claim that found no roles.
*/
export async function hasRecordedRoles(
db: D1Database,
accountId: number,
platform: number
): Promise<boolean> {
const row = await db
.prepare(
`SELECT 1 AS ok FROM platform_account
WHERE account_id = ?1 AND platform = ?2 AND role IS NOT NULL LIMIT 1`
)
.bind(accountId, platform)
.first<{ ok: number }>()
return row !== null
}
/**
* Record roles on an existing link that has NONE yet (`role` NULL), atomically. True when
* this call was the one that did it; false when the link is missing or already carries a
* role list — in which case nothing is written, and {@link linkPlatformIdentity}'s refresh
* is the caller's next step.
*
* The one-shot flip is the point: the benefits claim pays its first-claim gift on the
* INSERT of a fresh link, which two racing claims cannot both win. A link made by a Discord
* sign-in is already inserted by the time the claim comes, so this gives the claim the
* same single-winner write on the roles column instead.
*/
export async function recordFirstRoles(
db: D1Database,
accountId: number,
platform: number,
platformId: string,
roles: readonly string[]
): Promise<boolean> {
if (platformId === '') return false
const res = await db
.prepare(
`UPDATE platform_account SET role = ?4
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3 AND role IS NULL`
)
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
.run()
return res.meta.changes > 0
}
/**
* A list of Discord role ids out of an operator var — numeric snowflakes separated by
* commas and/or whitespace, the grammar every `*_ROLE_IDS`-shaped var here shares
* (`auth`'s DISCORD_ROLLOUT_ROLE_IDS, `STUDIO_ROLLOUT`; `www` reads
* DISCORD_BENEFITS_ROLE_IDS the same way). Unset or blank parses to an empty list.
*/
export function parseRoleIdList(raw: string | undefined): string[] {
return (raw ?? '')
.split(/[\s,]+/)
.map((id) => id.trim())
.filter((id) => id !== '')
}
/**
* Whether an account's Discord link records ANY of `roleIds`. False with none configured —
* the link read is skipped, so a var that names no role grants nobody anything. Roles are
* the SNAPSHOT the website's benefits claim and `www`'s daily sweep keep (see
* {@link PlatformLink.roles}), so a role granted in Discord today counts once the player
* re-claims or the sweep runs — not instantly.
*/
export async function accountHoldsDiscordRole(
db: D1Database,
accountId: number,
roleIds: readonly string[]
): Promise<boolean> {
if (roleIds.length === 0) return false
const links = await getLinksForAccount(db, accountId)
return links.some(
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
)
}
/**
* Whether an account has Rec Room Studio upload access — what `auth` stamps into the
* token as the `betastudio` role, `studio` answers on its access lookups and `www`'s
* `/api/studio-access` reports. Two grants, either suffices:
*
* - the account's own `hasStudio` flag, set by staff on the website or
* `runx admin grant-studio`;
* - the STUDIO ROLLOUT: a Discord link holding a role named in the `STUDIO_ROLLOUT` var
* (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`, reaching every worker), which hands a
* whole Discord role the same access without touching anyone's account. Unset, or
* naming no role, it grants nobody — the flag alone decides, as before.
*
* Every reader goes through this so the three surfaces cannot disagree: a role holder
* whom `auth` stamps `betastudio` for must also be told "granted" by the website, and
* `studio`'s lookups (which rebuild the role from the account rather than a token) must
* see the same answer. The flag is checked first so an account that has it costs no
* link read.
*/
export async function hasStudioAccess(
db: D1Database,
account: { accountId: number; hasStudio?: boolean } | null | undefined,
studioRolloutRoleIds: string | undefined
): Promise<boolean> {
if (!account) return false
if (account.hasStudio === true) return true
return accountHoldsDiscordRole(db, account.accountId, parseRoleIdList(studioRolloutRoleIds))
}
/** Every platform identity linked to an account (a player's PC and headset, say). */
export async function getLinksForAccount(
db: D1Database,
@@ -348,6 +489,40 @@ export async function isPlatformIdentityLinked(
return row !== null
}
/**
* Remove EVERY link an account holds on one platform, and return the links that were
* removed (empty when it held none). The website's Discord unlink is the one caller:
* a player taking their Discord off their account, which gives the identity back —
* "Sign in with Discord" with it afterwards makes a NEW account, and the benefits claim's
* once-only guard lets it claim elsewhere again.
*
* The removed rows are returned rather than a count because the caller needs to know what
* they carried: a link whose `role` was recorded is a CLAIM, and the account must remember
* having made one after the row is gone (see `account.hasClaimedDiscord`), or the
* first-link gift would pay again on every link/unlink cycle. Read-then-delete, not
* `DELETE … RETURNING`, so it runs on every D1 the tests do.
*
* Not for Steam or Meta: those links are what a cached login redeems, and nothing on the
* website removes one. The helper doesn't refuse them — the table has no idea what a
* platform is for — so a caller must only ever name a platform nobody logs in with.
*/
export async function unlinkPlatform(
db: D1Database,
accountId: number,
platform: number
): Promise<PlatformLink[]> {
const { results } = await db
.prepare(`${SELECT_LINK} WHERE account_id = ?1 AND platform = ?2 ORDER BY linked_at`)
.bind(accountId, platform)
.all<LinkRow>()
if (results.length === 0) return []
await db
.prepare(`DELETE FROM platform_account WHERE account_id = ?1 AND platform = ?2`)
.bind(accountId, platform)
.run()
return results.map(toLink)
}
/**
* How many accounts one platform identity already opens — the count both signup caps
* and link caps are enforced against, so an identity can't accumulate accounts by
+236 -11
View File
@@ -7,6 +7,7 @@ import '../../auth.app'
import {
AUDIT_LOG_SCHEMA_DDL,
GAME_VERSION,
getAccount,
getAccountsByDeviceId,
hashPassword,
PRESENCE_SCHEMA_DDL,
@@ -16,8 +17,14 @@ import {
SUBROOM_SCHEMA_DDL,
updateAccount,
} from '@repo/domain'
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
import {
generateIdentityAssertion,
generateToken,
TOKEN_TTL_SECONDS,
validateIdentityAssertion,
} from '@repo/jwt'
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
import {
banFromReport,
createReport,
@@ -102,6 +109,9 @@ beforeAll(async () => {
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
// the token grant reads it for the evasion arms.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Custom avatar items (owned by the api worker) — every room read joins the published
// room badges from this table.
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
@@ -682,8 +692,8 @@ describe('auth worker routes', () => {
expect(payload.iss).toBe('https://auth.recflare.net')
expect(payload.aud).toBe('https://auth.recflare.net')
expect(payload.role).toContain('gameClient')
// screenshare is a feature gate, not a grant — every token carries it.
expect(payload.role).toContain('screenshare')
// Screen sharing is disabled until staff explicitly grant it.
expect(payload.role).not.toContain('screenshare')
// A plain adult account carries nothing beyond those — no elevated roles.
expect(payload.role).not.toContain('developer')
expect(payload.role).not.toContain('moderator')
@@ -740,6 +750,37 @@ describe('auth worker routes', () => {
// refresh, and the Studio device grant — and a refresh after a revoke drops it. A
// token that kept the role for the rest of the day would leave a removed player
// uploading.
test('screen sharing requires a staff grant on login, refresh, and role lookup', async () => {
await seedAccount(9819, 'ScreenSharePlayer')
expect((await tokenFor(`account_id=9819&password=${LOGIN_PASSWORD}`)).role).not.toContain(
'screenshare'
)
await updateAccount(env.DB, 9819, { hasScreenshare: true })
const granted = await tokenFor(`account_id=9819&password=${LOGIN_PASSWORD}`)
expect(granted.role).toContain('screenshare')
expect(await (await exports.default.fetch(`${ORIGIN}/role/screenshare/9819`)).json()).toBe(true)
const session = await postToken(`account_id=9819&password=${LOGIN_PASSWORD}`)
const sessionBody = session.json as { refresh_token: string }
await updateAccount(env.DB, 9819, { hasScreenshare: false })
const refresh = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: sessionBody.refresh_token,
}),
})
expect(refresh.status).toBe(200)
const refreshed = (await refresh.json()) as { access_token: string }
const payload = JSON.parse(
atob(refreshed.access_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
)
expect(payload.role).not.toContain('screenshare')
expect(await (await exports.default.fetch(`${ORIGIN}/role/screenshare/9819`)).json()).toBe(
false
)
})
test('POST /connect/token stamps betastudio only while the account has hasStudio', async () => {
// 9811/9812, not 9601–9605: those ids belong to the staff-login audit tests,
// which insert with OR IGNORE and then count `staff_login` rows.
@@ -751,7 +792,6 @@ describe('auth worker routes', () => {
await updateAccount(env.DB, 9811, { hasStudio: true })
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
@@ -770,7 +810,6 @@ describe('auth worker routes', () => {
.run()
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'developer',
'moderator',
'betastudio',
@@ -803,7 +842,6 @@ describe('auth worker routes', () => {
expect(signedIn.status).toBe(200)
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
@@ -818,6 +856,53 @@ describe('auth worker routes', () => {
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
})
// STUDIO_ROLLOUT opens Studio to a Discord ROLE: a holder is stamped `betastudio` exactly
// as a flagged account is, with no account flag involved, from the same Discord link
// snapshot the build rollout gate reads. Unset, the flag alone decides.
test('POST /connect/token stamps betastudio for a Discord role in STUDIO_ROLLOUT', async () => {
const studioRole = '1077000000000000031'
const otherRole = '1077000000000000032'
await seedAccount(9813, 'StudioRoleHolder')
await linkPlatformIdentity(env.DB, 9813, PlatformType.Discord, '500000000000009813', [
studioRole,
])
await seedAccount(9814, 'StudioRoleOutsider')
await linkPlatformIdentity(env.DB, 9814, PlatformType.Discord, '500000000000009814', [
otherRole,
])
const login = (id: number) => tokenFor(`account_id=${id}&password=${LOGIN_PASSWORD}`)
const original = env.STUDIO_ROLLOUT
try {
// Unset, or naming no role: the link grants nothing.
delete env.STUDIO_ROLLOUT
expect((await login(9813)).role).not.toContain('betastudio')
env.STUDIO_ROLLOUT = ' , '
expect((await login(9813)).role).not.toContain('betastudio')
// Same list grammar as DISCORD_ROLLOUT_ROLE_IDS; any one named role qualifies.
env.STUDIO_ROLLOUT = ` ${otherRole}x ,\n${studioRole} `
expect((await login(9813)).role).toEqual(['gameClient', 'betastudio'])
// A Discord link holding only other roles is not in the rollout.
expect((await login(9814)).role).not.toContain('betastudio')
// The account flag never moved: this is the var, not a grant written anywhere.
expect((await getAccount(env.DB, 9813))?.hasStudio).toBeUndefined()
// The refresh grant re-resolves it, so ending the rollout drops the claim on the
// next refresh — the same path a revoked flag takes.
const session = await postToken(`account_id=9813&password=${LOGIN_PASSWORD}`)
delete env.STUDIO_ROLLOUT
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(session.json.refresh_token as string)}`
)
expect(refreshed.status).toBe(200)
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
} finally {
if (original === undefined) delete env.STUDIO_ROLLOUT
else env.STUDIO_ROLLOUT = original
}
})
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
// the website's Discord benefits claim sets. `econ` decides the CampusCard and the
// subscriber discount from this claim ALONE and never reads the account, so if this
@@ -888,7 +973,7 @@ describe('auth worker routes', () => {
)
.run()
const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'screenshare', 'junior']))
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'junior']))
expect(payload.role).not.toContain('developer')
// `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`.
expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat'])
@@ -907,9 +992,7 @@ describe('auth worker routes', () => {
)
.run()
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'influencer']))
expect(payload.role).not.toContain('developer')
// Off the flag, off the token — no other flag implies it.
@@ -1959,7 +2042,7 @@ describe('auth worker routes', () => {
for (const id of [42, 77]) {
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
expect(await res.json()).toBe(false)
}
})
@@ -2579,3 +2662,145 @@ describe('staff login audit', () => {
expect(await staffLogins(9605)).toHaveLength(1)
})
})
// The website's "Sign in with Discord". `www` proves the Discord user (it holds the client
// secret) and posts an identity ASSERTION signed with the shared JWT_SECRET; this grant
// resolves it through the link table like a cached_login, or makes the account when there
// is none. Everything here mints the assertion the way www does, with the test key.
describe('identity_assertion grant', () => {
const SIGNING_KEY = 'test-signing-key'
const assertionFor = (platformId: string, platform = PlatformType.Discord) =>
generateIdentityAssertion({ platform, platformId }, SIGNING_KEY)
const redeem = async (assertion: string, ip?: string) =>
postToken(`grant_type=identity_assertion&assertion=${encodeURIComponent(assertion)}`, ip)
test('an assertion round-trips, and nothing else passes as one', async () => {
const assertion = await assertionFor('700000000000000001')
await expect(validateIdentityAssertion(assertion, SIGNING_KEY)).resolves.toEqual({
platform: PlatformType.Discord,
platformId: '700000000000000001',
})
// A different key, and an access token signed with the RIGHT key: both refused. The
// second is the one that matters — the same secret signs sessions, so the audience is
// what keeps a stolen token from being replayed as proof of a platform identity.
await expect(validateIdentityAssertion(assertion, 'another-key')).resolves.toBeNull()
const session = await generateToken('42', '', 4, SIGNING_KEY)
await expect(validateIdentityAssertion(session, SIGNING_KEY)).resolves.toBeNull()
// And the other direction: an assertion carries no `sub`, so it is no session.
const res = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${assertion}`,
},
body: 'user_code=ABCDEFGH',
})
expect(res.status).toBe(401)
})
test('signs into the one account the identity is linked to', async () => {
await seedAccount(9400, 'DiscordLinked')
// Linked the way the benefits claim links: with roles recorded.
await linkPlatformIdentity(env.DB, 9400, PlatformType.Discord, '700000000000009400', [])
const res = await redeem(await assertionFor('700000000000009400'))
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('9400')
expect(res.json).not.toHaveProperty('created')
expect(typeof res.json.refresh_token).toBe('string')
})
test('refuses a missing, forged or replayed-session assertion', async () => {
expect((await postToken('grant_type=identity_assertion')).status).toBe(400)
const forged = await generateIdentityAssertion(
{ platform: PlatformType.Discord, platformId: '700000000000009400' },
'not-the-key'
)
const res = await redeem(forged)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('invalid or expired identity assertion')
// An access token for the linked account, presented as the assertion.
const session = await generateToken('9400', '', 4, SIGNING_KEY)
expect((await redeem(session)).status).toBe(400)
})
test('creates and links an account when the identity opens none, once', async () => {
const discordId = '700000000000009401'
const first = await redeem(await assertionFor(discordId))
expect(first.status).toBe(200)
expect(first.json.created).toBe(true)
const accountId = Number(decodePayload(first.json.access_token as string).sub)
expect(accountId).toBeGreaterThan(0)
// Linked on the Discord platform, and ONLY there: the blob carries no platform
// identity (the snowflake stays off the DTO and the token), like a password account.
const links = await getLinksForAccount(env.DB, accountId)
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
[PlatformType.Discord, discordId],
])
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(accountId)
.first<{ data: string }>()
const blob = JSON.parse(row!.data) as Record<string, unknown>
expect(blob.platformId).toBeUndefined()
expect(blob.passwordHash).toBeUndefined()
// Placed in Orientation like any signup.
const presence = await env.DB.prepare('SELECT 1 AS ok FROM presence WHERE account_id = ?1')
.bind(accountId)
.first()
expect(presence).not.toBeNull()
// The same Discord again is a sign-IN to that account, not a second account.
const again = await redeem(await assertionFor(discordId))
expect(again.status).toBe(200)
expect(again.json).not.toHaveProperty('created')
expect(decodePayload(again.json.access_token as string).sub).toBe(String(accountId))
await expect(
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
).resolves.toBe(1)
})
// The stretch of the feature: a Discord-made account has no password, and a password is
// what signs it into the game (a sideloaded Quest has no Meta nonce to offer). The
// website's password form posts with an EMPTY old password the first time.
test('a Discord-made account can set a password and then sign in with it', async () => {
const created = await redeem(await assertionFor('700000000000009402'))
const accountId = decodePayload(created.json.access_token as string).sub as string
expect((await postToken(`account_id=${accountId}&password=hunter22`)).status).toBe(400)
const set = await changePassword(
'oldPassword=&newPassword=hunter22',
created.json.access_token as string
)
expect(set.status).toBe(200)
const login = await postToken(`grant_type=password&account_id=${accountId}&password=hunter22`)
expect(login.status).toBe(200)
expect(decodePayload(login.json.access_token as string).sub).toBe(accountId)
})
test('refuses an identity that opens several accounts rather than guessing', async () => {
await seedAccount(9403, 'TwoLinksA')
await seedAccount(9404, 'TwoLinksB')
await linkPlatformIdentity(env.DB, 9403, PlatformType.Discord, '700000000000009403', [])
await linkPlatformIdentity(env.DB, 9404, PlatformType.Discord, '700000000000009403', [])
const res = await redeem(await assertionFor('700000000000009403'))
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('platform identity is linked to more than one account')
})
// The signup caps reach this path as they reach create_account — www forwards the
// browser's real address over the service binding, so the per-IP cap counts browsers.
test('the per-IP signup cap applies to accounts made this way', async () => {
const ip = '198.51.100.77'
for (let i = 0; i < 3; i++) {
const res = await redeem(await assertionFor(`70000000000000950${i}`), ip)
expect(res.status).toBe(200)
expect(res.json.created).toBe(true)
}
const fourth = await redeem(await assertionFor('700000000000009509'), ip)
expect(fourth.status).toBe(400)
expect(fourth.json.error_description).toBe('too many accounts created from this network')
// Signing IN from that network is untouched: the cap is on creation.
const existing = await redeem(await assertionFor('700000000000009500'), ip)
expect(existing.status).toBe(200)
})
})
+20 -6
View File
@@ -362,6 +362,13 @@ const app = new Hono<App>()
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
)
// SDK source downloads use an opaque upload capability (no bearer on this
// request). Only creator-authenticated save history exposes these filenames.
.get('/avatarunityasset/:filename{.+}', (c) => {
const name = c.req.param('filename')
if (!/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}$/.test(name)) return c.notFound()
return serveAsset(c, `avatarunityasset/${name}`)
})
// Editable Unity source manifests. Baked bundles are served separately.
.get(
'/roomunityasset/:filename{.+}',
@@ -430,15 +437,22 @@ const app = new Hono<App>()
* a build is stored under it. Null when no build is, or the table has not been migrated
* yet (the rooms worker owns it).
*/
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
async function unityAssetR2Key(env: StudioAssetEnv, filename: string): Promise<string | null> {
try {
// Inlined rather than imported so this worker does not take a dependency on the
// domain package.
const row = await db
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
const row = await env.DB.prepare(
`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`
)
.bind(filename)
.first<{ stored: number }>()
return row ? filename : null
if (!row) return null
if (/^(quest\/)?studio-avatar\//.test(filename)) return `avatar/${filename}`
// Room builds and consumable exports use the game's /room namespace.
// Retain older installations that stored the same filename at the bucket root.
if (filename.startsWith('studio/') && (await env.CDN_ASSETS.head(`room/${filename}`)))
return `room/${filename}`
return filename
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.toLowerCase().includes('no such table')) return null
@@ -450,14 +464,14 @@ async function unityAssetR2Key(db: D1Database, filename: string): Promise<string
async function serveUnityAsset(c: Context<App>) {
const name = c.req.param('filename') ?? ''
if (name.includes('..')) return c.body(null, 400)
const key = await unityAssetR2Key(c.env.DB, name)
const key = await unityAssetR2Key(c.env, name)
return serveAsset(c, key ?? `unityasset/${name}`)
}
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
if (name.includes('..')) return new Response(null, { status: 400 })
const key = await unityAssetR2Key(env.DB, name)
const key = await unityAssetR2Key(env, name)
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
if (!head) return new Response(null, { status: 404 })
+38
View File
@@ -12,6 +12,32 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
test('serves opaque avatar source manifests and SDK avatar bundle GET/HEAD/ranges', async () => {
const source = '2026-10-18/11111111-1111-1111-1111-111111111111'
await env.CDN_ASSETS.put('avatarunityasset/' + source, '{"Version":3}')
expect(await (await exports.default.fetch(ORIGIN + '/avatarunityasset/' + source)).text()).toBe(
'{"Version":3}'
)
await env.DB.prepare(
'CREATE TABLE IF NOT EXISTS unity_asset(unity_asset_id TEXT NOT NULL,target INTEGER NOT NULL,version INTEGER NOT NULL,kind TEXT NOT NULL,filename TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(unity_asset_id,target,version,kind))'
).run()
const name = 'quest/studio-avatar/test/item/lease.material.assetbundle'
await env.DB.prepare(
'INSERT INTO unity_asset(unity_asset_id,target,version,kind,filename,hash) VALUES (?,2,6,?,?,?)'
)
.bind('avatar-fixture', 'material', name, 'hash')
.run()
await env.CDN_ASSETS.put('avatar/' + name, 'UnityFS\0avatar')
expect(
(await exports.default.fetch(ORIGIN + '/unityasset/' + name, { method: 'HEAD' })).status
).toBe(200)
const response = await exports.default.fetch(ORIGIN + '/unityasset/' + name, {
headers: { Range: 'bytes=0-7' },
})
expect(response.status).toBe(206)
expect(await response.text()).toBe('UnityFS\0')
})
describe('cdn endpoints', () => {
test('GET / reports service status', async () => {
const res = await exports.default.fetch(`${ORIGIN}/`)
@@ -357,6 +383,18 @@ describe('cdn endpoints', () => {
expect(head.status).toBe(200)
expect(head.headers.get('content-length')).toBe('4')
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
// Current room/consumable builds live in the game's room namespace.
await env.CDN_ASSETS.put('room/' + filename, new Uint8Array([5, 6, 7, 8, 9]))
const current = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
headers: { Range: 'bytes=1-2' },
})
expect(current.status).toBe(206)
expect(new Uint8Array(await current.arrayBuffer())).toEqual(new Uint8Array([6, 7]))
const currentHead = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
method: 'HEAD',
})
expect(currentHead.status).toBe(200)
expect(currentHead.headers.get('content-length')).toBe('5')
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
expect(
+62
View File
@@ -106,6 +106,8 @@ import {
ConsumeConsumableRequest,
ConsumeEnvelope,
ConsumeGiftRequest,
ConsumeRoomConsumableRequest,
ConsumeRoomConsumableResponse,
CreatePurchaseOfferRequest,
CreateRoomCurrencyRequest,
CreateRoomKeyRequest,
@@ -177,6 +179,7 @@ import {
import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
import {
awardRoomConsumable,
consumeRoomConsumable,
getRoomConsumable,
getRoomConsumables,
getRoomInventory,
@@ -4220,6 +4223,65 @@ const app = new Hono<App>({ strict: false })
return c.json(await getRoomInventory(c.env.DB, roomId, id))
}
)
// Use up one of a room consumable the CALLER holds — drinking the Health Potion. Auth-gated
// (401); a refusal is a 200 in the same shape, as the purchase's is.
.put(
'/api/roomconsumables/v1/roomConsumable/:id/consume',
describeRoute({
tags: ['Econ'],
summary: 'Consume a room consumable',
description: [
'Takes ONE of the consumable named in the path from what the caller holds. The body',
'is only `{ CurrentConcurrencyCode, NewConcurrencyCode }`, at the top level; there is',
'no quantity. `NewConcurrencyCode` is stored and served back as the item’s',
'`ConcurrencyCode`; `CurrentConcurrencyCode` is accepted and NOT checked.',
'',
'Answers `{ Status, InventoryItem }` — the purchase’s `OperationResult` — with the',
'`RoomConsumableInventoryItem` the caller now holds: `Count` the RESULTING total, 0',
'when they used their last (after which `…/room/{roomId}/me` omits it).',
'',
'A consumable that does not exist, or one the caller holds none of, is `Status` 1',
'with a null `InventoryItem`, and nothing is written. The refusal form is an',
'ASSUMPTION — only success has been observed.',
].join('\n'),
security: AUTHED,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'The consumable to use — a `room_consumable` id',
schema: { type: 'string' },
},
],
requestBody: jsonBody(ConsumeRoomConsumableRequest, 'The concurrency pair'),
responses: {
200: json(ConsumeRoomConsumableResponse, 'What the caller now holds, or a refusal'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.json<{ NewConcurrencyCode?: unknown }>().catch(() => null)
const newCode =
body !== null && typeof body === 'object' && typeof body.NewConcurrencyCode === 'string'
? body.NewConcurrencyCode
: null
// Every key is always sent: an omitted `Status` reads as its enum's 0, success.
const refused = () => c.json({ Status: ROOM_KEY_STATUS_FAILED, InventoryItem: null })
const consumable = await getRoomConsumable(c.env.DB, c.req.param('id'))
if (!consumable) return refused()
const held = await consumeRoomConsumable(c.env.DB, id, consumable.RoomConsumableId, newCode)
if (!held) return refused()
return c.json({
Status: ROOM_KEY_STATUS_OK,
InventoryItem: toInventoryItem(id, held, consumable),
})
}
)
// The custom currencies a room has minted. Public, like the room itself: a room's
// currencies are shown to every player who walks into it, so the list is not a secret
// the way its ban list is. A room with none — or an unknown room — is an empty list.
+21
View File
@@ -1159,6 +1159,27 @@ export const PurchaseRoomConsumableResponse = z.object({
}),
})
/**
* `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` — just the concurrency pair, at the
* TOP level (not nested under `ConcurrencyCodes` as the purchase sends it). Uses up one.
*/
export const ConsumeRoomConsumableRequest = z.object({
CurrentConcurrencyCode: z.string().nullable().optional().describe('Accepted and NOT checked'),
NewConcurrencyCode: z
.string()
.nullable()
.optional()
.describe('Stored and echoed back as the item’s `ConcurrencyCode`'),
})
/** The consume's answer: the purchase's `OperationResult`, alone. */
export const ConsumeRoomConsumableResponse = z.object({
Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'),
InventoryItem: RoomConsumableInventoryItemDto.nullable().describe(
'What the caller holds afterwards (`Count` the resulting total, 0 included); null on a refusal'
),
})
/**
* The envelope the consumable write answers in — the same
* `{ Value, Success, Error, error_id }` the room-currency writes use.
+37
View File
@@ -13,6 +13,7 @@
* - `POST /api/roomconsumables/v1/roomConsumable/awardBulk` gives one to a player
* - `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens` and `…/purchase/currency`
* sell one, in tokens or the listing's room currency
* - `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` uses up one the caller holds
*
* `PurchaseCurrencyId` is a `room_currency`, so a room prices its goods in its own money. It
* is nullable — `Guid?` in the client's model — for a listing that names no currency, and
@@ -344,6 +345,42 @@ export async function awardRoomConsumable(
}
}
/**
* Use up ONE of a consumable a player holds, returning what they hold afterwards — or null
* when they hold none, and nothing was written. `concurrencyCode` is the request's
* `NewConcurrencyCode`, stored to be served back; null leaves the stored one.
*
* A single conditional UPDATE: the `quantity >= 1` guard is what refuses, so two consumes
* racing over the last one cannot both succeed. Here an empty `RETURNING` is exactly the
* answer wanted — "you had none" — unlike the award, where it would be ambiguous.
*/
export async function consumeRoomConsumable(
db: D1Database,
playerId: number,
consumableId: string,
concurrencyCode: string | null = null
): Promise<RoomInventoryHolding | null> {
const row = await db
.prepare(
`UPDATE room_inventory SET
quantity = quantity - 1,
concurrency_code = COALESCE(?3, concurrency_code),
modified_at = ?4
WHERE player_id = ?1 AND consumable_id = ?2 AND quantity >= 1
RETURNING quantity, concurrency_code, modified_at`
)
.bind(playerId, consumableId, concurrencyCode, new Date().toISOString())
.first<{ quantity: number; concurrency_code: string | null; modified_at: string | null }>()
return row
? {
quantity: row.quantity,
concurrencyCode: row.concurrency_code,
modifiedAt: row.modified_at,
}
: null
}
/**
* Everything a player owns of one room's consumables, in the shop's order. A listing they own
* none of is absent — including one they held and spent down to zero.
@@ -1773,6 +1773,77 @@ describe('econ endpoints', () => {
await env.DB.prepare('DELETE FROM room_balance WHERE currency_id = ?1').bind(coin).run()
})
test('PUT roomConsumable/:id/consume uses up one the caller holds', async () => {
const potion = (
await envOf(await save({ ...body, Name: 'Drinkable potion' }, await bearer('1')))
).Value!
await env.DB.prepare(
'INSERT INTO room_inventory (player_id, consumable_id, quantity) VALUES (7801, ?1, 2)'
)
.bind(potion.RoomConsumableId)
.run()
const consume = async (sub: string | null, consumableId: string, code: string) =>
exports.default.fetch(
`${ORIGIN}/api/roomconsumables/v1/roomConsumable/${consumableId}/consume`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(sub === null ? {} : await bearer(sub)),
},
body: JSON.stringify({
CurrentConcurrencyCode: '872adb91-b163-4f2c-aa8f-4f5d828078cb',
NewConcurrencyCode: code,
}),
}
)
const held = async () =>
(
await env.DB.prepare(
'SELECT quantity FROM room_inventory WHERE player_id = 7801 AND consumable_id = ?1'
)
.bind(potion.RoomConsumableId)
.first<{ quantity: number }>()
)?.quantity
expect((await consume(null, potion.RoomConsumableId, 'a')).status).toBe(401)
const res = await consume('7801', potion.RoomConsumableId, 'code-1')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Status: 0,
InventoryItem: {
RoomConsumableId: potion.RoomConsumableId,
AccountId: 7801,
Count: 1,
ConcurrencyCode: 'code-1',
ModifiedAt: expect.any(String),
Consumable: { ...potion, ImageName: '', IsGiftable: false, Visibility: 0 },
},
})
// The last one answers Count 0; after that there is nothing to use.
const last = (await (await consume('7801', potion.RoomConsumableId, 'code-2')).json()) as {
Status: number
InventoryItem: { Count: number; ConcurrencyCode: string } | null
}
expect(last).toMatchObject({
Status: 0,
InventoryItem: { Count: 0, ConcurrencyCode: 'code-2' },
})
const refusal = { Status: 1, InventoryItem: null }
expect(await (await consume('7801', potion.RoomConsumableId, 'code-3')).json()).toEqual(
refusal
)
expect(await held()).toBe(0)
// Someone who never had one, and an unknown consumable, are refused too.
expect(await (await consume('7802', potion.RoomConsumableId, 'x')).json()).toEqual(refusal)
expect(await (await consume('7801', 'nope', 'x')).json()).toEqual(refusal)
await env.DB.prepare('DELETE FROM room_inventory').run()
})
test('GET roomConsumable/room/:id is public and empty for an unknown room', async () => {
expect(await shopOf(99999)).toEqual([])
const res = await exports.default.fetch(
@@ -8047,6 +8118,7 @@ describe('econ endpoints', () => {
'POST /api/ugcPurchasables/v1/items/bulk',
'PUT /api/equipment/v1/update',
'PUT /api/roomconsumables/v1/roomConsumable',
'PUT /api/roomconsumables/v1/roomConsumable/{id}/consume',
'PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{via}',
'PUT /api/roomkeys/v1/updateAll',
])
@@ -26,6 +26,7 @@ import {
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
import {
banFromReport,
@@ -289,6 +290,9 @@ beforeAll(async () => {
// Platform identity links (owned by the auth worker) — a ban also reaches the
// accounts sharing a proven identity with the banned one.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Custom avatar items (owned by the api worker) — every room read joins the published
// room badges from this table.
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
/**
+7
View File
@@ -22,6 +22,13 @@ Player-settings worker served on the `playersettings` subdomain.
> Emptying the map with DELETE puts the player back to a first read: the next
> `GET` re-seeds the defaults.
> Keys under `Growth.YIR.` (the client's Year-In-Review telemetry — distance
> drawn, time with friends, …) and `MakerPenSessionCount` are **dropped** on
> `PUT`, not stored. The client posts them with a new value every time, so they
> were over half of all writes and nothing reads them. Real maker pen preferences
> (`MakerPen_SnappingMode`, …) are kept. See `IGNORED_SETTING_KEYS` /
> `IGNORED_SETTING_PREFIXES` in `default-settings.ts`.
## KV namespace
```sh
@@ -8,3 +8,21 @@ export const DEFAULT_SETTINGS: Array<{ Key: string; Value: string }> = [
{ Key: 'TUTORIAL_COMPLETE_MASK', Value: '11' },
{ Key: 'FIRST_TIME_IN_FLAGS', Value: '0' },
]
/**
* Settings the server DROPS on write, by exact key and by prefix. The client posts its
* Year-In-Review telemetry (`Growth.YIR.UGC.TubeDistance`, `Growth.YIR.DistanceTraveled`, …)
* and the `MakerPenSessionCount` counter through this endpoint as the player plays, and the
* values change every time, so they can never be deduplicated — measured at ~500 CCU they
* were over half of all settings writes. Nothing server-side reads them and the client works
* without them (a new player has none), so a PUT naming one is answered 200 and nothing is
* stored. Real maker pen preferences (`MakerPen_SnappingMode` and the other `MakerPen_*`
* keys) are kept: the client reads those back.
*/
export const IGNORED_SETTING_KEYS: ReadonlySet<string> = new Set(['MakerPenSessionCount'])
export const IGNORED_SETTING_PREFIXES: readonly string[] = ['Growth.YIR.']
/** Whether a posted setting is one the server drops rather than stores. */
export function isIgnoredSetting(key: string): boolean {
return IGNORED_SETTING_KEYS.has(key) || IGNORED_SETTING_PREFIXES.some((p) => key.startsWith(p))
}
@@ -6,7 +6,7 @@ import { mergePlayerSettings } from '@repo/domain'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { DEFAULT_SETTINGS } from './default-settings'
import { DEFAULT_SETTINGS, isIgnoredSetting } from './default-settings'
import {
AUTHED,
formOrJson,
@@ -196,7 +196,9 @@ const app = new Hono<App>()
'player’s other settings alone. A JSON body is also accepted, as one object or an',
'array, in either `key`/`value` or `Key`/`Value` casing; entries with an empty key are',
'dropped. An unparseable or empty body is a no-op 200, not a 400, and so is a PUT whose',
'values are already stored — nothing is written to KV. Empty body on success.',
'values are already stored — nothing is written to KV. Keys under `Growth.YIR.` (the',
'client’s Year-In-Review telemetry) and `MakerPenSessionCount`, which change on every',
'post and nothing reads, are dropped rather than stored. Empty body on success.',
].join(' '),
security: AUTHED,
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
@@ -209,7 +211,7 @@ const app = new Hono<App>()
const id = await authedId(c)
if (id === null) return unauthorized(c)
const incoming = await parseSettings(c)
const incoming = (await parseSettings(c)).filter((s) => !isIgnoredSetting(s.key))
if (incoming.length === 0) return c.body(null, 200)
const patch: Record<string, string> = {}
@@ -164,6 +164,42 @@ describe('playersettings endpoints', () => {
)
})
it('PUT /playersettings drops Growth.YIR and MakerPenSessionCount without writing', async () => {
const stored = '{ "PlayerSessionCount": "5" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player:11', stored)
const form = await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'Growth.YIR.UGC.TubeDistance', value: '1234.5' }, await bearer('11'))
)
expect(form.status).toBe(200)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'text')).toBe(stored)
// A JSON batch keeps the real settings (a maker pen PREFERENCE included) and drops
// only the telemetry and the session counter.
const json = await SELF.fetch(`${ORIGIN}/playersettings`, {
method: 'PUT',
headers: { 'content-type': 'application/json', ...(await bearer('11')) },
body: JSON.stringify([
{ key: 'Growth.YIR.DistanceTraveled', value: '99' },
{ key: 'MakerPenSessionCount', value: '3' },
{ key: 'MakerPen_SnappingMode', value: '2' },
]),
})
expect(json.status).toBe(200)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'json')).toEqual({
PlayerSessionCount: '5',
MakerPen_SnappingMode: '2',
})
// A player with nothing stored stays unseeded: no key is created for telemetry alone.
await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'Growth.YIR.TimeSpentWithFriends', value: '7' }, await bearer('12'))
)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:12', 'text')).toBeNull()
})
it('PUT /playersettings 200s with no parseable settings', async () => {
const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9')))
expect(res.status).toBe(200)
@@ -0,0 +1,11 @@
-- The searchable rooms by lowercased name, for the website's header search
-- (`GET /rooms/search/name`, searchRoomsByName).
--
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
--
-- PARTIAL over exactly SEARCHABLE_WHERE, so a prefix seek visits only rooms it can serve
-- and the LIMIT stops it: D1 bills the rows returned, not the room count. The existing
-- `idx_rooms_name_lower` holds every dorm and private room, and each one the seek passed
-- would be read to test the filters. Built from the VIRTUAL columns, so no backfill.
CREATE INDEX IF NOT EXISTS idx_room_search_name ON room (name_lower)
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1;
+127 -41
View File
@@ -70,6 +70,7 @@ import {
roomRoles,
saveSubRoomData,
searchRooms,
searchRoomsByName,
setPlayerData,
setRoomDescription,
setRoomImage,
@@ -83,6 +84,7 @@ import {
transferRoomOwnership,
unbanPlayerFromRoom,
updateRoomFields,
withConfirmedRoles,
writeAuditLog,
} from '@repo/domain'
import {
@@ -665,16 +667,14 @@ function parseMaxPlayerCalculationMode(value: unknown): number | undefined {
return Object.values(MAX_PLAYER_CALCULATION_MODES).includes(ordinal) ? ordinal : undefined
}
async function pushRoomUpdate(
c: Context<App>,
playerId: number,
room: Record<string, unknown>
): Promise<void> {
async function pushRoomUpdate(c: Context<App>, playerId: number, room: Room): Promise<void> {
try {
// The pushed room is the client's room, like any served one: pending invites are not
// part of its `Roles` (see `serveRoom`).
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
NotificationType.SubscriptionUpdateRoom,
room
withConfirmedRoles(room)
)
} catch (err) {
logger.error('failed to push RoomUpdate notification', {
@@ -702,7 +702,7 @@ async function pushRoomUpdate(
async function pushRoomUpdateToRoom(
c: Context<App>,
roomId: number,
room: Record<string, unknown>,
room: Room,
alsoNotify: number[] = []
): Promise<void> {
const playerIds = new Set(await getPlayerIdsInRoom(c.env.DB, roomId))
@@ -962,9 +962,15 @@ function pickBakedBundle(
target: number | null
): { filename: string; hash: string | null } | null {
if (!Array.isArray(save.UnitySubAssets)) return null
const rootId = typeof save.UnityAssetId === 'string' ? save.UnityAssetId.toLowerCase() : null
const rows = save.UnitySubAssets.filter(
(item): item is { Target?: unknown; Filename?: unknown; Hash?: unknown } =>
typeof item === 'object' && item !== null
typeof item === 'object' &&
item !== null &&
(rootId === null ||
('UnityAssetId' in item &&
typeof item.UnityAssetId === 'string' &&
item.UnityAssetId.toLowerCase() === rootId))
)
const chosen =
target === null
@@ -1108,16 +1114,44 @@ function toSaveDetail(save: Record<string, unknown>, target: number | null) {
}
}
/** Client envelope for room mutations: `{ success, error, value }` (lowercase). */
function roomEnvelope(c: Context<App>, value: unknown, error = '') {
return c.json({ success: error === '', error, value })
/**
* A room as the client is shown it. The stored room is served whole EXCEPT for its pending
* role invites: a `Roles` entry with `Role` 0 and an `InvitedRole` is an offer the player
* has not answered, and served as-is it put the invited player on the room's member list
* (and, on the 2025 client, in its co-owner list) before they had accepted anything. The
* offer reaches them as a `RoomCoOwnerInvited` message; the entry stays in the STORED room
* for the accept to promote, which is why this is applied where the room leaves the worker
* and never to a room about to be written back.
*
* Every room this worker answers — alone, in a list, in the mutation envelope or pushed as
* a `RoomUpdate` — goes through this (or {@link serveRooms}).
*/
const serveRoom = withConfirmedRoles
/** {@link serveRoom} over a list. */
const serveRooms = (rooms: Room[]): Room[] => rooms.map(serveRoom)
/** {@link serveRoom} over a `{ Results, TotalResults }` page. */
const serveRoomPage = <T extends { Results: Room[] }>(page: T): T => ({
...page,
Results: serveRooms(page.Results),
})
/**
* Client envelope for room mutations: `{ success, error, value }` (lowercase). `value` is
* the updated room (served through {@link serveRoom}) or null on a refusal.
*/
function roomEnvelope(c: Context<App>, value: Room | null, error = '') {
return c.json({ success: error === '', error, value: value && serveRoom(value) })
}
/**
* The same envelope for the ban write, whose `value` is the BAN rather than the room —
* a ban isn't part of the room the client renders, so there is no updated room to send.
*/
const banEnvelope = roomEnvelope
function banEnvelope(c: Context<App>, value: unknown, error = '') {
return c.json({ success: error === '', error, value })
}
/**
* The envelope both leaderboard routes answer: `{ Success, Error, error_id }`, carrying
@@ -1156,7 +1190,7 @@ const experienceEnvelope = leaderboardEnvelope
async function ownedRooms(c: Context<App>) {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
return c.json(await getRoomsByCreator(c.env.DB, accountId))
return c.json(serveRooms(await getRoomsByCreator(c.env.DB, accountId)))
}
/**
@@ -1169,12 +1203,15 @@ async function ownedRoomsExcludingDorm(c: Context<App>) {
const rooms = (await getContributedRooms(c.env.DB, accountId)).filter((room) =>
canManageRoom(room, accountId)
)
return c.json(rooms.filter((r) => r.IsDorm !== true))
return c.json(serveRooms(rooms.filter((r) => r.IsDorm !== true)))
}
/** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */
const DEFAULT_SUGGESTION_COUNT = 10
/** Cap on `/rooms/search/name`'s `take`: a type-ahead list, so the seek stays a few rows. */
const NAME_SEARCH_MAX = 10
const app = new Hono<App>()
.use(
'*',
@@ -1325,10 +1362,10 @@ const app = new Hono<App>()
if (idParam) {
const id = firstId(idParam)
const room = id === undefined ? null : await getRoomById(c.env.DB, id)
return c.json(room ?? (c.req.query('include') === '525581' ? null : {}))
return c.json(room ? serveRoom(room) : c.req.query('include') === '525581' ? null : {})
}
const room = await getRoomByName(c.env.DB, nameParam ?? '')
return c.json(room ?? (c.req.query('include') === '525581' ? null : {}))
return c.json(room ? serveRoom(room) : c.req.query('include') === '525581' ? null : {})
}
)
@@ -1360,7 +1397,7 @@ const app = new Hono<App>()
return c.json({ error: `You can only have ${maxRooms} rooms.` }, 403)
const room = await createStudioRoom(c.env.DB, name, accountId, version)
if (!room) return c.json({ error: 'A room with that name already exists.' }, 409)
return c.json(room)
return c.json(serveRoom(room))
}
)
@@ -1398,7 +1435,39 @@ const app = new Hono<App>()
const query = c.req.query('query') ?? ''
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30
return c.json(await searchRooms(c.env.DB, query, skip, take))
return c.json(serveRoomPage(await searchRooms(c.env.DB, query, skip, take)))
}
)
// The website's header search: searchable rooms whose name STARTS with `query`, A–Z,
// as a bare array. Not the game's search (`/rooms/search` is a substring match that
// reads every searchable room); this is an index seek that reads only the rows it
// returns, which is what lets the site ask on every pause in typing. Un-hydrated rooms.
.get(
'/rooms/search/name',
describeRoute({
tags: ['Discovery'],
summary: 'Prefix-search rooms by name',
description: [
'Searchable rooms (public, non-dorm, not `ExcludeFromSearch`) whose name begins',
'with `query`, case-insensitive, alphabetically — a bare array. An index seek, so',
'it reads only the rooms it returns. Rooms are served without subrooms, tags or',
'badges. The game’s substring search is `/rooms/search`.',
].join(' '),
parameters: [
stringQuery('query', 'Name prefix'),
intQuery('take', `How many rooms to return (default and max ${NAME_SEARCH_MAX})`),
],
responses: { 200: json(RoomDto.array(), 'The matching rooms, A–Z') },
}),
async (c) => {
const take = Number.parseInt(c.req.query('take') ?? '', 10)
const rooms = await searchRoomsByName(
c.env.DB,
c.req.query('query') ?? '',
Number.isNaN(take) ? NAME_SEARCH_MAX : Math.min(take, NAME_SEARCH_MAX)
)
return c.json(serveRooms(rooms))
}
)
@@ -1483,7 +1552,7 @@ const app = new Hono<App>()
const tag = c.req.query('tag') ?? ''
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 getHotRooms(c.env.DB, tag, skip, take))
return c.json(serveRoomPage(await getHotRooms(c.env.DB, tag, skip, take)))
}
)
@@ -1535,7 +1604,7 @@ const app = new Hono<App>()
async (c) => {
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 getTrendingRooms(c.env.DB, skip, take))
return c.json(serveRoomPage(await getTrendingRooms(c.env.DB, skip, take)))
}
)
@@ -1557,7 +1626,7 @@ const app = new Hono<App>()
async (c) => {
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 getBaseRooms(c.env.DB, skip, take))
return c.json(serveRooms(await getBaseRooms(c.env.DB, skip, take)))
}
)
@@ -1585,7 +1654,7 @@ const app = new Hono<App>()
async (c) => {
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 getRecommendedRooms(c.env.DB, skip, take))
return c.json(serveRooms(await getRecommendedRooms(c.env.DB, skip, take)))
}
)
@@ -1680,13 +1749,15 @@ const app = new Hono<App>()
if (ids.length > MAX_BULK_ROOM_IDS) return tooManyIds(c)
const rooms = await getRoomsByIds(c.env.DB, ids)
return c.json(
excludePrivateRooms(c.req.query('excludePrivateRooms'))
? rooms.filter((r) => r.Accessibility === 1)
: rooms
serveRooms(
excludePrivateRooms(c.req.query('excludePrivateRooms'))
? rooms.filter((r) => r.Accessibility === 1)
: rooms
)
)
}
const room = await getRoomByName(c.env.DB, nameParam ?? '')
return c.json(room ? [room] : [])
return c.json(room ? [serveRoom(room)] : [])
}
)
@@ -1738,7 +1809,7 @@ const app = new Hono<App>()
const rooms = await getRoomsByIds(c.env.DB, ids)
const excludePrivate = excludePrivateRooms(field('excludePrivateRooms')[0])
return c.json(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms)
return c.json(serveRooms(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms))
}
)
@@ -1829,7 +1900,7 @@ const app = new Hono<App>()
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
return c.json(await getContributedRooms(c.env.DB, accountId))
return c.json(serveRooms(await getContributedRooms(c.env.DB, accountId)))
}
)
@@ -1886,7 +1957,11 @@ const app = new Hono<App>()
responses: { 200: json(RoomDto.array(), 'That account’s public rooms') },
}),
async (c) =>
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
c.json(
serveRooms(
await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10))
)
)
)
// A player's showcased rooms — the hand-picked rail the client draws on a profile,
@@ -1934,7 +2009,7 @@ const app = new Hono<App>()
if (accountId === null) return unauthorized(c)
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 getFavoritedRooms(c.env.DB, accountId, skip, take))
return c.json(serveRooms(await getFavoritedRooms(c.env.DB, accountId, skip, take)))
}
)
@@ -1958,7 +2033,7 @@ const app = new Hono<App>()
if (accountId === null) return unauthorized(c)
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, accountId, skip, take))
return c.json(serveRooms(await getVisitedRooms(c.env.DB, accountId, skip, take)))
}
)
@@ -2004,9 +2079,11 @@ const app = new Hono<App>()
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(
own
? await getVisitedRooms(c.env.DB, playerId, skip, take)
: await getPublicVisitedRooms(c.env.DB, playerId, skip, take)
serveRooms(
own
? await getVisitedRooms(c.env.DB, playerId, skip, take)
: await getPublicVisitedRooms(c.env.DB, playerId, skip, take)
)
)
}
)
@@ -2617,9 +2694,14 @@ const app = new Hono<App>()
summary: 'A room’s roles',
description: [
'Everyone with a role in the room — its creator plus whoever has been granted Host,',
'Moderator or CoOwner — as whole `RoomRole` records. This is the same array the',
'room DTO serves under `Roles`, on its own endpoint, so it is public exactly like the',
'room is: a role list says who runs a room, which the room page already shows.',
'Moderator or CoOwner — as whole `RoomRole` records, PLUS every pending invite. It is',
'public exactly like the room is: a role list says who runs a room, which the room',
'page already shows.',
'',
'Unlike the room DTO’s `Roles`, this list INCLUDES unanswered co-owner invites (`Role`',
'0, the offer on `InvitedRole`): the client reads it to show the players with an',
'invite pending. The room DTO leaves those entries out, because there an invited',
'player read as a member before they had accepted anything.',
'',
'A bare array, NOT the `{ success, error, value }` envelope the role WRITE answers. An',
'unknown room is an empty list rather than an error — it reads the same as a room',
@@ -4305,7 +4387,7 @@ const app = new Hono<App>()
return c.json({
success: true,
error: null,
value: { room: result.room, subRoomDataSave: toSaveResponse(result.save) },
value: { room: serveRoom(result.room), subRoomDataSave: toSaveResponse(result.save) },
})
}
)
@@ -4856,7 +4938,9 @@ const app = new Hono<App>()
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 getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
serveRoomPage(
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
)
)
}
)
@@ -5124,7 +5208,9 @@ const app = new Hono<App>()
tags: ['Rooms'],
summary: 'A room by id',
description: [
'The room as stored, with its `SubRooms` re-attached. Unlike `GET /rooms?id=`, an',
'The room as stored, with its `SubRooms` re-attached and pending role invites left',
'out of `Roles` (an unanswered invite is `Role` 0 with the offer on `InvitedRole`; the',
'invited player is not a member until they accept). Unlike `GET /rooms?id=`, an',
'unknown room here is a 404, not `{}`. A save that has a Rec Room Studio build',
'lists its main bundles on `CurrentSave.UnitySubAssets` (`Target` 0 Windows, 2',
'Android/Quest, `Filename` downloaded from the CDN at `/unityasset/{filename}`).',
@@ -5147,7 +5233,7 @@ const app = new Hono<App>()
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
if (!room) return c.notFound()
filterRoomUnityAssets(room, unityAssetTargetQuery(c))
return c.json(room)
return c.json(serveRoom(room))
}
)
+301 -6
View File
@@ -9,6 +9,7 @@ import {
import { beforeAll, describe, expect, it } from 'vitest'
import {
answerRoomRoleInvite,
AUDIT_LOG_SCHEMA_DDL,
countRoomsUsingInvention,
createRoomInstance,
@@ -34,6 +35,7 @@ import {
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db'
import { NotificationType } from '../../../../notify/src/notification-types'
@@ -134,6 +136,7 @@ beforeAll(async () => {
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Inventions (owned by the api worker) — a room save writes each one's room count.
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves.
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -538,6 +541,20 @@ describe('rooms endpoints', () => {
Roles: [{ AccountId: 822, Role: 30 }],
})
await seed({ RoomId: 30405, Name: 'ContribNoRoles', CreatorAccountId: 821, SubRooms: [] })
// ...and one where 820 has only been INVITED to co-own — `Role` 0 with the offer on
// `InvitedRole`. They have no job in the room until they accept, so it stays out of
// both of their lists, and the pending entry is not served as part of the room.
await seed({
RoomId: 30408,
Name: 'ContribInvited',
CreatorAccountId: 821,
Accessibility: 1,
SubRooms: [],
Roles: [
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 820, Role: 0, LastChangedByAccountId: 821, InvitedRole: 30 },
],
})
const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
headers: await bearer('820'),
@@ -552,6 +569,46 @@ describe('rooms endpoints', () => {
expect(rooms.some((r) => r.RoomId === 30407)).toBe(false)
expect(rooms[0]).toMatchObject({ Name: expect.any(String), Accessibility: expect.any(Number) })
// The unanswered invite lists the room for NOBODY: not under "contributed" (above),
// not under "owned".
const owned = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('820') })
const ownedIds = ((await owned.json()) as Array<{ RoomId: number }>).map((r) => r.RoomId)
expect(ownedIds).toContain(30401)
expect(ownedIds).not.toContain(30408)
// Nor is the invited player part of the room as served: the room DTO carries only HELD
// roles. `GET /rooms/:id/roles` DOES list the invite — the client shows pending invites
// from it — and the stored room keeps the entry for the accept.
type Served = { Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }> }
const servedRoles = async () =>
((await (await SELF.fetch(`${ORIGIN}/rooms/30408`)).json()) as Served).Roles
expect(await servedRoles()).toEqual([
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
])
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30408/roles`)).json()).toEqual([
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 820, Role: 0, LastChangedByAccountId: 821, InvitedRole: 30 },
])
expect(
await env.DB.prepare(
'SELECT role, invited_role FROM room_role WHERE room_id = 30408 AND account_id = 820'
).first()
).toEqual({ role: 0, invited_role: 30 })
// Accepting is what puts them in: the room appears in both lists and the entry is served.
const invitedRoom = (await getRoomById(env.DB, 30408))!
expect(await answerRoomRoleInvite(env.DB, 30408, 820, 30, invitedRoom)).not.toBeNull()
expect(await contributed('820')).toContain(30408)
const ownedAfter = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
headers: await bearer('820'),
})
expect(((await ownedAfter.json()) as Array<{ RoomId: number }>).map((r) => r.RoomId)).toContain(
30408
)
expect(await servedRoles()).toContainEqual(
expect.objectContaining({ AccountId: 820, Role: 30, InvitedRole: 0 })
)
// A player who contributes to nothing gets an empty array, not a 404.
const none = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
headers: await bearer('829'),
@@ -577,8 +634,8 @@ describe('rooms endpoints', () => {
// The DB is shared across this file, and these are the only player-made public rooms
// in it — leaving them behind changes what the `new`/`community` room feeds serve.
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run()
await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30407').run()
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30408').run()
await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30408').run()
})
it('GET /rooms/:roomId/experience serves the room’s stored XP settings, no auth', async () => {
@@ -624,6 +681,46 @@ describe('rooms endpoints', () => {
expect(body.Results.some((r) => r.Name === 'RecCenter')).toBe(true)
})
it('GET /rooms/search/name prefix-matches searchable rooms A–Z through the partial index', async () => {
const search = async (query: string, extra = '') =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/search/name?query=${encodeURIComponent(query)}${extra}`)
).json()) as Array<{ Name: string; IsDorm?: boolean; Stats: { VisitCount: number } }>
// A bare array of whole rooms, case-insensitive, every name starting with the query, A–Z.
const rec = await search('REC', '&take=10')
expect(rec.some((r) => r.Name === 'RecCenter')).toBe(true)
for (const r of rec) expect(r.Name.toLowerCase().startsWith('rec')).toBe(true)
const names = rec.map((r) => r.Name.toLowerCase())
expect(names).toEqual([...names].sort())
expect(typeof rec[0]?.Stats.VisitCount).toBe('number')
// A prefix, not a substring: "center" is inside RecCenter but doesn't start it.
expect((await search('center')).some((r) => r.Name === 'RecCenter')).toBe(false)
// `take` caps it, and is itself capped at 10; empty and wildcard queries find nothing.
expect((await search('r', '&take=2')).length).toBeLessThanOrEqual(2)
expect(await search('', '&take=500')).toEqual([])
expect(await search('%')).toEqual([])
expect((await search('r', '&take=500')).length).toBeLessThanOrEqual(10)
// Dorms (named `@user's Dorm`) are never served.
expect((await search('@')).every((r) => r.IsDorm !== true)).toBe(true)
// The point of the endpoint: a SEEK on the partial index, so D1 reads only the rows it
// returns. Fall back to a scan (or the unfiltered name index) and every call is billed
// for every room again.
const plan = await env.DB.prepare(
`EXPLAIN QUERY PLAN SELECT data FROM room
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1
AND name_lower >= ?1 AND name_lower < ?2 AND name_lower LIKE ?3 ESCAPE '\\'
ORDER BY name_lower LIMIT 5`
)
.bind('r', 'r\u{10FFFF}', 'r%')
.all<{ detail: string }>()
const detail = plan.results.map((r) => r.detail).join('\n')
expect(detail).toContain('SEARCH room USING INDEX idx_room_search_name')
expect(detail).not.toContain('TEMP B-TREE')
})
it('GET /rooms/autocomplete_search suggests names and tags as plain strings', async () => {
const suggest = async (query: string, extra = '') =>
(await (
@@ -1673,6 +1770,18 @@ describe('rooms endpoints', () => {
type RoomEnv = { success: boolean; error: string; value: Record<string, unknown> | null }
const envOf = async (res: Response) => (await res.json()) as RoomEnv
// A role entry as STORED, read off the `room_role` index — pending invites included.
// The served room carries only held roles; `GET /rooms/:id/roles` lists invites too.
type StoredRole = { Role: number; InvitedRole: number; LastChangedByAccountId: number | null }
const storedRoleOf = async (roomId: number, accountId: number) =>
(await env.DB.prepare(
`SELECT role AS Role, invited_role AS InvitedRole,
last_changed_by_account_id AS LastChangedByAccountId
FROM room_role WHERE room_id = ?1 AND account_id = ?2`
)
.bind(roomId, accountId)
.first<StoredRole>()) ?? undefined
// A subroom as the client sees it. There is no GET for a single subroom — the client
// reads them off the room — so tests do the same.
const subRoomOf = async (
@@ -2089,7 +2198,9 @@ describe('rooms endpoints', () => {
success: false,
error: 'You must provide a valid role!',
})
expect(await roleOf(885)).toMatchObject({ InvitedRole: 30 })
expect(await storedRoleOf(2, 885)).toMatchObject({ Role: 0, InvitedRole: 30 })
// The role list still shows the invite as pending.
expect(await roleOf(885)).toMatchObject({ Role: 0, InvitedRole: 30 })
})
it('a revoke pushes a RoomUpdate to the room and to the player who lost the role', async () => {
@@ -2148,9 +2259,13 @@ describe('rooms endpoints', () => {
)
// RecCenter (room 2) is owned by account 1. The owner invites 850 to co-ownership,
// leaving a pending offer and no role yet.
// leaving a pending offer and no role yet. The role list shows the pending invite, but
// the room DTO does not list them: they are not a member until they accept.
expect((await putForm('/rooms/2/roles/850/invite', { role: '30' }, '1')).status).toBe(200)
expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Roles: Role[] }
expect(room2.Roles.some((r) => r.AccountId === 850)).toBe(false)
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/roles/850', { role: '30' })).status).toBe(401)
@@ -2163,7 +2278,7 @@ describe('rooms endpoints', () => {
await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, caller))
).toMatchObject({ success: false, error: 'Co-ownership must be invited, not granted!' })
}
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// Unknown room → failure envelope.
expect(
await envOf(await putForm('/rooms/99999/roles/850', { role: '30' }, '850'))
@@ -2180,7 +2295,7 @@ describe('rooms endpoints', () => {
expect(await envOf(await putForm('/rooms/2/roles/850', { role: '255' }, '850'))).toMatchObject({
success: false,
})
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// A player with no entry in the room at all is the same rejection — probing tells
// them nothing.
@@ -2227,11 +2342,13 @@ describe('rooms endpoints', () => {
// Invited, then declined: the whole entry goes, leaving `Roles` as it was before the
// offer rather than an entry with nothing pending on it.
expect((await putForm('/rooms/2/roles/851/invite', { role: '30' }, '1')).status).toBe(200)
expect(await storedRoleOf(2, 851)).toMatchObject({ Role: 0, InvitedRole: 30 })
expect(await roleOf(851)).toMatchObject({ Role: 0, InvitedRole: 30 })
const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851')
expect(declined.status).toBe(200)
expect(await envOf(declined)).toMatchObject({ success: true, error: '' })
expect(await storedRoleOf(2, 851)).toBeUndefined()
expect(await roleOf(851)).toBeUndefined()
// And the decline can't be replayed either — there is no entry left to answer.
@@ -2246,6 +2363,7 @@ describe('rooms endpoints', () => {
expect(await roleOf(852)).toMatchObject({ Role: 10, InvitedRole: 0 })
expect((await putForm('/rooms/2/roles/852/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/852', { role: '0' }, '852')).status).toBe(200)
expect(await storedRoleOf(2, 852)).toBeUndefined()
expect(await roleOf(852)).toBeUndefined()
})
@@ -2493,12 +2611,23 @@ describe('rooms endpoints', () => {
expect(await res.json()).toEqual({ success: true })
// A new entry, holding no role yet — only the pending offer, stamped with the inviter.
// The role list shows it as a pending invite (the client lists pending invites from
// it); the room DTO's `Roles` does not, until they accept.
expect(await storedRoleOf(2, 187)).toEqual({
Role: 0,
InvitedRole: 30,
LastChangedByAccountId: 1,
})
expect(await rolesOf(2)).toContainEqual({
AccountId: 187,
Role: 0,
InvitedRole: 30,
LastChangedByAccountId: 1,
})
const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Roles: Array<{ AccountId: number }>
}
expect(room2.Roles.some((r) => r.AccountId === 187)).toBe(false)
// One MessageReceived to the INVITED player carrying a type 62 RoomCoOwnerInvited —
// the entry alone is silent, this is what raises the invite on their client.
@@ -6162,6 +6291,7 @@ describe('rooms endpoints', () => {
'GET /rooms/ownedby/{accountId}',
'GET /rooms/recommendations',
'GET /rooms/search',
'GET /rooms/search/name',
'GET /rooms/visitedby/me',
'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}',
@@ -6329,6 +6459,111 @@ describe('studio room bundles', () => {
)
}
it('serves partitioned prefab and shared-material exports as save dependencies', async () => {
await reset()
await storeFiles()
const prefab = '00111111-2222-4333-8444-555555555555'
const shared = '00222222-2222-4333-8444-555555555555'
await env.DB.prepare(
"UPDATE subroom_save SET data=json_set(data,'$.ServerData',json(?1)) WHERE sub_room_id=99011"
)
.bind(
JSON.stringify({
Exports: [
{ e: 'prefab', u: prefab.toUpperCase() },
{ e: 'shared', u: shared },
{ e: 'duplicate', u: prefab },
],
})
)
.run()
for (const id of [prefab, shared]) {
for (const target of [0, 2]) {
for (const kind of ['main', 'stripped'] as const) {
await putUnityAsset(
env.DB,
{
UnityAssetId: id,
Target: target,
Version: 5,
Filename: publicStudioBundleFilename(
9901,
id,
target === 0 ? 'windows' : 'android',
kind
),
Hash: winHash,
},
kind
)
}
}
}
for (const target of [0, 2]) {
const response = await SELF.fetch(`${ORIGIN}/rooms/9901?unityAssetTarget=${target}`)
const room = (await response.json()) as {
SubRooms: Array<{
CurrentSave: {
SubRoomDataSaveId: number
UnitySubAssets: Array<{ UnityAssetId: string; Target: number }>
}
}>
}
const current = room.SubRooms[0]!.CurrentSave
expect(current.UnitySubAssets.map((asset) => asset.UnityAssetId).sort()).toEqual(
[ASSET, prefab, shared].sort()
)
expect(current.UnitySubAssets.every((asset) => asset.Target === target)).toBe(true)
const detailResponse = await SELF.fetch(
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${current.SubRoomDataSaveId}?unityAssetTarget=${target}`,
{ headers: await bearer('1') }
)
expect(await detailResponse.json()).toMatchObject({
UnityAsset: target === 0 ? winName : androidName,
UnitySubAssets: [prefab, shared].map((id) => ({
UnityAssetId: id,
UnityAsset: publicStudioBundleFilename(
9901,
id,
target === 0 ? 'windows' : 'android',
'main'
),
UnityAssetHash: winHash,
})),
})
}
// In-game saves reuse the compiled Studio asset. They must keep the export
// manifest when staging, saving again, and publishing the new scene data.
for (const autoPublish of [false, false, true]) {
const response = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/data`, {
method: 'POST',
headers: { ...(await bearer('1')), 'Content-Type': 'application/json' },
body: JSON.stringify({
UnityAssetId: ASSET.toUpperCase(),
SubRoomData: { Filename: 'in-game-save' },
AutoPublish: autoPublish,
}),
})
const result = (await response.json()) as {
success: boolean
value: { subRoomDataSave: { subRoomDataSaveId: number; unityAsset: string } }
}
expect(result.success).toBe(true)
expect(result.value.subRoomDataSave.unityAsset).toBe(winName)
const detail = await SELF.fetch(
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${result.value.subRoomDataSave.subRoomDataSaveId}?unityAssetTarget=0`,
{ headers: await bearer('1') }
)
expect(await detail.json()).toMatchObject({
UnityAsset: winName,
UnitySubAssets: [prefab, shared].map((UnityAssetId) => ({ UnityAssetId })),
})
}
await env.DB.prepare('DELETE FROM unity_asset WHERE unity_asset_id IN (?1,?2)')
.bind(prefab, shared)
.run()
})
it('loads baked bundles onto the room and serves the unity asset', async () => {
await reset()
@@ -7185,3 +7420,63 @@ describe('Studio source revision permissions', () => {
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' })
})
})
it('omits hydrated Badges when persisting a room settings edit', async () => {
await seedRoomWithSubRooms(env.DB, {
RoomId: 987670,
Name: 'BadgeSerialization',
CreatorAccountId: 987670,
SubRooms: [],
})
await env.DB.prepare(
"UPDATE room SET data=json_set(data,'$.Badges',json(?)) WHERE room_id=987670"
)
.bind(JSON.stringify([{ Name: 'derived badge' }]))
.run()
const response = await SELF.fetch(`${ORIGIN}/rooms/987670/warning`, {
method: 'PUT',
headers: await bearer('987670'),
body: new URLSearchParams({ warningMask: '1' }),
})
expect(response.status).toBe(200)
const stored = await env.DB.prepare('SELECT data FROM room WHERE room_id=987670').first<{
data: string
}>()
expect(JSON.parse(stored!.data)).not.toHaveProperty('Badges')
})
// The badge lookup runs on every room page, served by a partial index over the badge rows
// (api migrations/0035, mirrored in SCHEMA_DDL above). Only a PUBLISHED badge with a built save
// hydrates; a draft stays on its creator's Studio shelf.
it("GET /rooms/:id hydrates the room's published badges", async () => {
await seedRoomWithSubRooms(env.DB, {
RoomId: 987671,
Name: 'BadgeHydration',
CreatorAccountId: 987671,
SubRooms: [],
})
const badge = (id: string, roomId: number, accessibility: number, saves: unknown[]) =>
JSON.stringify({
CustomAvatarItemId: id,
CreatorAccountId: 987671,
Name: id,
OutfitType: 1000,
Accessibility: accessibility,
CurrentSaves: saves,
CustomBadgeMetadata: { RoomId: roomId },
})
const insert = env.DB.prepare('INSERT INTO custom_avatar_item (data) VALUES (?)')
await env.DB.batch([
insert.bind(badge('badge-published', 987671, 1, [{ BodyType: 0 }])),
insert.bind(badge('badge-draft', 987671, 0, [{ BodyType: 0 }])),
insert.bind(badge('badge-unbuilt', 987671, 1, [])),
insert.bind(badge('badge-other-room', 987670, 1, [{ BodyType: 0 }])),
])
// Twice: the second read takes the path that remembers the table exists.
for (let i = 0; i < 2; i++) {
const res = await SELF.fetch(`${ORIGIN}/rooms/987671`)
expect(res.status).toBe(200)
const room = (await res.json()) as { Badges: Array<{ CustomAvatarItemId: string }> }
expect(room.Badges.map((b) => b.CustomAvatarItemId)).toEqual(['badge-published'])
}
})
+3
View File
@@ -56,6 +56,8 @@ const UPLOAD_SUBFOLDER: Record<number, string> = {
8: 'room',
9: 'roomunityasset',
10: 'unityasset',
13: 'avatar',
14: 'avatarunityasset',
}
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
@@ -380,6 +382,7 @@ const app = new Hono<App>()
contentType = image.contentType
}
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
customMetadata: { ownerId: String(id) },
httpMetadata: { contentType },
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
@@ -21,6 +21,27 @@ beforeAll(async () => {
// storage worker's validation accepts it.
const TEST_SECRET = 'test-signing-key'
it('stores avatar source and baked uploads with immutable account ownership metadata', async () => {
for (const [type, prefix] of [
[13, 'avatar'],
[14, 'avatarunityasset'],
] as const) {
const form = new FormData()
form.set('fileType', String(type))
form.set('file', new File(['avatar source'], 'source.bin'))
const r = await SELF.fetch(ORIGIN + '/upload', {
method: 'POST',
headers: { ...(await bearer()), 'User-Agent': 'RRStudio' },
body: form,
})
expect(r.status).toBe(200)
const body = await r.json<{ Filename: string; Hash: string }>()
expect(body.Hash).toBeTruthy()
const object = await env.CDN_ASSETS.head(prefix + '/' + body.Filename)
expect(object?.customMetadata?.ownerId).toBe('42')
}
})
function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = ''
+27 -8
View File
@@ -83,12 +83,16 @@ build ID with a null completion time. Repeated requests for an in-progress save
reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status;
`PUT /cloud-builds/cancel` cancels a queued or running job.
Apply migration `0003_studio_cloud_build_job.sql`. Configure a private
`CLOUD_BUILD_KEY` and run the outbound worker from `recflare/cloud-building` with
the matching credential. Without a key, creation returns an explicit unavailable
response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and Android), and
`CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169. Match installed editor modules to
the enabled targets.
Apply migration `0003_studio_cloud_build_job.sql`. Create the private
`CLOUD_BUILD_KEY` in the shared Secrets Store (the one `JWT_SECRET` lives in — see
`.env.example`) and run the outbound worker from `recflare/cloud-building` with the
same value as its `STUDIO_WORKER_KEY`. The binding must exist for the worker to
deploy; a value of `off` disables cloud building, and creation then returns an
explicit unavailable response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and
Android), and `CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169 — the cloud-building
worker claims only jobs for its own configured template version (171 by default),
so set this to match or jobs queue and are never claimed. Match installed editor
modules to the enabled targets.
The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish`
routes lease jobs to that worker. Only validated UnityFS artifacts for the
@@ -96,5 +100,20 @@ allocated asset IDs and requested platforms are accepted. SDK bundle version 5
is retained. Completion links the submitted save; auto-publish does not replace
a newer draft. Failed, cancelled, or expired jobs never become successful builds.
Claims include a signed, short-lived SDK validation token with no account subject
or roles. Deploy the paired API support: only bulk component-text validation
accepts this credential. It cannot authenticate as an account or publish a room.
or roles. Bulk component-text validation and job-scoped grant reads accept this
credential. It cannot authenticate as an account or publish a room.
## Avatar Studio and consumable validation
Avatar authoring uses per-account Studio grants, source revisions, and the managed
cloud queue. Apply `migrations/0004_avatar_studio.sql` to the shared database before
enabling these routes. The API owns creator saves and explicit publication; the
Studio service queues prefab builds and returns per-platform main/stripped bundles
at version 5 and material bundles at version 6. Image badges publish directly.
Changed body/configuration inputs receive distinct output IDs, and automatic
publication cannot replace a newer revision. Deploy the paired cloud-building
worker for prefab support; restricted validation credentials grant no account role.
The SDK's access override reads the target account's entitlement. Build-validation
credentials can inspect only the current owners of their active job's room; peers
can inspect grants only while they currently manage the same room.
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id);
CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL);
+350 -62
View File
@@ -1,11 +1,18 @@
import { Hono } from 'hono'
import {
AVATAR_STUDIO_SLOTS,
avatarItemAccessBit,
avatarStudioFlags,
avatarStudioItem,
avatarStudioSave,
canManageRoom,
canManageRoomById,
getRoomById,
getSubRoom,
getSubRoomSaveById,
publicStudioBundleFilename,
publishAvatarSave,
studioBundleR2Key,
} from '@repo/domain'
import {
@@ -16,13 +23,28 @@ import {
import { parseMultipart } from './multipart'
import type { App } from './context'
import type { Context } from 'hono'
import type { App, Env } from './context'
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
)`
interface Job {
kind?: 'avatar'
avatarItemId?: string
avatar?: {
itemId: string
outfitType: number
bodyType: number
configuration: string
name: string
createdAt: string
price: number
creatorAccountId: number
canCreateRoomieEquipment: boolean
permittedItemTypes: number[]
}
buildId: string
roomId: number
subRoomId: number
@@ -34,7 +56,8 @@ interface Job {
targets: number[]
manifest: {
Version: number
MainSceneProjectRelativePath: string
MainSceneProjectRelativePath?: string
MainPrefabProjectRelativePath?: string
ExportPartitionVersion: number
Files: Array<{ n: string; p: string; g?: string }>
Exports?: Array<{ e: string; d?: string[] }>
@@ -51,6 +74,7 @@ const ok = (value: unknown) => ({ success: true, error: null, value })
const fail = (error: string) => ({ success: false, error, value: null })
const ID = /^[1-9]\d*$/
const guid = () => crypto.randomUUID()
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
interface Artifact {
field: string
unityAssetId: string
@@ -84,7 +108,6 @@ interface CloudBuildRecord {
unity_asset_id: string
created_by_account_id: number
}
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
function wire(row: CloudBuildRecord) {
return {
cloudBuildId: row.cloud_build_id,
@@ -99,7 +122,21 @@ function wire(row: CloudBuildRecord) {
: null,
}
}
async function privateAuth(request: Request, key: string | undefined) {
/**
* The pull worker's credential, resolved per request from the Secrets Store. Returns ''
* when cloud building is off: the secret holds the literal `off` (the binding has to
* resolve for the worker to deploy, so that is how an operator opts out) or can't be read.
* Every gate on "is cloud building configured" goes through here.
*/
export async function cloudBuildKey(env: Env): Promise<string> {
try {
const key = (await env.CLOUD_BUILD_KEY.get()) ?? ''
return key === 'off' ? '' : key
} catch {
return ''
}
}
async function privateAuth(request: Request, key: string) {
if (!key) return false
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
const got = await crypto.subtle.digest(
@@ -116,7 +153,48 @@ async function pendingBuild(db: D1Database, roomId: number, subRoomId: number, s
.bind(roomId, subRoomId, saveId)
.first<CloudBuildRecord>()
}
async function pendingAvatarBuild(db: D1Database, itemId: string, saveId: number) {
return db
.prepare(
"SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) JOIN studio_cloud_build_job j USING(cloud_build_id) WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing') LIMIT 1"
)
.bind(itemId, saveId)
.first<CloudBuildRecord>()
}
const app = new Hono<App>()
.get('/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}', async (c) => {
const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (id === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, c.req.param('itemId'))
if (!item || item.CreatorAccountId !== id) return c.body(null, 403)
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
const { results } = await c.env.DB.prepare(
'SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) WHERE a.item_id=? ORDER BY b.started_at DESC'
)
.bind(item.CustomAvatarItemId)
.all<CloudBuildRecord>()
return c.json({
results: results.slice(skip, skip + take).map(wire),
totalResults: results.length,
})
})
.post(
'/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}/save/:saveId{[1-9][0-9]*}',
(c) => enqueueAvatar(c, c.req.param('itemId'), Number(c.req.param('saveId')))
)
.post('/cloud-builds/custom-avatar-item/create-for-new-build-version', async (c) => {
const form = await c.req.parseBody()
const itemId = String(form.customAvatarItemId ?? ''),
assetId = String(form.unityAssetId ?? '')
const row = await c.env.DB.prepare(
'SELECT id FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
)
.bind(itemId, assetId)
.first<{ id: number }>()
if (!row) return c.notFound()
return enqueueAvatar(c, itemId, row.id, form)
})
.post('/cloud-builds/create', async (c) => {
const secret = await c.env.JWT_SECRET.get()
const accountId = await validateAndGetAccountId(c.req.raw, secret)
@@ -124,7 +202,7 @@ const app = new Hono<App>()
if (accountId === null) return c.body(null, 401)
if (!roles?.includes('betastudio'))
return c.json(fail('Studio upload authorization is required.'), 403)
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503)
if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
const form = await c.req.parseBody()
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id))))
@@ -272,11 +350,16 @@ const app = new Hono<App>()
const form = await c.req.parseBody()
const id = textField(form.cloudBuildId)
const row = await c.env.DB.prepare(
'SELECT room_id FROM studio_cloud_build WHERE cloud_build_id = ?'
'SELECT room_id,created_by_account_id FROM studio_cloud_build WHERE cloud_build_id = ?'
)
.bind(id)
.first<{ room_id: number }>()
if (!row || !(await canManageRoomById(c.env.DB, row.room_id, accountId)))
.first<{ room_id: number; created_by_account_id: number }>()
if (
!row ||
!(row.room_id === 0
? row.created_by_account_id === accountId
: await canManageRoomById(c.env.DB, row.room_id, accountId))
)
return c.json(fail('No accessible build.'), 404)
await c.env.DB.batch([
c.env.DB.prepare(
@@ -289,7 +372,7 @@ const app = new Hono<App>()
return c.json(ok(null))
})
.use('/internal/cloud-builds/*', async (c, next) => {
if (!(await privateAuth(c.req.raw, c.env.CLOUD_BUILD_KEY))) return c.body(null, 401)
if (!(await privateAuth(c.req.raw, await cloudBuildKey(c.env)))) return c.body(null, 401)
return next()
})
.post('/internal/cloud-builds/claim', async (c) => {
@@ -325,17 +408,13 @@ const app = new Hono<App>()
})
.post('/internal/cloud-builds/:id/heartbeat', async (c) => {
const { leaseToken } = await c.req.json<{ leaseToken: string }>()
const leaseUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString()
const result = await c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
)
.bind(
new Date(Date.now() + 5 * 60 * 1000).toISOString(),
c.req.param('id'),
leaseToken,
new Date().toISOString()
)
.bind(leaseUntil, c.req.param('id'), leaseToken, new Date().toISOString())
.run()
return c.json({ active: result.meta.changes > 0 })
return c.json(result.meta.changes > 0 ? { active: true, leaseUntil } : { active: false })
})
.post('/internal/cloud-builds/:id/finish', async (c) => {
const form = await parseMultipart(c.req.raw)
@@ -359,6 +438,26 @@ const app = new Hono<App>()
])
return c.json(ok(null))
}
if (job.kind === 'avatar') {
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
if (
!item ||
!save ||
item.CreatorAccountId !== job.accountId ||
!((await avatarStudioFlags(c.env.DB, job.accountId)) & avatarItemAccessBit(item.OutfitType))
) {
await c.env.DB.batch([
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='building'"
).bind(id, lease.lease_token),
c.env.DB.prepare(
"UPDATE studio_cloud_build SET completed_at=?,error='Avatar source or authorization was removed.' WHERE cloud_build_id=? AND completed_at=''"
).bind(new Date().toISOString(), id),
])
return c.json(fail('Avatar source or authorization was removed.'), 409)
}
}
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
let entries: unknown
try {
@@ -368,7 +467,10 @@ const app = new Hono<App>()
}
if (!Array.isArray(entries) || !entries.every(isArtifact))
return c.json(fail('Invalid build artifact metadata.'), 400)
if (!entries.length || entries.length > assets.size * job.targets.length * 2)
if (
!entries.length ||
entries.length > assets.size * job.targets.length * (job.kind === 'avatar' ? 3 : 2)
)
return c.json(fail('Missing build artifacts.'), 400)
const seen = new Set<string>()
for (const entry of entries) {
@@ -377,8 +479,10 @@ const app = new Hono<App>()
if (
!assets.has(entry.unityAssetId) ||
!job.targets.includes(entry.target) ||
!['main', 'stripped'].includes(entry.kind) ||
entry.version !== 5 ||
!(job.kind === 'avatar' ? ['main', 'stripped', 'material'] : ['main', 'stripped']).includes(
entry.kind
) ||
entry.version !== (job.kind === 'avatar' && entry.kind === 'material' ? 6 : 5) ||
!(file instanceof File) ||
file.size === 0 ||
seen.has(key)
@@ -390,6 +494,11 @@ const app = new Hono<App>()
for (const target of job.targets)
if (!seen.has(`${asset}:${target}:main`))
return c.json(fail('A required asset bundle is missing.'), 400)
if (
job.kind === 'avatar' &&
job.targets.some((target) => !seen.has(`${job.unityAssetId}:${target}:material`))
)
return c.json(fail('An avatar material bundle is missing.'), 400)
const writes: D1PreparedStatement[] = []
for (const entry of entries) {
const file = form[entry.field] as File
@@ -416,8 +525,11 @@ const app = new Hono<App>()
entry.target === 2 ? 'android' : 'windows',
entry.kind
)
const filename = baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
const key = studioBundleR2Key(filename)
const filename =
job.kind === 'avatar'
? `${entry.target === 2 ? 'quest/' : ''}studio-avatar/${job.avatarItemId}/${entry.unityAssetId}/${lease.lease_token}.${entry.kind}.assetbundle`
: baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
const key = job.kind === 'avatar' ? `avatar/${filename}` : studioBundleR2Key(filename)
await c.env.CDN_ASSETS.put(key, bytes)
keys.push(key)
writes.push(
@@ -439,48 +551,52 @@ const app = new Hono<App>()
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
keys.push(serverDataKey)
// The exact submitted save is linked; a newer draft must never be replaced.
writes.push(
c.env.DB.prepare(
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
).bind(
job.unityAssetId,
JSON.stringify({ Exports: job.exports }),
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
writes.push(
c.env.DB.prepare(
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
)
if (job.autoPublish) {
// Test and update the staging slot in the same statement. A draft saved
// during compilation must not be replaced by this build's older save.
if (job.kind !== 'avatar')
writes.push(
c.env.DB.prepare(
`UPDATE subroom SET current_save_id=?,
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
).bind(
job.saveId,
job.saveId,
job.roomId,
job.subRoomId,
job.saveId,
job.unityAssetId,
JSON.stringify({ Exports: job.exports }),
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
if (job.kind !== 'avatar') {
writes.push(
c.env.DB.prepare(
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
)
if (job.autoPublish) {
// Test and update the staging slot in the same statement. A draft saved
// during compilation must not be replaced by this build's older save.
writes.push(
c.env.DB.prepare(
`UPDATE subroom SET current_save_id=?,
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
).bind(
job.saveId,
job.saveId,
job.roomId,
job.subRoomId,
job.saveId,
job.saveId,
job.subRoomId,
id,
lease.lease_token
)
)
}
}
writes.push(
c.env.DB.prepare(
"UPDATE studio_cloud_build SET completed_at=?,error=NULL WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
@@ -491,20 +607,42 @@ const app = new Hono<App>()
"UPDATE studio_cloud_build_job SET status='completed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
).bind(id, lease.lease_token)
)
// Recheck the lease and target inside the publication transaction. R2
// uploads can outlast a lease, and a room can be deleted while compiling.
// Recheck authority, lease and target inside the final publication transaction.
const publicationTime = new Date().toISOString()
const roomRow =
job.kind !== 'avatar'
? await c.env.DB.prepare('SELECT data FROM room WHERE room_id=?')
.bind(job.roomId)
.first<{ data: string }>()
: null
const authorizedRoom =
roomRow && canManageRoom(JSON.parse(roomRow.data), job.accountId) ? roomRow.data : null
const targetExists =
job.kind === 'avatar'
? `SELECT 1 FROM avatar_studio_save s JOIN custom_avatar_item i ON i.custom_avatar_item_id=s.item_id
JOIN avatar_studio_access a ON a.account_id=json_extract(i.data,'$.CreatorAccountId')
WHERE s.id=? AND s.item_id=? AND s.unity_asset_id=? AND a.account_id=? AND (a.flags & ?)<>0`
: `SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
JOIN room ON room.room_id=r.room_id
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=? AND room.data=?`
const targetBindings =
job.kind === 'avatar'
? [
job.saveId,
job.avatarItemId!,
job.unityAssetId,
job.accountId,
avatarItemAccessBit(job.avatar!.outfitType),
]
: [job.saveId, job.subRoomId, job.roomId, authorizedRoom]
const results = await c.env.DB.batch([
c.env.DB.prepare(
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
AND (lease_until<=? OR NOT EXISTS (
SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
JOIN room ON room.room_id=r.room_id
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=?))`
).bind(id, lease.lease_token, publicationTime, job.saveId, job.subRoomId, job.roomId),
AND (lease_until<=? OR NOT EXISTS (${targetExists}))`
).bind(id, lease.lease_token, publicationTime, ...targetBindings),
c.env.DB.prepare(
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target save removed.'
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target authorization/save removed.'
WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
).bind(publicationTime, id, id),
@@ -512,7 +650,7 @@ const app = new Hono<App>()
])
if (!results[results.length - 1].meta.changes) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
return c.json(fail('Build lease expired or target save removed.'), 409)
return c.json(fail('Build lease expired or target authorization/save removed.'), 409)
}
} catch (error) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
@@ -523,6 +661,156 @@ const app = new Hono<App>()
.run()
throw error
}
if (job.kind === 'avatar') {
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
if (
item &&
save &&
(job.autoPublish || (item.Accessibility === 0 && save.update_current === 1))
) {
await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id, true)
}
return c.json(ok(null))
}
return c.json(ok(null))
})
export default app
async function enqueueAvatar(
c: Context<App>,
itemId: string,
saveId: number,
supplied?: Record<string, unknown>
) {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, itemId),
save = await avatarStudioSave(c.env.DB, itemId, saveId)
if (!item || !save) return c.notFound()
if (item.OutfitType === 1000)
return c.json(fail('Badge images publish directly and do not require a Unity build.'), 400)
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
if (
item.CreatorAccountId !== accountId ||
!roles?.includes('betastudio') ||
!((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(item.OutfitType))
)
return c.body(null, 403)
if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
const pending = await pendingAvatarBuild(c.env.DB, itemId, saveId)
if (pending) return c.json(ok(wire(pending)))
const object = await c.env.CDN_ASSETS.get('avatarunityasset/' + save.source_filename)
if (!object || object.size > 8 * 1024 * 1024)
return c.json(fail('Missing or oversized avatar source manifest.'), 400)
let manifest: Job['manifest']
try {
manifest = await object.json<Job['manifest']>()
} catch {
return c.json(fail('Invalid source manifest JSON.'), 400)
}
const main = manifest?.MainPrefabProjectRelativePath
if (
!manifest ||
manifest.Version !== 3 ||
typeof main !== 'string' ||
!main.startsWith('Assets/') ||
!main.endsWith('.prefab') ||
!Array.isArray(manifest.Files) ||
!manifest.Files.length ||
manifest.Files.length > 100000 ||
!manifest.Files.some((f) => f?.p === main) ||
manifest.Files.some(
(f) =>
!f ||
typeof f.p !== 'string' ||
!/^(Assets|Packages)\//.test(f.p) ||
f.p.includes('\\') ||
f.p.includes(':') ||
f.p
.split('/')
.some(
(p) => p === '..' || p === '.' || p === '' || p.toLowerCase().endsWith('.bundle')
) ||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
!/^[a-z0-9]{40}$/.test(f.n)
) ||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
)
return c.json(fail('Invalid avatar source file manifest.'), 400)
const form = supplied ?? (await c.req.parseBody())
if (form.pipelineVersion !== undefined && String(form.pipelineVersion) !== '2')
return c.json(fail('Unsupported avatar build pipeline.'), 400)
const targets = (c.env.CLOUD_BUILD_TARGETS ?? '0,2').split(',').map(Number)
if (!targets.length || targets.some((t) => t !== 0 && t !== 2))
return c.json(fail('Invalid build targets.'), 503)
const access = await avatarStudioFlags(c.env.DB, accountId)
const permittedItemTypes = AVATAR_STUDIO_SLOTS.filter(
(slot) =>
(access & avatarItemAccessBit(slot)) !== 0 &&
(![104, 203].includes(slot) || roles.includes('developer'))
)
const job: Job = {
kind: 'avatar',
avatarItemId: itemId,
avatar: {
itemId,
outfitType: item.OutfitType,
bodyType: save.body_type,
configuration: save.configuration,
name: String(item.Name ?? ''),
createdAt: save.created_at,
price: Number(item.Price ?? 0),
creatorAccountId: accountId,
canCreateRoomieEquipment: roles.includes('developer'),
permittedItemTypes,
},
buildId: numericId(),
roomId: 0,
subRoomId: 0,
saveId,
accountId,
unityAssetId: save.unity_asset_id,
autoPublish: textField(form.autoPublish).toLowerCase() === 'true',
templateVersion: Number(c.env.CLOUD_BUILD_TEMPLATE_VERSION ?? 169),
targets,
manifest,
room: null,
exports: [],
}
const now = new Date().toISOString()
// As for rooms, reserve the build and its queue/link rows in one transaction.
const created = await c.env.DB.batch([
c.env.DB.prepare(
`INSERT INTO studio_cloud_build(cloud_build_id,room_id,sub_room_id,sub_room_data_save_id,unity_asset_id,created_by_account_id,started_at,completed_at,error)
SELECT ?,0,0,?,?,?,?,'',NULL WHERE NOT EXISTS (
SELECT 1 FROM studio_avatar_build a JOIN studio_cloud_build_job j USING(cloud_build_id)
WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing'))`
).bind(job.buildId, saveId, job.unityAssetId, accountId, now, itemId, saveId),
c.env.DB.prepare(
'INSERT INTO studio_avatar_build(cloud_build_id,item_id,save_id) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
).bind(job.buildId, itemId, saveId, job.buildId),
c.env.DB.prepare(
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,created_at) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
).bind(job.buildId, JSON.stringify(job), now, job.buildId),
])
if (!created[0].meta.changes) {
const existing = await pendingAvatarBuild(c.env.DB, itemId, saveId)
return existing
? c.json(ok(wire(existing)))
: c.json(fail('Build state changed; retry creation.'), 409)
}
return c.json(
ok(
wire({
cloud_build_id: job.buildId,
started_at: now,
completed_at: null,
unity_asset_id: job.unityAssetId,
created_by_account_id: accountId,
error: null,
})
)
)
}
+13 -2
View File
@@ -17,12 +17,23 @@ export type Env = SharedHonoEnv & {
// Shared `recflare-cdn` bucket. Local Studio builds land under
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
CDN_ASSETS: R2Bucket
/** Private pull-worker credential; leave unset to disable cloud build dispatch. */
CLOUD_BUILD_KEY?: string
/**
* Private pull-worker credential, out of the shared Secrets Store (same store as
* JWT_SECRET). Read it through `cloudBuildKey()` in cloud-builds.ts, never `.get()`
* directly: the value `off` (or an unreadable secret) means cloud building is disabled.
*/
CLOUD_BUILD_KEY: SecretsStoreSecret
CLOUD_BUILD_TARGETS?: string
CLOUD_BUILD_TEMPLATE_VERSION?: string
/** R2 key of an operator-published template catalog; unset disables downloads. */
STUDIO_DISTRIBUTION_MANIFEST?: string
/**
* Discord role ids whose holders have Studio upload access without the account's
* `hasStudio` flag (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`; the same var `auth`
* stamps `betastudio` from). The access lookups here rebuild the role from the account
* rather than a token, so they read it through the same `hasStudioAccess`.
*/
STUDIO_ROLLOUT?: string
}
/** Variables can be extended */
+12 -5
View File
@@ -109,22 +109,29 @@ export async function getUserBuildCount(
const end = new Date(start.getTime() + 60 * 60 * 1000)
const row = await db
.prepare(
`SELECT COUNT(*) AS total,
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS failed
`SELECT SUM(CASE WHEN room_id!=0 THEN 1 ELSE 0 END) AS total,
SUM(CASE WHEN room_id!=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN room_id=0 THEN 1 ELSE 0 END) AS avatar_total,
SUM(CASE WHEN room_id=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS avatar_failed
FROM studio_cloud_build
WHERE created_by_account_id = ?1
AND started_at >= ?2 AND started_at < ?3`
)
.bind(accountId, start.toISOString(), end.toISOString())
.first<{ total: number | string; failed: number | string | null }>()
.first<{
total: number | string
failed: number | string | null
avatar_total: number | null
avatar_failed: number | null
}>()
return {
accountId,
startTime: start.toISOString(),
endTime: end.toISOString(),
subRoomTotalBuildCount: Number(row?.total ?? 0),
subRoomFailedBuildCount: Number(row?.failed ?? 0),
avatarItemTotalBuildCount: 0,
avatarItemFailedBuildCount: 0,
avatarItemTotalBuildCount: Number(row?.avatar_total ?? 0),
avatarItemFailedBuildCount: Number(row?.avatar_failed ?? 0),
}
}
+79 -1
View File
@@ -3,13 +3,15 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
canManageRoomById,
getAccount,
getPlayerIdsInRoomSubRoom,
getRoomCoOwnerIds,
getSubRoom,
} from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { studioBuildValidationId, validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { hasStudioAccess } from '../../auth/src/platform-db'
import cloudBuilds from './cloud-builds'
import distribution from './distribution'
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
@@ -30,6 +32,13 @@ function positiveId(raw: string | undefined): number | null {
return Number.isSafeInteger(id) ? id : null
}
/** SQLite datetime() is UTC, but its space-separated output is not a .NET JSON DateTime. */
function accessTimestamp(value: string | undefined, fallback: string): string {
const normalized = value?.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/, '$1T$2Z')
const time = normalized ? Date.parse(normalized) : NaN
return Number.isFinite(time) ? new Date(time).toISOString() : fallback
}
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
const UPLOAD_FIRST =
'Upload the room before building locally. The latest save is where this build is recorded.'
@@ -168,6 +177,75 @@ const app = new Hono<App>()
.get('/', async (c) => {
return c.text('hello, world!')
})
.get('/access/override/:accountId{[1-9][0-9]*}', async (c) => {
const secret = await c.env.JWT_SECRET.get()
const caller = await validateAndGetAccountId(c.req.raw, secret)
const id = Number(c.req.param('accountId'))
if (!Number.isSafeInteger(id)) return c.body(null, 400)
let roles = await validateAndGetRoles(c.req.raw, secret)
if (caller === null) {
const buildId = await studioBuildValidationId(c.req.raw, secret)
if (!buildId) return c.body(null, 401)
const job = await c.env.DB.prepare(
"SELECT payload FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='building' AND lease_until>?"
)
.bind(buildId, new Date().toISOString())
.first<{ payload: string }>()
if (!job) return c.body(null, 403)
const payload = JSON.parse(job.payload) as {
roomId: number
room?: { Roles?: Array<{ AccountId: number; Role: number }> }
}
if (
!payload.roomId ||
!payload.room?.Roles?.some((r) => r.AccountId === id && [30, 255].includes(r.Role))
)
return c.body(null, 403)
if (!(await getRoomCoOwnerIds(c.env.DB, payload.roomId))?.includes(id))
return c.body(null, 403)
const account = await getAccount(c.env.DB, id)
roles = (await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT)) ? ['betastudio'] : []
}
if (
caller !== null &&
caller !== id &&
!roles?.some((r) => r === 'developer' || r === 'moderator')
) {
// The SDK validates a registry against every owner of its room. Allow
// peers to inspect grants only when both currently manage the same room.
const shared = await c.env.DB.prepare(
`SELECT 1 FROM room r WHERE
(r.creator_account_id=?1 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?1 AND json_extract(role.value,'$.Role') IN (30,255))) AND
(r.creator_account_id=?2 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?2 AND json_extract(role.value,'$.Role') IN (30,255))) LIMIT 1`
)
.bind(caller, id)
.first()
if (!shared) return c.body(null, 403)
}
// Inspection authorization above belongs to the caller; this grant belongs to the target.
if (caller !== null && caller !== id)
roles = (await hasStudioAccess(
c.env.DB,
await getAccount(c.env.DB, id),
c.env.STUDIO_ROLLOUT
))
? ['betastudio']
: []
const grant = await c.env.DB.prepare(
'SELECT flags,created_at,modified_at FROM avatar_studio_access WHERE account_id=?'
)
.bind(id)
.first<{ flags: number; created_at: string; modified_at: string }>()
const flags = roles?.includes('betastudio') ? (grant?.flags ?? 0) : 0
const now = new Date().toISOString()
return c.json({
AccountId: id,
StudioAccess: roles?.includes('betastudio') ? 1 : 2,
AvatarStudioAccess: flags,
CreatedAt: accessTimestamp(grant?.created_at, now),
ModifiedAt: accessTimestamp(grant?.modified_at, now),
})
})
// There is no circuit recommendation corpus on a fresh deployment. An empty
// list is the SDK's supported no-recommendations state, not a failed lookup.
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
@@ -0,0 +1,348 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import {
AVATAR_STUDIO_SCHEMA,
avatarSaveDto,
avatarStudioSave,
publishAvatarSave,
ROOM_SCHEMA_DDL,
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { generateToken } from '@repo/jwt'
import { SCHEMA_DDL, toQuestCustomAvatarItem } from '../../../../api/src/custom-avatar-items-db'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const origin = 'https://example.com',
itemId = '11111111-1111-1111-1111-111111111111',
assetId = '22222222-2222-2222-2222-222222222222'
const worker = { Authorization: 'Bearer private-avatar-worker-key' }
async function auth(id = 42, roles = ['betastudio']) {
return {
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
}
}
const manifest = {
Version: 3,
MainPrefabProjectRelativePath: 'Assets/Hat.prefab',
Files: [{ n: 'a'.repeat(40), p: 'Assets/Hat.prefab' }],
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
await adminSecretsStore(env.CLOUD_BUILD_KEY).create('private-avatar-worker-key')
env.CLOUD_BUILD_TARGETS = '0,2'
for (const sql of [
...SCHEMA_DDL,
...ROOM_SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
...STUDIO_CLOUD_BUILD_SCHEMA_DDL,
...UNITY_ASSET_SCHEMA_DDL,
CLOUD_JOB_SCHEMA,
])
await env.DB.prepare(sql).run()
})
beforeEach(async () => {
for (const table of [
'studio_cloud_build_job',
'studio_cloud_build',
'studio_avatar_build',
'unity_asset',
'avatar_studio_save',
'avatar_studio_access',
'custom_avatar_item',
])
await env.DB.prepare('DELETE FROM ' + table).run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
await env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
.bind(
JSON.stringify({
CustomAvatarItemId: itemId,
CreatorAccountId: 42,
OutfitType: 0,
Accessibility: 0,
Name: 'Hat',
CurrentSaves: [],
})
)
.run()
await env.DB.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,update_current,created_at) VALUES (1,?,1,?,'source','hash','thumb','{}',1,'2026-10-18')"
)
.bind(itemId, assetId)
.run()
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
})
async function create(id = 42) {
return SELF.fetch(origin + '/cloud-builds/custom-avatar-item/' + itemId + '/save/1', {
method: 'POST',
headers: await auth(id),
body: new URLSearchParams({ pipelineVersion: '2', autoPublish: 'true' }),
})
}
async function claim() {
const r = await SELF.fetch(origin + '/internal/cloud-builds/claim', {
method: 'POST',
headers: worker,
})
return r.json<{ job: { buildId: string; kind: string; manifest: unknown }; leaseToken: string }>()
}
function artifacts(lease: { leaseToken: string }, materialVersion = 6) {
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
const entries = []
for (const target of [0, 2])
for (const kind of ['main', 'material', 'stripped']) {
const field = `bundle${target}${kind}`
form.set(
field,
new File(['UnityFS\0avatar fixture ' + target + kind], field + '.assetbundle')
)
entries.push({
field,
unityAssetId: assetId,
target,
kind,
version: kind === 'material' ? materialVersion : 5,
})
}
form.set('artifacts', JSON.stringify(entries))
return form
}
it('authorizes per-account access and rejects builds from other owners', async () => {
expect((await SELF.fetch(origin + '/access/override/42')).status).toBe(401)
expect(
(await SELF.fetch(origin + '/access/override/42', { headers: await auth(43) })).status
).toBe(403)
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 1 })
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth(42, []) })).json()
).toMatchObject({ AvatarStudioAccess: 0 })
expect((await create(43)).status).toBe(403)
})
it('serializes SQLite and ISO access timestamps as SDK-compatible UTC DateTimes', async () => {
await env.DB.prepare(
'UPDATE avatar_studio_access SET flags=15,created_at=?,modified_at=? WHERE account_id=42'
)
.bind('2026-10-19T00:45:00.372Z', '2026-10-19 16:44:56')
.run()
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
).toMatchObject({
AvatarStudioAccess: 15,
CreatedAt: '2026-10-19T00:45:00.372Z',
ModifiedAt: '2026-10-19T16:44:56.000Z',
})
})
it('builds and publishes prefab revisions with real SDK main/material version distinctions', async () => {
expect((await create()).status).toBe(200)
const lease = await claim()
expect(lease.job.kind).toBe('avatar')
expect(lease.job.manifest).toMatchObject(manifest)
const finish = () => origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish'
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease, 5) }))
.status
).toBe(400)
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
).toBe(200)
const row = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
const item = JSON.parse(row!.data)
expect(item.CurrentSaves).toHaveLength(1)
expect(item.CurrentSaves[0]).toMatchObject({
CustomAvatarItemSaveId: 1,
BodyType: 1,
OutfitType: 0,
})
expect(item.CurrentSaves[0].UnityAsset).toMatch(/^studio-avatar\//)
expect(item.CurrentSaves[0].UnityAsset2).toContain('.material.assetbundle')
expect(item.CurrentSaves[0]).not.toHaveProperty('SourceUnityAssetFilename')
const saved = await avatarStudioSave(env.DB, itemId, 1),
dto = await avatarSaveDto(env.DB, saved!)
expect(dto.BakedAssets).toHaveLength(4)
expect(dto.BakedAssets.map((a) => a.Version).sort()).toEqual([5, 5, 6, 6])
const quest = toQuestCustomAvatarItem(item).CurrentSaves[0]
expect(quest.UnityAssetHash).toBe(
dto.BakedAssets.find((a) => a.Target === 2 && a.Version === 5)?.Hash
)
expect(quest.UnityAssetHash).not.toBe(item.CurrentSaves[0].UnityAssetHash)
expect((await env.CDN_ASSETS.get('avatar/' + dto.UnityAsset))?.size).toBeGreaterThan(8)
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
).toBe(409)
const count = await SELF.fetch(origin + '/cloud-builds/get-build-count-for-user', {
headers: await auth(),
})
expect(await count.json()).toMatchObject({
value: { subRoomTotalBuildCount: 0, avatarItemTotalBuildCount: 1 },
})
})
it('rejects source code paths and cannot cancel another account’s build', async () => {
await env.CDN_ASSETS.put(
'avatarunityasset/source',
JSON.stringify({ ...manifest, Files: [null] })
)
expect((await create()).status).toBe(400)
await env.CDN_ASSETS.put(
'avatarunityasset/source',
JSON.stringify({
...manifest,
Files: [...manifest.Files, { n: 'a'.repeat(40), p: 'Assets/Code.cs' }],
})
)
expect((await create()).status).toBe(400)
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
await create()
const lease = await claim()
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(401)
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
headers: await auth(43),
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(404)
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
headers: await auth(),
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(200)
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(lease),
})
).status
).toBe(409)
})
it('does not publish a deleted item or an older revision after a newer save', async () => {
await create()
const lease = await claim()
await env.DB.prepare(
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1'
).run()
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(lease),
})
).status
).toBe(200)
const item = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
expect(JSON.parse(item!.data).CurrentSaves).toEqual([])
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
await env.DB.prepare('DELETE FROM studio_cloud_build').run()
await env.DB.prepare('DELETE FROM studio_avatar_build').run()
await create()
const deleted = await claim()
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + deleted.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(deleted),
})
).status
).toBe(409)
})
it('deduplicates concurrent avatar requests and allows a new build after failure', async () => {
const responses = await Promise.all([create(), create(), create()])
const ids = await Promise.all(
responses.map(async (response) => {
expect(response.status).toBe(200)
return (await response.json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
})
)
expect(new Set(ids).size).toBe(1)
for (const table of ['studio_cloud_build', 'studio_avatar_build', 'studio_cloud_build_job'])
expect(await env.DB.prepare('SELECT COUNT(*) AS n FROM ' + table).first()).toEqual({ n: 1 })
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='failed'").run()
expect(
(await (await create()).json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
).not.toBe(ids[0])
})
it.each([false, true])(
'checks automatic avatar revision ordering in the write, including CAS retry=%s, preserving explicit restore',
async (retry) => {
await env.DB.prepare("INSERT INTO unity_asset VALUES (?,0,5,'main','old.bundle','hash')")
.bind(assetId)
.run()
let intercepted = false
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'prepare')
return (sql: string) => {
const wrap = (statement: D1PreparedStatement): D1PreparedStatement =>
new Proxy(statement, {
get(stmt, method) {
if (method === 'bind') return (...values: unknown[]) => wrap(stmt.bind(...values))
if (method === 'run' && sql.startsWith('UPDATE custom_avatar_item'))
return async () => {
if (!intercepted) {
intercepted = true
await target
.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT 2,item_id,body_type,'new-identity',source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1"
)
.run()
if (retry)
await target
.prepare(
"UPDATE custom_avatar_item SET data=json_set(data,'$.Name','Concurrent metadata')"
)
.run()
}
return stmt.run()
}
const value = Reflect.get(stmt, method)
return typeof value === 'function' ? value.bind(stmt) : value
},
})
return wrap(target.prepare(sql))
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
expect(await publishAvatarSave(DB, itemId, 1, true)).toBe(false)
expect(intercepted).toBe(true)
let item = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
)
expect(item.CurrentSaves).toEqual([])
expect(await publishAvatarSave(env.DB, itemId, 1)).toBe(true)
item = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
)
expect(item.CurrentSaves[0].CustomAvatarItemSaveId).toBe(1)
if (retry) expect(item.Name).toBe('Concurrent metadata')
}
)
@@ -8,7 +8,7 @@ import {
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import cloudBuilds, { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
@@ -49,7 +49,7 @@ async function auth(id = 42, studio = true) {
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
env.CLOUD_BUILD_KEY = key
await adminSecretsStore(env.CLOUD_BUILD_KEY).create(key)
env.CLOUD_BUILD_TARGETS = '0'
for (const sql of [
...ROOM_SCHEMA_DDL,
@@ -468,3 +468,138 @@ it('does not complete a build whose submitted save was deleted during compilatio
.run()
}
})
function builtArtifacts(lease: Awaited<ReturnType<typeof claim>>) {
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('bundle', new File(['UnityFS\0regression'], 'bundle.assetbundle'))
form.set(
'artifacts',
JSON.stringify([
{
field: 'bundle',
unityAssetId: lease.job.unityAssetId,
target: 0,
kind: 'main',
version: 5,
},
])
)
return form
}
function finishBuild(
lease: Awaited<ReturnType<typeof claim>>,
form = builtArtifacts(lease),
bindings = env
) {
return cloudBuilds.request(
origin + `/internal/cloud-builds/${lease.job.buildId}/finish`,
{ method: 'POST', headers: worker, body: form },
bindings
)
}
it.each(['before finish', 'before transaction'])(
'rejects removed room co-owner authority %s without linking or publishing',
async (timing) => {
const prior = await env.DB.prepare('SELECT data FROM room WHERE room_id=3000').first<{
data: string
}>()
const originalSave = await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first<{ data: string }>()
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
.bind(JSON.stringify([{ AccountId: 43, Role: 30 }]))
.run()
await create(43, true, { autoPublish: 'true' })
const lease = await claim()
const revoke = () =>
env.DB.prepare(
"UPDATE room SET data=json_set(data,'$.Roles',json('[]')) WHERE room_id=3000"
).run()
if (timing === 'before finish') await revoke()
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'batch')
return async (statements: D1PreparedStatement[]) => {
await revoke()
return target.batch(statements)
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
try {
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
expect(
await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first()
).toEqual(originalSave)
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
expect(
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
.bind(lease.job.buildId)
.first()
).toEqual({ status: 'failed' })
expect(
await env.CDN_ASSETS.get(
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
)
).toBeNull()
expect(
(await env.CDN_ASSETS.list({ prefix: `room/studio/3000/${lease.job.unityAssetId}` }))
.objects
).toEqual([])
} finally {
await env.DB.prepare('UPDATE room SET data=? WHERE room_id=3000').bind(prior!.data).run()
}
}
)
it.each(['expired lease', 'deleted save'])(
'cleans uploaded artifacts when final publication finds %s',
async (condition) => {
await create()
const lease = await claim()
const save = await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first<{ data: string }>()
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'batch')
return async (statements: D1PreparedStatement[]) => {
if (condition === 'expired lease')
await target
.prepare("UPDATE studio_cloud_build_job SET lease_until='2000-01-01'")
.run()
else
await target
.prepare('DELETE FROM subroom_save WHERE sub_room_data_save_id=3002')
.run()
return target.batch(statements)
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
try {
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
expect(
await env.CDN_ASSETS.get(
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
)
).toBeNull()
} finally {
if (condition === 'deleted save')
await env.DB.prepare('INSERT INTO subroom_save VALUES (3002,3001,?)').bind(save!.data).run()
}
}
)
@@ -0,0 +1,108 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import { SCHEMA_DDL as ACCOUNT_SCHEMA, AVATAR_STUDIO_SCHEMA, ROOM_SCHEMA_DDL } from '@repo/domain'
import { generateStudioBuildValidationToken, generateToken } from '@repo/jwt'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const base = 'https://example.com/access/override/'
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
for (const sql of [
...ACCOUNT_SCHEMA,
...ROOM_SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
CLOUD_JOB_SCHEMA,
])
await env.DB.prepare(sql).run()
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
.bind(JSON.stringify({ accountId: 42, username: 'Fixture', hasStudio: true }))
.run()
await env.DB.prepare('INSERT INTO room(data) VALUES (?)')
.bind(
JSON.stringify({ RoomId: 3000, CreatorAccountId: 42, Roles: [{ AccountId: 42, Role: 255 }] })
)
.run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,8,'now','now')").run()
})
beforeEach(async () => {
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
await env.DB.prepare(
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,status,lease_until,created_at) VALUES (?,?,?,?,?)'
)
.bind(
'123',
JSON.stringify({ roomId: 3000, room: { Roles: [{ AccountId: 42, Role: 255 }] } }),
'building',
new Date(Date.now() + 60000).toISOString(),
'now'
)
.run()
})
async function request(id: number, build = '123') {
return SELF.fetch(base + id, {
headers: {
Authorization: 'Bearer ' + (await generateStudioBuildValidationToken(build, 'test-key')),
},
})
}
it('allows read-only consumable validation only for an active job’s room owners', async () => {
expect(await (await request(42)).json()).toMatchObject({
AccountId: 42,
StudioAccess: 1,
AvatarStudioAccess: 8,
})
expect((await request(43)).status).toBe(403)
expect((await request(42, '124')).status).toBe(403)
expect((await SELF.fetch(base + '42')).status).toBe(401)
})
it('rejects cancelled and expired jobs', async () => {
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='cancelled'").run()
expect((await request(42)).status).toBe(403)
await env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='building',lease_until='2000-01-01'"
).run()
expect((await request(42)).status).toBe(403)
})
it('lets co-owners validate each other’s grants without exposing unrelated accounts', async () => {
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
.bind(
JSON.stringify([
{ AccountId: 42, Role: 255 },
{ AccountId: 43, Role: 30 },
])
)
.run()
const headers = {
Authorization:
'Bearer ' + (await generateToken('43', 'fixture', 0, 'test-key', ['betastudio'])),
}
expect(await (await SELF.fetch(base + '42', { headers })).json()).toMatchObject({
AvatarStudioAccess: 8,
})
expect((await SELF.fetch(base + '44', { headers })).status).toBe(403)
})
it('uses target entitlement for staff inspections in both directions', async () => {
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
.bind(JSON.stringify({ accountId: 44, username: 'Disabled', hasStudio: false }))
.run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (44,15,'now','now')").run()
const staff = async (roles: string[]) => ({
Authorization: 'Bearer ' + (await generateToken('99', 'staff', 0, 'test-key', roles)),
})
expect(
await (
await SELF.fetch(base + '44', { headers: await staff(['developer', 'betastudio']) })
).json()
).toMatchObject({ AccountId: 44, StudioAccess: 2, AvatarStudioAccess: 0 })
expect(
await (await SELF.fetch(base + '42', { headers: await staff(['moderator']) })).json()
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 8 })
})
+14
View File
@@ -37,6 +37,20 @@
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
},
// The credential the cloud-building pull worker (recflare/cloud-building, its
// STUDIO_WORKER_KEY) presents as a bearer token on /internal/cloud-builds/*. A real
// secret, so it lives in the same store rather than riding along as a `--var` from
// .env, which would put it in plaintext on every worker. The binding must resolve
// for this worker to deploy: create it with the value `off` to run without cloud
// builds (creation then answers 503 and the private routes refuse everyone).
//
// wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
// --scopes workers --remote
{
"binding": "CLOUD_BUILD_KEY",
"store_id": "local",
"secret_name": "CLOUD_BUILD_KEY"
}
],
"upload_source_maps": true,
+51 -5
View File
@@ -154,6 +154,23 @@ box. The grant runs after Plus is granted and the link written, and a failure in
logged rather than failing the claim. Unset, the var awards nothing and the claim is as
before.
**Unlinking.** The same panel offers **Unlink Discord** whenever any Discord is on the
account — a claim's link or the bare one "Sign in with Discord" leaves — and
`POST /api/benefits/unlink` removes every `PlatformType.Discord` link the account holds.
It is refused (403) until the account has an **email address**: an account made from
Discord has none until the player sets one, the link may be its only way in, and an address
is what the account can be recovered through (a password is not required — a passwordless
account sets one in game). The row decides, not the page; `/api/benefits/status` serves the
same flag (`hasEmail`, beside `discordLinked`) so the panel can say so first. Afterwards that
Discord opens no account — signing in with it makes a new one — and the once-only guard lets
it claim elsewhere. **The grants go with the link**: `hasPlus` and `hasStudio` are both
cleared, whoever set them (an operator's grant too — nothing on the row says which wrote
it), and the panel warns before the confirm; both reach the game on the next token. What the
account does keep is that it claimed: removing a link with roles recorded stamps
`hasClaimedDiscord` on the account, and the claim's "first link" check consults it, or
link → unlink → link would pay the one-time supporter gift on every cycle. The route is not gated on the Discord
configuration — the link is in the table whether or not the claim is still open.
The link also records the member's **roles** (`platform_account.role`, a JSON array of
role ids). The claim writes them from the member record it read; on their own they'd be
a snapshot from claim time, because the claim revokes the player's token the moment it's
@@ -220,11 +237,40 @@ won't pick up newly created secrets until it restarts.
which one is missing. `wrangler tail www` shows it.
In the [Discord developer portal](https://discord.com/developers/applications),
add `https://<your domain>/claim` to the app's **Redirects**. It has to match byte
for byte: `www` derives the redirect URI from the incoming request's own origin
(never from the request body, which would turn the client secret into a redemption
oracle for someone else's app), so add `http://localhost:5173/claim` too if you
want the flow to work under `pnpm turbo dev`.
add `https://<your domain>/claim` **and** `https://<your domain>/login/discord` to the
app's **Redirects**. Each has to match byte for byte: `www` derives the redirect URI
from the incoming request's own origin (never from the request body, which would turn
the client secret into a redemption oracle for someone else's app), so add the
`http://localhost:5173/…` pair too if you want the flows to work under `pnpm turbo dev`.
### Sign in with Discord
The same app also serves the login page's **Sign in with Discord** button, on the same
switch (`/api/config` serves `discordLoginUrl` beside `discordAuthorizeUrl`, both null
while the claim is closed). It asks Discord for the `identify` scope only and returns to
`/login/discord`, where the SPA posts the code to `POST /api/login/discord`. `www`
exchanges it, reads `GET /users/@me` for the Discord user id, revokes the token, and
mints an **identity assertion** — a one-minute HS256 token signed with the shared
`JWT_SECRET` naming platform `101` and that id — which it posts to `auth`'s
`identity_assertion` grant over the service binding. `auth` resolves the id through
`platform_account`:
- **One linked account** (a player who claimed benefits on it) — signed in; the ordinary
token body comes back and the SPA stores it like a password sign-in's.
- **No linked account** — `auth` **creates** one and links the Discord to it, under the
ban-evasion check and the per-IP / per-identity signup caps (but not `PASSWORD_SIGNUP`:
that switch exists because a password account has no identity but its address). The
body carries `created: true`, and the SPA stops to say so: the account has **no
password**, and setting one on the account page is what lets it sign in to the game —
on a sideloaded Quest without a Meta account, which is the case this is for. Its first
benefits claim still pays the welcome gift: the claim keys "first" on roles being
recorded on a Discord link, not on the link existing.
- **Several linked accounts** — refused; the claim never produces this.
`www` never chooses the account id: the assertion names an identity, and which account
that is (or that there is none) is `auth`'s decision from the same table `cached_login`
reads. The Discord snowflake stays off the new account's blob and token, as it does
after a claim.
### Discord role refresh (cron)
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RecFlare — play like it&apos;s 2025</title>
<title>RecFlare</title>
<meta
name="description"
content="RecFlare is a free, open source fan rebuild of the RecNet servers. Download for PC, join the Discord, and play."
+8
View File
@@ -40,6 +40,14 @@ const AUTH_MESSAGES: Record<string, string> = {
'That platform cannot be verified — only Steam and Meta are supported.',
'no linked account for this platform identity':
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
// The website's Discord sign-in found the Discord user on several accounts — a state the
// benefits claim never produces, so this is for an operator to untangle, not the player.
'platform identity is linked to more than one account':
'That Discord account is linked to more than one RecFlare account. Sign in with your username and password instead.',
// www minted the assertion a moment ago, so this is a key mismatch between www and auth
// (or a clock a minute out), never something the player did.
'invalid or expired identity assertion':
'Sign-in is unavailable right now. This is a problem on our end — please try again later.',
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
// An account that shares a device or network with a BANNED one. Phrased for BOTH the
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
+690 -108
View File
@@ -16,6 +16,7 @@ import { downgradeRoom, needsDowngrade } from '../room-converter'
// The session token, the worker hostnames and the `call` every request goes through —
// see api.ts for why they're a module of their own rather than defined here.
import {
accountByUsername,
accountIdForUsername,
call,
hasToken,
@@ -30,11 +31,12 @@ import {
import { DeviceLinkPage } from './DeviceLink'
import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item'
import { ModerationPage, PlayerBanStatus } from './Moderation'
import { PumpleBossFight } from './PumpleBossFight'
import { StatsPage } from './Stats'
import { StudioAccessPage } from './StudioAccess'
import type { ReactNode } from 'react'
import type { Hosts } from './api'
import type { Hosts, PublicAccount } from './api'
/**
* Site config from `www`. `signupEnabled` is false when the operator has no Turnstile
@@ -56,6 +58,12 @@ interface SiteConfig {
* benefits are off. The `state` nonce is appended here — see `startDiscordAuth`.
*/
discordAuthorizeUrl: string | null
/**
* The consent URL for "Sign in with Discord" — same app, `identify` scope only, and its own
* return leg (`/login/discord`). On the same switch as the claim, so null whenever
* `benefitsEnabled` is false. See `DiscordLoginPage`.
*/
discordLoginUrl: string | null
}
/** The private self DTO from `accounts` (`GET /account/me`). */
@@ -66,6 +74,8 @@ interface SelfAccount {
email: string | null
/** A key on the `img` worker — `DefaultProfileImage.jpg` until the player sets one. */
profileImage: string
/** The profile banner's key on `img` — `""` until the player sets one. */
bannerImage: string
/**
* Username changes left on the account — each change spends one, and an account
* starts with one. Absent on an older self DTO, which reads as "unknown": the form
@@ -356,6 +366,41 @@ async function setProfileImage(imageName: string): Promise<void> {
if (res.success !== true) throw new Error('The accounts worker refused the image.')
}
/**
* Upload a banner picture through the same `api` route as the avatar, but typed
* `SavedImageType.None` (0): the game has no banner type (its banner is one of the
* player's camera photos), and anything but ShareCamera stays out of the photo feed and
* the player's photo list, while ProfileThumbnail would also overwrite the avatar. Filed
* under `none/` in the bucket. Returns the key `img` serves it by.
*/
async function uploadBannerImage(file: File): Promise<string> {
const form = new FormData()
form.set('imgMeta', JSON.stringify({ savedImageType: 0 }))
form.set('image', file)
const { ImageName } = await call<{ ImageName?: string }>(
`${where().api}/api/images/v4/uploadsaved`,
{ method: 'POST', multipart: form, authed: true }
)
if (!ImageName) throw new Error('The api worker accepted the picture but returned no name.')
return ImageName
}
/**
* Point the account's banner at an uploaded picture — `PUT /account/me/bannerimage` on
* `accounts`, the call the game makes when a player picks a banner. Same `{ success }`
* envelope as the avatar's PUT, and it pushes the same `AccountUpdate` frame, so a
* running client redraws the banner too.
*/
async function setBannerImage(imageName: string): Promise<void> {
const res = await call<{ success?: boolean }>(`${where().accounts}/account/me/bannerimage`, {
method: 'PUT',
authed: true,
form: { imageName },
refusal: 'The accounts worker refused the banner.',
})
if (res.success !== true) throw new Error('The accounts worker refused the banner.')
}
/**
* The blob's SHA-256, base64 — the encoding this API's hash fields use (an invention's
* `BlobHash` comes back the same way). `rooms` only echoes it back on the save, but a
@@ -552,6 +597,16 @@ interface BenefitsStatus {
hasPlus: boolean
/** Whether a Discord identity is already tied to it. Which one is deliberately not served. */
linked: boolean
/**
* Whether ANY Discord is on the account — a claim's link or the bare one "Sign in with
* Discord" leaves. What the unlink button keys on; `linked` is only the claimed case.
*/
discordLinked: boolean
/**
* The unlink's one precondition: the account must have an email address, or taking the
* Discord off could leave nothing to recover it by. Served so the panel can say so first.
*/
hasEmail: boolean
}
/**
@@ -582,6 +637,14 @@ interface ClaimResult {
const claimBenefits = (code: string): Promise<ClaimResult> =>
call<ClaimResult>('/api/benefits/claim', { json: { code }, authed: true })
/**
* Take the Discord off the account. `www` refuses (403, with a sentence) unless the account
* has a password and an email — the panel checks the same two flags first so the button
* only shows when it can work. Answers the status as it now stands.
*/
const unlinkDiscord = (): Promise<BenefitsStatus> =>
call<BenefitsStatus>('/api/benefits/unlink', { json: {}, authed: true })
/**
* The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage.
*
@@ -592,21 +655,52 @@ const claimBenefits = (code: string): Promise<ClaimResult> =>
*/
const OAUTH_STATE_KEY = 'rf_discord_state'
/**
* Where to land after a Discord SIGN-IN — the `?next=` the login page was opened with.
* Stashed beside the nonce because the round trip through Discord loses the query string,
* and read back by `DiscordLoginPage` exactly once.
*/
const OAUTH_NEXT_KEY = 'rf_discord_next'
/**
* Send the browser to Discord's consent screen.
*
* A real navigation, not a client-side route — Discord is another origin. The `state` is
* minted here and stashed for the return leg; `www` built everything else about the URL
* (see `/api/config`), so this only ever appends the one parameter it owns.
* (see `/api/config`), so this only ever appends the one parameter it owns. `next` is the
* sign-in's post-login destination; the claim passes none.
*/
function startDiscordAuth(authorizeUrl: string) {
function startDiscordAuth(authorizeUrl: string, next?: string) {
const state = crypto.randomUUID()
sessionStorage.setItem(OAUTH_STATE_KEY, state)
if (next) sessionStorage.setItem(OAUTH_NEXT_KEY, next)
else sessionStorage.removeItem(OAUTH_NEXT_KEY)
const url = new URL(authorizeUrl)
url.searchParams.set('state', state)
window.location.assign(url.toString())
}
/** What `/api/login/discord` answers: auth's token body, plus who signed in and whether it made them. */
interface DiscordSignIn {
access_token?: string
/** True when no account was linked to this Discord and `auth` created one just now. */
created?: boolean
/** The Discord username, for the confirmation line only. */
discordUsername?: string
}
/**
* Redeem the sign-in's authorization code. Goes through `www`, like signup and the claim,
* because the exchange needs the Discord client secret; `www` then has `auth` issue the
* session and relays the token body, which is stored exactly as a password sign-in's is.
*/
async function signInWithDiscord(code: string): Promise<DiscordSignIn> {
const data = await call<DiscordSignIn>('/api/login/discord', { json: { code } })
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login'))
setToken(data.access_token)
return data
}
/**
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
* — so a session without the role is rejected there (403) even though the UI shows no
@@ -739,6 +833,7 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
// and tokens last a day and are never refreshed. Without this line the claim looks like
// it silently did nothing, which is the single most likely support question here.
const [relogin, setRelogin] = useState(false)
const [unlinking, setUnlinking] = useState(false)
// StrictMode runs effects twice in dev, and a Discord code is single-use: the second
// run would redeem a spent code and report a failure over a claim that just worked.
const redeemed = useRef(false)
@@ -779,7 +874,14 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
setPending(true)
claimBenefits(code)
.then((result) => {
setStatus({ hasPlus: result.hasPlus, linked: true })
setStatus((prev) => ({
hasPlus: result.hasPlus,
linked: true,
discordLinked: true,
// The claim says nothing about this; keep what the status read said, and assume
// none until it answers.
hasEmail: prev?.hasEmail ?? false,
}))
const who = result.discordUsername ? ` as ${result.discordUsername}` : ''
// Linked either way; whether Plus came with it depends on the roles. Without a
// qualifying role the roles are still saved for the game, and that is said
@@ -881,6 +983,60 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
Verify with Discord
</button>
)}
{status?.discordLinked && !pending && (
// Taking the Discord off again. Offered for ANY link — a claim's, or the bare one a
// Discord sign-in made — but only once the account can be recovered without it: an
// account made from Discord has no email until the player sets one, and `www`
// refuses the unlink until there is one. The panel says so rather than showing a
// button that would fail.
<div className="unlink">
<h3>Unlink Discord</h3>
{status.hasEmail ? (
<>
<p className="muted">
Removes your Discord from this account. Rec Room Plus and Rec Room Studio access
granted to this account are removed with it, your roles stop being read, and signing
in with that Discord afterwards will make a new account rather than open this one.
You can link again at any time.
</p>
<button
type="button"
className="linkish danger"
disabled={unlinking}
onClick={() => {
if (
!confirm(
'Unlink your Discord from this account? Rec Room Plus and Studio access on this account will be removed.'
)
)
return
setUnlinking(true)
setError('')
setDone('')
unlinkDiscord()
.then((next) => {
setStatus(next)
setRelogin(false)
setDone('Your Discord has been unlinked from this account.')
})
.catch((err: unknown) =>
setError(err instanceof Error ? err.message : String(err))
)
.finally(() => setUnlinking(false))
}}
>
{unlinking ? 'Unlinking…' : 'Unlink Discord'}
</button>
</>
) : (
<p className="hint">
Before you can unlink, add an email address to this account (the{' '}
<strong>Email</strong> tab), so the account can be recovered without Discord.
</p>
)}
</div>
)}
</section>
)
}
@@ -952,16 +1108,6 @@ function roomIdFromPath(path: string): number | null {
return match ? Number.parseInt(match[1], 10) : null
}
/** Public account shape, from `GET /account/:id` or `/account/search`. */
interface PublicAccount {
accountId: number
username: string
displayName: string
profileImage: string
bannerImage: string
createdAt: string
}
/**
* A player's photo, from the `ImagesPlayer` projection. Public unless the list was the
* viewer's own (see fetchPlayerPhotos), which is the one way a private one gets here —
@@ -983,24 +1129,23 @@ interface PublicRoom {
Stats: { VisitCount: number; CheerCount: number; FavoriteCount: number }
}
/** Prefix-search accounts by username — backs the header search bar. */
async function searchPlayers(query: string): Promise<PublicAccount[]> {
/** Prefix-search accounts by username — backs the header search. */
async function searchPlayers(query: string, take: number): Promise<PublicAccount[]> {
if (query.trim() === '') return []
return call<PublicAccount[]>(
`${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}`
`${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}&take=${take}`
)
}
/** The profile page's account: an exact lookup, not the search bar's prefix query. */
const fetchPublicAccount = (username: string): Promise<PublicAccount | null> =>
searchPlayers(username).then(
(matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null
)
accountByUsername(username)
/**
* The photos a player has taken. Sent WITH the session token when there is one: `api`
* reads it and, only when the list is the caller's own, includes the photos they keep
* private — so a signed-in player sees all of theirs, on `/account` and on their own
* `/u/<name>`, while everyone else's profile is the public list either way. Nothing
* `/user/<name>`, while everyone else's profile is the public list either way. Nothing
* here decides who the viewer is; the worker does, from the token.
*/
const fetchPlayerPhotos = (accountId: number): Promise<PublicPhoto[]> =>
@@ -1073,91 +1218,199 @@ async function setRoomExcluded(roomId: number, excluded: boolean): Promise<Owned
return result.value
}
/** The `/u/<username>` path, or null for any other path. */
/** The `/user/<username>` path (or the old `/u/<username>`), or null for any other path. */
function usernameFromPath(path: string): string | null {
const match = /^\/u\/([^/]+)$/.exec(path)
const match = /^\/(?:user|u)\/([^/]+)$/.exec(path)
return match ? decodeURIComponent(match[1]) : null
}
// The nav's player search. `/u/:name` profiles stay reachable by URL either way; this only
// decides whether the search bar that leads to them is shown.
const SHOW_PLAYER_SEARCH: boolean = true
// The nav's search. `/user/:name` and `/rooms/:id` stay reachable by URL either way; this
// only decides whether the search button that leads to them is shown.
const SHOW_SEARCH: boolean = true
/** How many of each kind the search panel lists. */
const SEARCH_TAKE = 5
/**
* The header search bar — a rec.net-style bubble dropdown of matching players as you
* type. Debounced so it doesn't fire a search per keystroke; closes on selecting a
* result, on Escape, or on clicking outside it.
* Rooms whose name starts with `query`. The prefix endpoint, not the game's `/rooms/search`:
* that one's substring match reads every public room per call, which a search fired on
* every pause in typing can't afford; this one is an index seek billed per row returned.
*/
function PlayerSearch({ navigate }: { navigate: Navigate }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<PublicAccount[]>([])
const searchRooms = (query: string, take: number): Promise<PublicRoom[]> =>
query.trim() === ''
? Promise.resolve([])
: call<PublicRoom[]>(
`${where().rooms}/rooms/search/name?query=${encodeURIComponent(query.trim())}&take=${take}`
)
function SearchIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" strokeWidth="2.2" />
<path
d="M16 16l4.5 4.5"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
/>
</svg>
)
}
/**
* The header search: a magnifier button that opens a panel searching players and rooms
* together as you type. A button rather than an always-on bar so it fits a phone's nav.
* Debounced so it doesn't fire per keystroke; closes on picking a result, on Escape, or
* on a tap outside it. Enter takes the first player, else the first room.
*/
function SiteSearch({ navigate }: { navigate: Navigate }) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [players, setPlayers] = useState<PublicAccount[]>([])
const [rooms, setRooms] = useState<PublicRoom[]>([])
// The query the results on screen answer, so "nothing found" waits for the answer.
const [answered, setAnswered] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Answers by normalized query, for the panel's lifetime on the page: backspacing to a
// query already asked redraws it without another request (and another D1 read).
const cache = useRef(new Map<string, [PublicAccount[], PublicRoom[]]>())
useEffect(() => {
if (query.trim() === '') {
setResults([])
const key = query.trim().toLowerCase()
const show = ([p, r]: [PublicAccount[], PublicRoom[]]) => {
setPlayers(p.slice(0, SEARCH_TAKE))
setRooms(r.slice(0, SEARCH_TAKE))
setAnswered(query)
}
if (key === '') {
setPlayers([])
setRooms([])
setAnswered('')
return
}
const known = cache.current.get(key)
if (known) {
show(known)
return
}
let stale = false
const timeout = setTimeout(() => {
void searchPlayers(query)
.then((r) => setResults(r.slice(0, 6)))
.catch(() => setResults([]))
void Promise.all([
searchPlayers(key, SEARCH_TAKE).catch(() => null),
searchRooms(key, SEARCH_TAKE).catch(() => null),
]).then(([p, r]) => {
const found: [PublicAccount[], PublicRoom[]] = [p ?? [], r ?? []]
// A failed half isn't cached, so the next time this is typed it asks again.
if (p !== null && r !== null) cache.current.set(key, found)
if (!stale) show(found)
})
}, 250)
return () => clearTimeout(timeout)
return () => {
stale = true
clearTimeout(timeout)
}
}, [query])
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (!open) return
inputRef.current?.focus()
const onPointerDown = (e: PointerEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [])
document.addEventListener('pointerdown', onPointerDown)
return () => document.removeEventListener('pointerdown', onPointerDown)
}, [open])
const go = (username: string) => {
const go = (to: string) => {
setOpen(false)
setQuery('')
navigate(`/u/${encodeURIComponent(username)}`)
navigate(to)
}
const playerPath = (r: PublicAccount) => `/user/${encodeURIComponent(r.username)}`
const roomPath = (r: PublicRoom) => `/rooms/${r.RoomId}`
const nothing = query.trim() !== '' && answered === query && players.length + rooms.length === 0
return (
<div className="player-search" ref={containerRef}>
<input
type="text"
placeholder="Search players…"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Escape') setOpen(false)
if (e.key === 'Enter' && results[0]) go(results[0].username)
}}
/>
{open && results.length > 0 && (
<div className="player-search-bubble">
{results.map((r) => (
<button
key={r.accountId}
className="player-search-result"
onClick={() => go(r.username)}
>
<img
className="player-search-avatar"
src={`${where().img}/${r.profileImage}?width=64`}
alt=""
/>
<span>
<span className="player-search-name">{r.displayName || r.username}</span>
<span className="player-search-handle">@{r.username}</span>
</span>
</button>
))}
<div className="site-search" ref={containerRef}>
<button
type="button"
className={`site-search-toggle${open ? ' active' : ''}`}
aria-label="Search players and rooms"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<SearchIcon />
</button>
{open && (
<div className="site-search-panel">
<input
ref={inputRef}
type="search"
placeholder="Search players and rooms…"
aria-label="Search players and rooms"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setOpen(false)
if (e.key === 'Enter') {
if (players[0]) go(playerPath(players[0]))
else if (rooms[0]) go(roomPath(rooms[0]))
}
}}
/>
{players.length > 0 && (
<div className="site-search-group">
<p className="site-search-heading">Players</p>
{players.map((r) => (
<button
key={r.accountId}
type="button"
className="site-search-result"
onClick={() => go(playerPath(r))}
>
<img
className="site-search-avatar"
src={`${where().img}/${r.profileImage}?width=64`}
alt=""
/>
<span>
<span className="site-search-name">{r.displayName || r.username}</span>
<span className="site-search-sub">@{r.username}</span>
</span>
</button>
))}
</div>
)}
{rooms.length > 0 && (
<div className="site-search-group">
<p className="site-search-heading">Rooms</p>
{rooms.map((r) => (
<button
key={r.RoomId}
type="button"
className="site-search-result"
onClick={() => go(roomPath(r))}
>
<img
className="site-search-thumb"
src={`${where().img}/${r.ImageName}?width=128`}
alt=""
/>
<span>
<span className="site-search-name">^{r.Name}</span>
<span className="site-search-sub">
{r.Stats.VisitCount.toLocaleString()} visits
</span>
</span>
</button>
))}
</div>
)}
{nothing && <p className="site-search-empty">No players or rooms match.</p>}
</div>
)}
</div>
@@ -1284,7 +1537,7 @@ function ImagePage({ imageId, config }: { imageId: number; config: SiteConfig |
}
/**
* A player's public profile page (`/u/<username>`) — banner, avatar, bio-adjacent
* A player's public profile page (`/user/<username>`) — banner, avatar, bio-adjacent
* info, their public rooms, and their public photos. Read-only: this is the
* "rec.net-style profile" other players browse to, not the owner's own dashboard
* (that stays on `/account`).
@@ -1306,7 +1559,7 @@ function PlayerPage({
useEffect(() => {
// Waits for the config, not the session: this page is public, but every fetch on it
// goes through `where()`, which has no hostnames until `/api/config` lands. On a cold
// load of `/u/<name>` the effect would otherwise fire first and the page would be
// load of `/user/<name>` the effect would otherwise fire first and the page would be
// stuck on "Still starting up".
if (config === undefined) return
setAccount(undefined)
@@ -1777,11 +2030,18 @@ function halloweenActive(): boolean {
}
/** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */
const PUMPKIN_SRC =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAZCAYAAADaILXQAAAFD0lEQVR42pWVX2xcxRXGf2fu3Lu7jh0bO3FsnMQ2idP8gZoIGkootZsIGrUlVEVCTVW1qVREUPtSiTckYgQPqEIg1FZU6gtP8EJVAaZpIqE4UtrQOCSIkj+YVgRsp3E2mzisnb27OzOnD+uuE2yJZJ7u6J755jvf+eYc4SbX7qHBbNyjpe5VS5k4Nw3VZj4evcrFiWn+9fZxuTbW3Cw43WcxBMBCNYtYRYwiujDU3ijmwK++rWv7G7BSpLdrGf8Zv8TY+54kCuQ/v4IsckZuBPg7Twzq2ntq1IwXjvzlCz4cPv6VZxcN2LttTfXh9phKLPxtMmVnW7BqAiPnU2aipTzUFnjzonMiFhFn9747vihO9P+PoW29Dz7Yt3LD/sdl3dRl/9NmLRu1GJPzpqfFk2SV6ZIhGwlrVoBP1MQO0xQrQ/1N76/qXL7u4KeXxxbV3Jjwuveu1USGH98XOH44EIopu+7PECJBXGBmVpFKhr71JfqAD0cTVMGY5G1MukAJC/DM9l597IEmVsRTAJz4u2XrG+cxQGnPHuyOP6L797AxOYh1KdGmnYAhHPsrrjrLlq05dNTCtm71Jd82dGTiUh3cKDV7ac2ZAUP60moEB6ffQvfvQeucQt3Bd+3eAZlb+OCV1+n/Zpl3hpsAP8/86QdW6VM/tEjlCmWf8sizGcaiHB9thYQZgg+sfngYMPxuR46VpkS/exMRRQWE5bWL1fD9LsuhcV/Yu707febdz3I29gapBMg0467maG5pI3JlitaSAZaEIk1tHbXqNyxBowTT4IjK0zW72Qoyl7GmJdTPu8SC4dg/Krw6GrhcbeUDrYIY7v+th4uTnHxuaT3NR157j0CVvoN3cvo303PyBPRLjz3MSWiDwMvvrQACEpRg5v6oQVs3sfHFgEpg7NzxufRhbPIE0ZNw+NG1xDbHvnxA/+mR+kVz4Af9p/G31vjq5NkGyppjszpOYFFVQBERfmkNfvVmHIqo4e5cBBiy4RIVyhiRmhnUAG6+cY2M4FzSMNyQc9XWlgoiwi0mkAhkgO4AagUTZVkeWZZloSnyeHzNTdfIMeWgHMmBGbtkX12o5w+ceWj5ra7Y1j4LQK8qwZfRNGVbDgKWINCei+kVT1+Yt6XB4VGEwNEZoZKaXS8cOPWjBV1RCKAWpMLPlrYg1at4CWzf/DkXTt7K16qeFhNxVCI6br/Avrwl+Dw/6Grl5zMrWNbXQFquwpGJhf1cTJYnt16ou8BVpxm8q7bvvCPPW50T/L6rQMcd+TmPQ5rt4RezLdz7aCNrtwi5ZlMYeGKgdF0v2NR/W8FFcatUS/ykI8POzhasVf787/N8rzNi31TKRyu7KSyJicXw30/ykFiCEZbd1k6aFvn6fRmSOEsZ5ZPDbl4Wn2QxCmQa0VCiWrlMKjE+00g5MlQyEaXY4GyEw+EaDZFP8FJG1WGTLGhMpeJAPS5yNeYb7t543ZA6fexUPaP139ioEULcnqGtu5mKKOu3eFDDn84eihiiXt7BXw/o4HdXYnIlxicK12uuaHotMMCZ0VMSVClPpRTOzlL4rMjYUaGnq5nHewb8Y68O1ImpKucu5BmfrNXkpgb0lUK+oz3X1IRa1MiCoSwnTay2dEmI+fio1jRXleFIwYupLgZqggwDJMXkKhtwEEhLji+uKEhdFUZGRtztu3YeMBI1Znzxxgb0tWtw92BWGyitu8dzZlQxCof+cHhRnP8B5aopRAfi2ZcAAAAASUVORK5CYII='
const PUMPKIN_SCALE = 3
const PUMPKIN_W = 23 * PUMPKIN_SCALE
const PUMPKIN_H = 25 * PUMPKIN_SCALE
const PUMPKIN_FRAMES = [
new URL('./pumple-frames/frame-00.png', import.meta.url).href,
new URL('./pumple-frames/frame-01.png', import.meta.url).href,
new URL('./pumple-frames/frame-02.png', import.meta.url).href,
new URL('./pumple-frames/frame-03.png', import.meta.url).href,
new URL('./pumple-frames/frame-04.png', import.meta.url).href,
new URL('./pumple-frames/frame-05.png', import.meta.url).href,
new URL('./pumple-frames/frame-06.png', import.meta.url).href,
new URL('./pumple-frames/frame-07.png', import.meta.url).href,
]
const PUMPKIN_W = 150
const PUMPKIN_H = 150
const PUMPKIN_LINES = ['boo!', 'hehe', 'hey!', 'spooky!', 'happy halloween!', 'ow!', '🎃']
const PUMPKIN_CSS = `
@@ -1793,16 +2053,7 @@ const PUMPKIN_CSS = `
image-rendering: pixelated;
transform-origin: 50% 100%;
}
.rf-pumpkin.is-walking img {
animation: rf-pumpkin-step 0.24s steps(2, end) infinite;
}
@keyframes rf-pumpkin-step {
0%, 100% { transform: translateY(0) scaleX(1); }
50% { transform: translateY(-1px) scaleX(0.96); }
}
@media (prefers-reduced-motion: reduce) {
.rf-pumpkin.is-walking img { animation: none; }
}
/* The animated GIF provides its own walking frames. */
.rf-pumpkin-bubble { position: absolute; left: 50%; transform: translateX(-50%); margin: 6px 0; padding: 3px 8px;
border-radius: 10px; background: #fff3dc; color: #2a1608; font-size: 12px; font-weight: 600;
white-space: nowrap; opacity: 0; transition: opacity 0.15s; }
@@ -1816,13 +2067,15 @@ const PUMPKIN_CSS = `
function LilPumpkin() {
const rootRef = useRef<HTMLDivElement>(null)
const flipRef = useRef<HTMLDivElement>(null)
const spriteRef = useRef<HTMLImageElement>(null)
const bubbleRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const root = rootRef.current
const flip = flipRef.current
const sprite = spriteRef.current
const bubble = bubbleRef.current
if (!root || !flip || !bubble) return
if (!root || !flip || !sprite || !bubble) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
root.style.display = 'none'
return
@@ -1841,12 +2094,73 @@ function LilPumpkin() {
let jy = 0
let vy = 0
let walkTime = 0
let walkDistance = 0
let walkFrame = 0
let mouseX = -1
let mouseY = -1
let mouseAt = -Infinity
let speechTimer = 0
let last = performance.now()
let raf = 0
const effectNodes: HTMLElement[] = []
const spawnBatSwarm = () => {
const flash = document.createElement('div')
Object.assign(flash.style, {
position: 'fixed',
inset: '0',
background: 'rgba(210, 0, 28, 0.32)',
opacity: '1',
transition: 'opacity 480ms ease-out',
pointerEvents: 'none',
zIndex: '100000',
})
document.body.appendChild(flash)
effectNodes.push(flash)
requestAnimationFrame(() => {
flash.style.opacity = '0'
})
window.setTimeout(() => {
flash.remove()
const i = effectNodes.indexOf(flash)
if (i !== -1) effectNodes.splice(i, 1)
}, 520)
const cx = x + PUMPKIN_W / 2
const cy = y + jy + PUMPKIN_H / 2
for (let i = 0; i < 22; i++) {
const bat = document.createElement('span')
const angle = Math.random() * Math.PI * 2
const distance = 100 + Math.random() * 260
const dx = Math.cos(angle) * distance
const dy = Math.sin(angle) * distance
Object.assign(bat.style, {
position: 'fixed',
left: `${cx}px`,
top: `${cy}px`,
fontSize: `${20 + Math.random() * 20}px`,
lineHeight: '1',
pointerEvents: 'none',
zIndex: '100001',
opacity: '1',
transform: 'translate(-50%, -50%) scale(0.4)',
transition: 'transform 850ms ease-out, opacity 850ms ease-out',
willChange: 'transform, opacity',
})
bat.textContent = '🦇'
document.body.appendChild(bat)
effectNodes.push(bat)
requestAnimationFrame(() => {
bat.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${Math.random() * 100 - 50}deg) scale(1.2)`
bat.style.opacity = '0'
})
window.setTimeout(() => {
bat.remove()
const i = effectNodes.indexOf(bat)
if (i !== -1) effectNodes.splice(i, 1)
}, 900)
}
}
root.style.transform = `translate(${x}px, ${y}px)`
@@ -1870,14 +2184,30 @@ function LilPumpkin() {
const onLeave = () => {
mouseAt = -Infinity
}
let rapidClickCount = 0
let rapidClickStart = 0
const onClick = (e: MouseEvent) => {
const top = y + jy
const bob = 0
const hit =
e.clientX >= x - 6 &&
e.clientX <= x + PUMPKIN_W + 6 &&
e.clientY >= top - 6 &&
e.clientY <= top + PUMPKIN_H + 6
e.clientX >= x - 45 &&
e.clientX <= x + PUMPKIN_W + 45 &&
e.clientY >= top - bob - 45 &&
e.clientY <= top - bob + PUMPKIN_H + 45
if (!hit) return
const now = performance.now()
if (now - rapidClickStart > 5000) {
rapidClickStart = now
rapidClickCount = 0
}
rapidClickCount++
if (rapidClickCount >= 10) {
rapidClickCount = 0
rapidClickStart = 0
window.dispatchEvent(new CustomEvent('rf-pumple-boss'))
}
spawnBatSwarm()
if (jy === 0) vy = -430
say(PUMPKIN_LINES[Math.floor(Math.random() * PUMPKIN_LINES.length)])
}
@@ -1903,11 +2233,11 @@ function LilPumpkin() {
const dy = mouseY - (y + PUMPKIN_H / 2)
const dist = Math.hypot(dx, dy)
if (Math.abs(dx) > 4) facing = dx > 0 ? 1 : -1
if (dist > 110) {
if (dist > 85) {
// Walk toward the cursor but stop short, so it never ends up sitting under the pointer.
tx = clamp(mouseX - (dx / dist) * 80 - PUMPKIN_W / 2, 0, maxX())
ty = clamp(mouseY - (dy / dist) * 80 - PUMPKIN_H / 2, 0, maxY())
speed = 150
speed = 310
} else {
tx = x
ty = y
@@ -1920,7 +2250,7 @@ function LilPumpkin() {
ty = Math.random() * maxY()
}
} else {
speed = 60
speed = 145
}
const mx = tx - x
@@ -1932,6 +2262,15 @@ function LilPumpkin() {
x += (mx / md) * step
y += (my / md) * step
moving = true
// Advance the walk cycle according to distance actually traveled.
walkDistance += step
const nextFrame = Math.floor(walkDistance / 30) % PUMPKIN_FRAMES.length
if (nextFrame !== walkFrame) {
walkFrame = nextFrame
sprite.src = PUMPKIN_FRAMES[walkFrame]
}
if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1
} else if (speed > 0 && !following) {
pause = 1 + Math.random() * 2.5
@@ -1980,7 +2319,7 @@ function LilPumpkin() {
<div className="rf-pumpkin" ref={rootRef}>
<div className="rf-pumpkin-bubble" ref={bubbleRef} />
<div ref={flipRef} style={{ transformOrigin: '50% 100%' }}>
<img src={PUMPKIN_SRC} alt="" draggable={false} />
<img ref={spriteRef} src={PUMPKIN_FRAMES[0]} alt="" draggable={false} />
</div>
</div>
</>
@@ -2085,6 +2424,7 @@ export function App() {
turnstileSiteKey: null,
benefitsEnabled: false,
discordAuthorizeUrl: null,
discordLoginUrl: null,
})
setAccount(null)
})
@@ -2102,7 +2442,12 @@ export function App() {
return (
<>
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
{path === '/login' || path === '/signup' ? (
{path === '/login/discord' ? (
// Discord's registered redirect for a SIGN-IN (the claim's is `/claim`). Matched
// before `/login`, which it would otherwise never reach; a cold load falls through
// to the SPA shell like every other client-side route.
<DiscordLoginPage config={config} navigate={navigate} onAuthed={setAccount} />
) : path === '/login' || path === '/signup' ? (
// One page, two doors. `/signup` exists so the homepage's create-account link
// lands on that tab instead of dropping people on sign-in to find it — and so
// the URL is linkable. Unknown paths fall back to index.html (see the assets
@@ -2175,6 +2520,7 @@ export function App() {
)}
<SiteFooter />
<HalloweenOverlay />
<PumpleBossFight />
</>
)
}
@@ -2222,7 +2568,6 @@ function NavBar({
RecFlare
</Link>
<nav className="nav-links">
{SHOW_PLAYER_SEARCH && <PlayerSearch navigate={navigate} />}
<a href={DISCORD_INVITE} target="_blank" rel="noreferrer">
Discord
</a>
@@ -2267,6 +2612,7 @@ function NavBar({
Sign in
</Link>
)}
{SHOW_SEARCH && <SiteSearch navigate={navigate} />}
</nav>
</header>
)
@@ -2482,7 +2828,7 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
return (
<section className="about">
<div>
<h2 className="about-title">A cloud architected server for the 2023/2025 game clients</h2>
<h2 className="about-title">A cloud architected server for the 2023-2026 game clients</h2>
<p className="about-lede">
A free fan project, made by players who missed it. Aiming to be{' '}
<strong>feature-complete</strong> and infinitely scalable —{' '}
@@ -2563,6 +2909,7 @@ function LoginPage({
}
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
const discordLoginUrl = config?.discordLoginUrl ?? null
return (
<main className="shell">
@@ -2609,6 +2956,25 @@ function LoginPage({
your Steam ID — set a password on it and it signs in here too.
</p>
<LoginForm onAuthed={authed} />
{/* The other door. Shown on the server's say-so like the signup tab: the URL is
null until the Discord app is configured. `next` rides along through Discord in
sessionStorage, since the round trip drops the query. */}
{discordLoginUrl && (
<div className="alt-signin">
<p className="muted">or</p>
<button
type="button"
className="cta discord"
onClick={() => startDiscordAuth(discordLoginUrl, next)}
>
Sign in with Discord
</button>
<p className="muted">
Signs you into the account your Discord is linked to. No account yet? This makes
one — set a password on it afterwards and it signs in to the game too.
</p>
</div>
)}
{/* 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. */}
@@ -2627,6 +2993,109 @@ function LoginPage({
)
}
/**
* `/login/discord` — the page Discord redirects a SIGN-IN back to. Its own route rather than
* `/claim`, because that page wants a session before it does anything and this is how one
* gets made; it is the app's second registered `redirect_uri`, so like `/claim` it has to
* work on a cold load.
*
* The return leg mirrors the claim's: check the nonce this tab minted, strip the code off
* the URL before anything else, redeem it exactly once. What differs is the outcome. A
* sign-in to an existing account just goes where the login page was headed. A sign-in that
* CREATED an account (`created`) stops here first: that account has no password, and
* without one it can't sign in to the game — on a Quest without a Meta account, which is
* the whole point of making it this way — so the one thing to tell a brand-new player is
* where to set one.
*/
function DiscordLoginPage({
config,
navigate,
onAuthed,
}: {
config: SiteConfig | undefined
navigate: Navigate
onAuthed: (a: SelfAccount) => void
}) {
const [error, setError] = useState('')
const [created, setCreated] = useState<SelfAccount | null>(null)
// StrictMode runs effects twice in dev, and a Discord code is single-use — see BenefitsPanel.
const redeemed = useRef(false)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
const expected = sessionStorage.getItem(OAUTH_STATE_KEY)
const next = safeNextPath(sessionStorage.getItem(OAUTH_NEXT_KEY)) ?? '/account'
if (code === null) {
// Opened directly, or Discord sent the player back with `?error=access_denied`
// because they cancelled. Nothing to redeem; back to the door.
navigate('/login')
return
}
window.history.replaceState(null, '', '/login/discord')
if (redeemed.current) return
redeemed.current = true
sessionStorage.removeItem(OAUTH_STATE_KEY)
sessionStorage.removeItem(OAUTH_NEXT_KEY)
if (state === null || expected === null || state !== expected) {
setError('That Discord sign-in did not match this browser. Please start again.')
return
}
signInWithDiscord(code)
.then(async (result) => {
const me = await fetchMe()
onAuthed(me)
if (result.created) setCreated(me)
else navigate(next)
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
}, [navigate, onAuthed])
if (created) {
return (
<main className="shell">
<section className="card">
<h2>Welcome to RecFlare</h2>
<p>
Your account is ready, linked to your Discord. Your username is{' '}
<strong>@{created.username}</strong> — you can change it on your account page.
</p>
<p className="muted">
It has no password yet. Set one on your account page: your username and that password
are how you sign in to the game — including on a Quest without a Meta account.
</p>
<Link to="/account" navigate={navigate} className="cta">
Go to my account
</Link>
</section>
</main>
)
}
return (
<main className="shell">
<section className="card">
<h2>Signing in with Discord</h2>
{error ? (
<>
<p className="error">{error}</p>
<Link to="/login" navigate={navigate} className="cta ghost">
Back to sign in
</Link>
</>
) : (
<p className="muted">{config === undefined ? 'Loading…' : 'One moment…'}</p>
)}
</section>
</main>
)
}
/** The signed-in account page. Redirects to sign-in when there's no session. */
function AccountPage({
account,
@@ -2838,7 +3307,7 @@ function PublicRoomView({
{creator && (
<p className="room-hero-by">
by{' '}
<Link to={`/u/${encodeURIComponent(creator.username)}`} navigate={navigate}>
<Link to={`/user/${encodeURIComponent(creator.username)}`} navigate={navigate}>
{creator.displayName || creator.username}
</Link>
</p>
@@ -2889,6 +3358,7 @@ function PublicRoomView({
<>
{isDeveloper() && <StaffRoomTokens room={room} />}
<StaffListing room={room} onRoomChange={onRoomChange} />
<StaffClearInstances room={room} />
{isDeveloper() && <StaffTakedown room={room} onTakenDown={onTakenDown} />}
</>
)}
@@ -3027,6 +3497,64 @@ function StaffListing({
)
}
/**
* Delete every instance of the room, for one whose sessions are stuck. Two steps, like the
* dorm clear: nothing is lost for good, but anyone inside is cut off from their session.
*/
function StaffClearInstances({ room }: { room: OwnedRoom }) {
const [confirming, setConfirming] = useState(false)
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Instances</h2>
<p className="hint">
Deletes every instance of this room, including ones with players in them. The next player to
join gets a fresh instance.
</p>
<div className="mod-filter-actions">
{confirming ? (
<>
<button
type="submit"
disabled={pending}
onClick={() =>
void run(async () => {
const res = await call<{ deleted: number[] }>(
`/api/staff/rooms/${room.RoomId}/clear-instances`,
{ authed: true, method: 'POST' }
)
setConfirming(false)
const n = res.deleted.length
return n === 0
? `^${room.Name} had no instances.`
: `Cleared ${n} instance${n === 1 ? '' : 's'} of ^${room.Name}.`
})
}
>
{pending ? 'Clearing…' : 'Confirm clear instances'}
</button>
<button
type="button"
className="linkish"
disabled={pending}
onClick={() => setConfirming(false)}
>
Cancel
</button>
</>
) : (
<button type="submit" onClick={() => setConfirming(true)}>
Clear instances
</button>
)}
</div>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
</section>
)
}
function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) {
const [confirming, setConfirming] = useState(false)
const { pending, error, run } = useAction()
@@ -3934,6 +4462,11 @@ function Dashboard({
// settings form they opened the page to avoid.
{ id: 'rooms', label: 'My rooms', render: () => <MyRooms navigate={navigate} /> },
{ id: 'photos', label: 'My photos', render: () => <MyPhotos accountId={account.accountId} /> },
{
id: 'banner',
label: 'Profile banner',
render: () => <BannerUpload account={account} onChange={onChange} />,
},
{
id: 'username',
label: 'Username',
@@ -4112,6 +4645,55 @@ function MyPhotos({ accountId }: { accountId: number }) {
)
}
/**
* The profile banner, as an {@link ImageUpload} in the profile banner's own shape: upload
* the picture (`uploadBannerImage`), then set it on the account (`setBannerImage`). The
* game only lets a player pick one of their camera photos; this takes any JPEG or PNG.
* The held account is patched with the new key, so the preview redraws without a refetch.
*/
function BannerUpload({
account,
onChange,
}: {
account: SelfAccount
onChange: (a: SelfAccount) => void
}) {
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Profile banner</h2>
<p className="muted">
The wide picture across the top of your profile. Click it to upload a JPEG or PNG.
</p>
<ImageUpload
// No banner yet: a transparent pixel, so the frame shows its gradient backdrop
// (the profile's empty-banner look) rather than a stretched default avatar.
src={
account.bannerImage
? `${where().img}/${account.bannerImage}?width=1024`
: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
}
className="banner-upload"
imgClassName="banner-preview"
hint={account.bannerImage ? 'Change banner' : 'Upload a banner'}
pending={pending}
onFile={(file) =>
void run(async () => {
requireImage(file)
const imageName = await uploadBannerImage(file)
await setBannerImage(imageName)
onChange({ ...account, bannerImage: imageName })
return 'Banner updated.'
})
}
/>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
</section>
)
}
/**
* One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's
* doing. The whole row links to the room's own page.
+4 -4
View File
@@ -216,10 +216,10 @@ export function ItemPage({
<p className="room-hero-by">
by{' '}
<a
href={`/u/${encodeURIComponent(creator.username)}`}
href={`/user/${encodeURIComponent(creator.username)}`}
onClick={(e) => {
e.preventDefault()
navigate(`/u/${encodeURIComponent(creator.username)}`)
navigate(`/user/${encodeURIComponent(creator.username)}`)
}}
>
{creator.displayName || creator.username}
@@ -379,10 +379,10 @@ export function InventionPage({
<p className="room-hero-by">
by{' '}
<a
href={`/u/${encodeURIComponent(creator.username)}`}
href={`/user/${encodeURIComponent(creator.username)}`}
onClick={(e) => {
e.preventDefault()
navigate(`/u/${encodeURIComponent(creator.username)}`)
navigate(`/user/${encodeURIComponent(creator.username)}`)
}}
>
{creator.displayName || creator.username}
+1 -1
View File
@@ -369,7 +369,7 @@ function BanSummary({
}
/**
* One line for a player's profile page (`/u/<username>`): whether a ban blocks them right
* One line for a player's profile page (`/user/<username>`): whether a ban blocks them right
* now — their own or a linked account's — and a link to their full history here.
*/
export function PlayerBanStatus({
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react'
type Mob = { id: number; x: number; y: number; hp: number }
type Shot = { id: number; x: number; y: number; vx: number; vy: number; bomb?: boolean }
type Game = {
player: number
hp: number
bossHp: number
mobs: Mob[]
enemyShots: Shot[]
arrows: Shot[]
bombs: number
kills: number
waves: number
nextId: number
lastSpawn: number
lastAttack: number
phase: 'fight' | 'victory' | 'defeat'
}
const freshGame = (): Game => ({
player: (typeof window !== 'undefined' ? window.innerWidth : 1024) / 2, hp: 100, bossHp: 100,
mobs: [], enemyShots: [], arrows: [], bombs: 0, kills: 0,
waves: 1, nextId: 1, lastSpawn: 0, lastAttack: 0, phase: 'fight',
})
const css = `
.rf-boss{position:fixed;inset:0;z-index:100010;overflow:hidden;background:radial-gradient(ellipse at 50% 34%,#542619 0%,#26121a 43%,#090810 100%);color:#fff0d5;font-family:system-ui,sans-serif;cursor:crosshair;user-select:none}
.rf-boss:before{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,#08060a66,transparent 35%,#05050988);z-index:0}
.rf-boss:after{content:"";position:absolute;inset:0;pointer-events:none;opacity:.22;background-image:radial-gradient(#ffb35b 1px,transparent 1px);background-size:37px 37px;mask-image:linear-gradient(transparent,#000 30%,#000)}
.rf-boss *{box-sizing:border-box}
.rf-boss-hud{position:absolute;z-index:8;top:18px;left:50%;transform:translateX(-50%);width:min(900px,82%);display:grid;grid-template-columns:1.2fr 1fr;gap:22px;padding:16px 20px;border:1px solid #e9843c88;border-radius:15px;background:linear-gradient(135deg,#160d12eF,#2a1117eF);box-shadow:0 10px 45px #0009,inset 0 1px #ffd2a322;backdrop-filter:blur(8px)}
.rf-boss-label{font-weight:950;letter-spacing:2px;font-size:11px;text-transform:uppercase;margin-bottom:8px;color:#ffddb2}
.rf-boss-track{height:17px;border:1px solid #ffd9aa88;background:#100a10;border-radius:5px;overflow:hidden;box-shadow:inset 0 2px 5px #000a}
.rf-boss-fill{height:100%;background:linear-gradient(90deg,#a82420,#ff6036,#ffb05b);box-shadow:0 0 15px #ff552a99;transition:width .12s}
.rf-boss-player .rf-boss-fill{background:linear-gradient(90deg,#138a65,#49e7a0);box-shadow:0 0 14px #3df0a088}
.rf-boss-monster{position:absolute;z-index:4;top:16%;left:50%;width:min(43vw,390px);height:min(43vw,390px);min-width:230px;min-height:230px;transform:translateX(-50%);display:grid;place-items:center;filter:drop-shadow(0 0 18px #ff5b24aa);animation:rf-boss-hover 3s ease-in-out infinite}
.rf-boss-monster:before{content:"";position:absolute;width:90%;height:35%;bottom:0;border-radius:50%;background:#ff5a1726;filter:blur(25px)}
.rf-boss-monster img{position:relative;width:100%;height:100%;object-fit:contain;image-rendering:pixelated;filter:drop-shadow(0 12px 0 #150a10) drop-shadow(0 0 16px #ff6b35aa)}
.rf-boss-minion{position:absolute;z-index:4;transform:translate(-50%,-50%);width:clamp(44px,7vw,78px);height:clamp(44px,7vw,78px);filter:drop-shadow(0 0 10px #ff4b2588);pointer-events:none}
.rf-boss-minion img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated}
.rf-boss-shot{position:absolute;z-index:5;transform:translate(-50%,-50%);font-size:25px;pointer-events:none;filter:drop-shadow(0 0 9px #ff712f);animation:rf-shot-glow .5s ease-in-out infinite alternate}
.rf-boss-player-char{position:absolute;z-index:5;bottom:7%;font-size:42px;transform:translateX(-50%);width:64px;height:64px;display:grid;place-items:center;border-radius:50%;background:radial-gradient(circle,#77eaff35,transparent 70%);filter:drop-shadow(0 0 10px #7ad9ff);pointer-events:none}
.rf-boss-hint{position:absolute;z-index:8;bottom:14px;left:50%;transform:translateX(-50%);width:max-content;max-width:88%;text-align:center;font-size:11px;font-weight:850;letter-spacing:1px;color:#ffe3c1;padding:12px 18px;border:1px solid #b9684777;border-radius:9px;background:#100b13dc;box-shadow:0 4px 25px #0008}
.rf-boss-vines{position:absolute;z-index:6;inset:0;pointer-events:none;overflow:hidden;opacity:1}
.rf-boss-vines svg{position:absolute;inset:-2%;width:104%;height:104%;overflow:visible}
.rf-boss-vines .vine-main{fill:none;stroke:#172f1b;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;filter:drop-shadow(0 0 5px #050807)}
.rf-boss-vines .vine-edge{fill:none;stroke:#426a2d;stroke-width:12;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:25 8;animation:rf-vine-crawl 7s linear infinite}
.rf-boss-vines .vine-glow{fill:none;stroke:#df722a;stroke-width:3;stroke-linecap:round;stroke-dasharray:3 28;animation:rf-vine-crawl 4s linear infinite}
.rf-boss-vines .thorn{fill:#72913b;stroke:#18291a;stroke-width:3}
.rf-boss-vines .root{fill:none;stroke:#253d20;stroke-width:39;stroke-linecap:round;opacity:.92}
.rf-boss-vines-retreat{animation:rf-vine-retreat 2.8s ease-in forwards}
.rf-boss-end{position:absolute;z-index:10;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;background:linear-gradient(180deg,#08090bea,#130d0bea);padding:24px}
.rf-boss-panel{max-width:560px;width:100%;padding:clamp(26px,5vw,44px);border:2px solid #9b542d;border-radius:4px;background:#171310;box-shadow:8px 8px 0 #050505, inset 0 0 0 4px #171310, inset 0 0 0 5px #443023;color:#eee2ce}
.rf-boss-title{font-family:Impact,'Arial Narrow',system-ui,sans-serif;font-size:clamp(34px,6.5vw,60px);font-weight:900;line-height:1.05;letter-spacing:1px;text-transform:uppercase;color:#f0a04d;text-shadow:3px 3px 0 #522719;margin:0 0 20px}
.rf-boss-button{margin:10px 6px 0;border:2px solid #c47b43;border-radius:3px;padding:12px 20px;background:#30251b;color:#ffe5c3;font-family:inherit;font-size:13px;font-weight:800;letter-spacing:1px;text-transform:uppercase;cursor:pointer;box-shadow:0 3px 0 #090807;transition:background .12s,transform .12s}
.rf-boss-button:hover{background:#513522;transform:translateY(-2px)}
@keyframes rf-boss-hover{0%,100%{translate:0 0}50%{translate:0 -8px}}
@keyframes rf-shot-glow{from{scale:.9}to{scale:1.12}}
@keyframes rf-vine-crawl{to{stroke-dashoffset:-132}}
@keyframes rf-vine-retreat{to{transform:scale(1.4);opacity:0}}
@media(max-width:600px){.rf-boss-hud{top:10px;width:86%;padding:12px;gap:12px}.rf-boss-label{font-size:9px;letter-spacing:1px}.rf-boss-track{height:13px}.rf-boss-monster{top:23%;width:65vw;height:65vw;min-width:0;min-height:0}.rf-boss-hint{font-size:9px;letter-spacing:.3px;padding:10px;bottom:8px}}
@media(prefers-reduced-motion:reduce){.rf-boss-monster,.rf-boss-shot,.rf-boss-vines .vine-edge,.rf-boss-vines .vine-glow{animation:none}}
`
export function PumpleBossFight() {
const [active, setActive] = useState(false)
const [view, setView] = useState<Game>(freshGame)
const game = useRef<Game>(freshGame())
const mouse = useRef({ x: typeof window !== 'undefined' ? window.innerWidth / 2 : 512, y: typeof window !== 'undefined' ? window.innerHeight * .78 : 600 })
const aim = useRef({ x: typeof window !== 'undefined' ? window.innerWidth / 2 : 512, y: typeof window !== 'undefined' ? window.innerHeight * .3 : 200 })
const started = useRef(false)
useEffect(() => {
const start = () => {
if (started.current) return
started.current = true
game.current = freshGame()
mouse.current = { x: innerWidth / 2, y: innerHeight * .78 }
setView({ ...game.current })
setActive(true)
}
window.addEventListener('rf-pumple-boss', start)
return () => window.removeEventListener('rf-pumple-boss', start)
}, [])
useEffect(() => {
if (!active) return
const g = game.current
let last = performance.now()
let frame = 0
let endTimer = 0
const tick = () => {
const now = performance.now()
const dt = Math.min(.05, (now - last) / 1000)
last = now
if (g.phase === 'fight') {
g.player = Math.max(28, Math.min(innerWidth - 28, mouse.current.x))
const difficulty = 1 + (g.waves - 1) * .18
if (now - g.lastSpawn > Math.max(900, 3000 / difficulty) && g.mobs.length < 5 + g.waves) {
g.lastSpawn = now
g.mobs.push({ id: g.nextId++, x: Math.random() * (innerWidth - 80) + 40, y: innerHeight * (.35 + Math.random() * .28), hp: 2 })
}
if (now - g.lastAttack > Math.max(450, 1400 / difficulty)) {
g.lastAttack = now
const sx = innerWidth / 2, sy = innerHeight * .29
const dx = g.player - sx, dy = innerHeight * .78 - sy
const d = Math.hypot(dx, dy) || 1
g.enemyShots.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 280 * difficulty, vy: dy / d * 280 * difficulty })
if (g.waves >= 2) {
g.enemyShots.push({ id: g.nextId++, x: sx, y: sy, vx: -dx / d * 240 * difficulty, vy: 270 * difficulty })
}
}
for (const m of g.mobs) {
const dx = g.player - m.x, dy = innerHeight * .78 - m.y, d = Math.hypot(dx, dy) || 1
m.x += dx / d * 75 * difficulty * dt
m.y += dy / d * 75 * difficulty * dt
if (d < 35) { g.hp -= 18 * dt; m.hp = 0 }
}
for (const s of g.enemyShots) { s.x += s.vx * dt; s.y += s.vy * dt }
g.enemyShots = g.enemyShots.filter(s => {
if (Math.hypot(s.x - g.player, s.y - innerHeight * .78) < 32) { g.hp -= 12; return false }
return s.x > -50 && s.x < innerWidth + 50 && s.y < innerHeight + 50
})
for (const s of g.arrows) {
s.x += s.vx * dt; s.y += s.vy * dt
if (s.bomb) {
let detonated = false
for (const m of g.mobs) {
if (m.hp > 0 && Math.hypot(s.x - m.x, s.y - m.y) < 65) {
m.hp = 0
detonated = true
}
}
if (Math.hypot(s.x - innerWidth / 2, s.y - innerHeight * .29) < 115) {
g.bossHp -= 14
detonated = true
}
if (detonated) s.x = -999
} else {
let hit = false
for (const m of g.mobs) {
if (m.hp > 0 && Math.hypot(s.x - m.x, s.y - m.y) < 28) {
m.hp = 0
hit = true
break
}
}
if (!hit && Math.hypot(s.x - innerWidth / 2, s.y - innerHeight * .29) < 90) {
g.bossHp -= 2
hit = true
}
if (hit) s.x = -999
}
}
g.arrows = g.arrows.filter(s => s.x > -100 && s.x < innerWidth + 100 && s.y > -100 && s.y < innerHeight + 100)
// Count defeated minions before removing them; some kills drop bombs.
const dead = g.mobs.filter(m => m.hp <= 0)
if (dead.length) {
g.kills += dead.length
for (let i = 0; i < dead.length; i++) {
if (Math.random() < .38) g.bombs++
}
}
g.mobs = g.mobs.filter(m => m.hp > 0)
if (g.bossHp <= 0) g.phase = 'victory'
else if (g.hp <= 0) g.phase = 'defeat'
if (g.kills >= g.waves * 5) g.waves = Math.min(8, Math.floor(g.kills / 5) + 1)
}
setView({ ...g, mobs: [...g.mobs], enemyShots: [...g.enemyShots], arrows: [...g.arrows] })
if (g.phase !== 'fight') {
window.clearInterval(frame)
endTimer = window.setTimeout(() => {
if (g.phase === 'defeat') window.location.reload()
}, 6500)
}
}
frame = window.setInterval(tick, 50) as unknown as number
return () => { window.clearInterval(frame); window.clearTimeout(endTimer) }
}, [active])
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
mouse.current = { x: e.clientX, y: e.clientY }
aim.current = { x: e.clientX, y: e.clientY }
}
const fire = (e: React.MouseEvent<HTMLDivElement>) => {
if (view.phase !== 'fight') return
const g = game.current
const sx = g.player, sy = innerHeight * .78
const dx = e.clientX - sx, dy = e.clientY - sy, d = Math.hypot(dx, dy) || 1
g.arrows.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 620, vy: dy / d * 620 })
}
const bomb = () => {
const g = game.current
if (!active || g.phase !== 'fight' || g.bombs <= 0) return
g.bombs--
const sx = g.player, sy = innerHeight * .78
const dx = aim.current.x - sx, dy = aim.current.y - sy, d = Math.hypot(dx, dy) || 1
g.arrows.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 350, vy: dy / d * 350, bomb: true })
setView({ ...g, arrows: [...g.arrows] })
}
useEffect(() => {
if (!active) return
const key = (e: KeyboardEvent) => { if (e.code === 'Space') { e.preventDefault(); bomb() } }
window.addEventListener('keydown', key)
return () => window.removeEventListener('keydown', key)
}, [active])
if (!active) return null
const ended = view.phase !== 'fight'
return <div className="rf-boss" onMouseMove={onMove} onClick={fire} onContextMenu={e => e.preventDefault()}>
<style>{css}</style>
<div className="rf-boss-hud">
<div style={{ flex: 1 }}><div className="rf-boss-label">YOUR HEALTH {Math.max(0, Math.ceil(view.hp))}/100</div><div className="rf-boss-track rf-boss-player"><div className="rf-boss-fill" style={{ width: `${Math.max(0, view.hp)}%` }} /></div></div>
<div style={{ flex: 1 }}><div className="rf-boss-label">PUMPLE {Math.max(0, Math.ceil(view.bossHp))}%</div><div className="rf-boss-track"><div className="rf-boss-fill" style={{ width: `${Math.max(0, view.bossHp)}%` }} /></div></div>
</div>
<div className="rf-boss-monster">
<img src={new URL('./pumple-frames/frame-04.png', import.meta.url).href} alt="Pumple, the pumpkin king" draggable={false} />
</div>
{view.mobs.map(m => <div key={m.id} className="rf-boss-minion" style={{ left: m.x, top: m.y }}>
<img src={new URL('./Pumple_monster_walk.gif', import.meta.url).href} alt="" draggable={false} />
</div>)}
{view.enemyShots.map(s => <div key={s.id} className="rf-boss-shot" style={{ left: s.x, top: s.y }}>🔥</div>)}
{view.arrows.map(s => <div key={s.id} className="rf-boss-shot" style={{ left: s.x, top: s.y }}>{s.bomb ? '💣' : '➶'}</div>)}
<div className="rf-boss-player-char" style={{ left: view.player }}>🛡️</div>
<div className="rf-boss-hint">MOVE YOUR MOUSE TO DODGE · CLICK TO SHOOT ARROWS · SPACE: PUMPKIN BOMB ({view.bombs}) · WAVE {view.waves}</div>
<div className={`rf-boss-vines ${view.phase === 'victory' ? 'rf-boss-vines-retreat' : ''}`}>
<svg viewBox="0 0 1200 800" preserveAspectRatio="none" aria-hidden="true">
<path className="root" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760"/>
<path className="vine-main" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760 M-20 300 Q100 250 160 330 T250 390 M1220 450 Q1100 390 1050 460 T960 510"/>
<path className="vine-edge" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760 M-20 300 Q100 250 160 330 T250 390 M1220 450 Q1100 390 1050 460 T960 510"/>
<path className="vine-glow" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760"/>
<g className="thorn">
<path d="M54 180 l-35 -18 27 43z M94 305 l-36 -8 30 31z M81 500 l-33 -18 23 39z M1140 200 l34 -24 -24 48z M1100 360 l37 -12 -29 36z M1134 545 l36 -19 -23 43z M190 760 l-5 -40 27 31z M1010 760 l6 -40 -28 31z M180 345 l-25 -28 7 40z M1035 455 l24 -28 -7 40z"/>
</g>
</svg>
</div>
{ended && <><div className="rf-boss-end"><div className="rf-boss-panel">
<h1 className="rf-boss-title">{view.phase === 'victory' ? 'PUMPLE DEFEATED!' : 'RECFLARE HAS FALLEN'}</h1>
<p>{view.phase === 'victory' ? 'The pumpkin king crumbles into embers. The vines retreat and RecFlare is saved!' : 'The vines have consumed the battlefield. Pumple claims RecFlare...'}</p>
{view.phase === 'victory' && <p>Waves reached: {view.waves} · Minions defeated: {view.kills} · Bombs remaining: {view.bombs}</p>}
{view.phase === 'victory' && <button className="rf-boss-button" onClick={e => { e.stopPropagation(); setActive(false); started.current = false }}>RETURN TO RECFLARE</button>}
{view.phase === 'defeat' && <p>The site will reset after the vines take over...</p>}
</div></div></>}
</div>
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+68 -29
View File
@@ -14,16 +14,29 @@ import { call, isAdmin, useAction } from './api'
const PAGE = '/settings/recroomstudio'
type AccessKind = keyof typeof accessLabels
const accessLabels = {
studio: 'Studio uploads',
'avatar-items': 'Avatar Studio items',
'avatar-costumes': 'Avatar Studio costumes',
'avatar-badges': 'Avatar Studio badges',
'avatar-consumables': 'Avatar Studio consumables',
screenshare: 'Screen sharing',
}
interface AccessRow {
accountId: number
username: string | null
displayName: string | null
enabled: boolean
}
export function StudioAccessPage({
function AccessPage({
kind,
account,
navigate,
}: {
kind: AccessKind
/** undefined while the session is still being checked, null when signed out. */
account: { accountId: number; username: string } | null | undefined
navigate: (to: string) => void
@@ -39,15 +52,17 @@ export function StudioAccessPage({
const add = useAction()
const load = useCallback(async () => {
const me = await call<{ granted: boolean }>('/api/studio-access', { authed: true })
setGranted(me.granted === true)
const me = await call<Record<AccessKind | 'granted', boolean>>('/api/studio-access', {
authed: true,
})
setGranted((kind === 'studio' ? me.granted : me[kind]) === true)
if (isAdmin()) {
const page = await call<{ accounts: AccessRow[] }>('/api/staff/studio-access', {
const page = await call<{ accounts: AccessRow[] }>(`/api/staff/studio-access?kind=${kind}`, {
authed: true,
})
setRows(page.accounts ?? [])
}
}, [])
}, [kind])
const accountId = account?.accountId
useEffect(() => {
@@ -80,13 +95,13 @@ export function StudioAccessPage({
void add.run(async () => {
const result = await call<{ username: string; alreadyGranted: boolean }>(
'/api/staff/studio-access',
{ method: 'POST', authed: true, json: { username: username.trim() } }
{ method: 'POST', authed: true, json: { username: username.trim(), kind } }
)
setUsername('')
await reload()
return result.alreadyGranted
? `@${result.username} is already on the list.`
: `@${result.username} can upload the next time they sign in to Studio.`
? `@${result.username} already has ${accessLabels[kind]} access.`
: `@${result.username} now has ${accessLabels[kind]} access.`
})
}
@@ -95,7 +110,14 @@ export function StudioAccessPage({
setRemoveError('')
void (async () => {
try {
await call(`/api/staff/studio-access/${row.accountId}`, { method: 'DELETE', authed: true })
await call(
row.enabled
? `/api/staff/studio-access/${row.accountId}?kind=${kind}`
: '/api/staff/studio-access',
row.enabled
? { method: 'DELETE', authed: true }
: { method: 'POST', authed: true, json: { accountId: row.accountId, kind } }
)
await reload()
} catch (err) {
setRemoveError(err instanceof Error ? err.message : String(err))
@@ -108,13 +130,16 @@ export function StudioAccessPage({
const signInHref = `/login?next=${encodeURIComponent(PAGE)}`
return (
<main className={staff ? 'shell wide' : 'shell'}>
<h1>RecFlare Studio access</h1>
<section>
<h2>{accessLabels[kind]}</h2>
<section className="card">
<p className="muted">
RecFlare Studio signs in with any account. Uploading is limited to accounts on this list.
Access is picked up the next time you sign in to Studio, or when that session refreshes. A
session you already have keeps its old access until then, and a session lasts a day.
{kind === 'studio'
? 'Manage permission to upload from RecFlare Studio.'
: kind.startsWith('avatar-')
? 'Grant this Avatar Studio feature independently. Studio upload access is also required. Roomie equipment still requires developer access.'
: 'Screen sharing is disabled by default. Staff can enable it for individual users.'}{' '}
Sign in again or refresh the session to pick up permission changes.
</p>
{account === undefined ? (
<p className="muted">Loading…</p>
@@ -130,7 +155,7 @@ export function StudioAccessPage({
>
Sign in
</a>{' '}
<span className="muted">to see whether this account can upload.</span>
<span className="muted">to see the access for this account.</span>
</p>
) : loading ? (
<p className="muted">Loading…</p>
@@ -138,22 +163,23 @@ export function StudioAccessPage({
<p className="error">{loadError}</p>
) : granted ? (
<p>
<strong>{account.username}</strong> can upload in RecFlare Studio. If the editor still
<strong>{account.username}</strong> has {accessLabels[kind]} access. If the editor still
says otherwise, sign out of it and sign in again.
</p>
) : (
<p>
<strong>{account.username}</strong> cannot upload in RecFlare Studio yet. A moderator
can add this account to the list.
<strong>{account.username}</strong> does not have {accessLabels[kind]} access. A
moderator can add this account to the list.
</p>
)}
</section>
{staff && account && (
<section className="card">
<h2>Whitelist</h2>
<h3>Whitelist</h3>
<p className="muted">
Adding a player puts the betastudio role on their next Studio sign-in. Developers are
not on this list unless someone adds them.
{kind === 'screenshare'
? 'Only users on this list can share their screen.'
: 'Add a username to grant access, or remove a player to revoke it.'}
</p>
<form
onSubmit={(e) => {
@@ -172,7 +198,7 @@ export function StudioAccessPage({
/>
</label>
<button type="submit" disabled={add.pending || username.trim() === ''}>
Add
{kind === 'screenshare' ? 'Enable' : 'Add'}
</button>
</form>
{add.error && <p className="error">{add.error}</p>}
@@ -180,7 +206,7 @@ export function StudioAccessPage({
{loading ? (
<p className="muted">Loading…</p>
) : loadError ? null : rows.length === 0 ? (
<p className="muted">Nobody is on the list.</p>
<p className="muted">No users on this list.</p>
) : (
<ul className="studio-access-list">
{rows.map((row) => (
@@ -192,13 +218,12 @@ export function StudioAccessPage({
{row.displayName && row.displayName !== row.username ? (
<span className="muted"> · {row.displayName}</span>
) : null}
{kind === 'screenshare' && (
<span className="muted"> - {row.enabled ? 'Enabled' : 'Disabled'}</span>
)}
</span>
<button
type="button"
disabled={removing === row.accountId}
onClick={() => remove(row)}
>
Remove
<button type="button" disabled={removing !== null} onClick={() => remove(row)}>
{kind === 'screenshare' ? (row.enabled ? 'Disable' : 'Enable') : 'Remove'}
</button>
</li>
))}
@@ -207,6 +232,20 @@ export function StudioAccessPage({
{removeError && <p className="error">{removeError}</p>}
</section>
)}
</section>
)
}
export function StudioAccessPage(props: {
account: { accountId: number; username: string } | null | undefined
navigate: (to: string) => void
}) {
return (
<main className="shell wide">
<h1>RecFlare Studio access</h1>
{(Object.keys(accessLabels) as AccessKind[]).map((kind) => (
<AccessPage key={kind} kind={kind} {...props} />
))}
</main>
)
}
+25 -13
View File
@@ -219,23 +219,32 @@ export function useAction() {
}
/**
* Resolve an `@username` to the account id the workers address a player by.
*
* `accounts` serves no exact-name lookup, so this goes through the PREFIX search and
* keeps only an exact (case-insensitive) hit: a prefix match is a different player, and
* acting on whoever happened to sort first would be worse than refusing. The exact name
* always sorts first among its own prefixes, so it's inside the search limit whenever it
* exists.
* The public account behind an exact `@username`, or null when nobody has the name.
* `accounts`' `GET /account/username/:name` is an indexed equality lookup — NOT the
* prefix search, which this once went through and filtered client-side; one profile view
* then cost a 20-row index walk, and the search bar's typeahead already keeps that query
* busy enough. A 404 is the one non-ok answer that means "no such player"; anything else
* is a real failure and throws.
*/
export async function accountByUsername(input: string): Promise<PublicAccount | null> {
const name = input.trim().replace(/^@/, '')
if (name === '') return null
const res = await fetch(`${where().accounts}/account/username/${encodeURIComponent(name)}`)
if (res.status === 404) return null
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) throw new Error(errorMessage(data, res.status))
return data as unknown as PublicAccount
}
/**
* Resolve an `@username` to the account id the workers address a player by. An exact
* match only: a prefix match is a different player, and acting on whoever happened to
* sort first would be worse than refusing.
*/
export async function accountIdForUsername(input: string): Promise<number> {
const name = input.trim().replace(/^@/, '')
if (name === '') throw new Error('Enter a username.')
const matches = await call<Array<{ accountId?: number; username?: string }>>(
`${where().accounts}/account/search?name=${encodeURIComponent(name)}`
)
const found = Array.isArray(matches)
? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase())
: undefined
const found = await accountByUsername(name)
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
return found.accountId
}
@@ -245,6 +254,9 @@ export interface PublicAccount {
accountId: number
username: string
displayName: string
profileImage: string
bannerImage: string
createdAt: string
}
/**
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+160 -32
View File
@@ -135,8 +135,8 @@ body {
font-size: 0.95rem;
color: var(--muted);
text-decoration: none;
/* A link is one or two words; with the search bar in the row it has to give up space
by wrapping the ROW (see the nav rule under player search), not by breaking mid-link. */
/* A link is one or two words; short of room the ROW wraps (see the nav rule under
site search), never a link mid-word. */
white-space: nowrap;
}
@@ -447,6 +447,33 @@ body {
border-color: #4752c4;
}
/* The way back out of the Discord link, at the foot of the benefits card: set apart by a
rule so it doesn't read as part of the claim, and the button in the error colour so it
reads as the undo it is. The hint beside it says what has to be set first. */
.unlink {
margin-top: 22px;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.unlink h3 {
margin: 0 0 8px;
font-size: 1rem;
}
.linkish.danger {
color: var(--error);
}
.linkish.danger:hover {
color: var(--text);
}
.linkish.danger:disabled {
opacity: 0.6;
cursor: default;
}
/* Secondary action: same shape, outlined instead of filled. */
.cta.ghost {
background: transparent;
@@ -1770,50 +1797,101 @@ button[type='submit']:disabled {
}
}
/* ---- Player search ------------------------------------------------------ */
/* ---- Site search ------------------------------------------------------- */
.player-search {
/* The panel hangs off the NAV, not the button: the nav can wrap on a phone, which moves
the button, but the nav's right edge stays put — so the panel always opens flush right
and never runs off the screen. */
.nav {
position: relative;
width: min(280px, 100%);
/* Wraps only when the row doesn't fit (a staff account's extra links on a phone). */
flex-wrap: wrap;
}
.player-search input {
margin: 0;
height: 40px;
padding: 8px 12px;
.site-search-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
padding: 0;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
border-color: var(--line);
color: var(--muted);
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
background 0.15s ease;
}
.player-search input:focus {
.site-search-toggle:hover,
.site-search-toggle.active {
color: var(--text);
border-color: var(--accent);
background: var(--surface-hi);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
outline: none;
}
.player-search-bubble {
.site-search-panel {
position: absolute;
z-index: 20;
top: calc(100% + 8px);
left: 0;
right: 0;
padding: 6px;
top: calc(100% - 8px);
right: 20px;
width: min(380px, calc(100% - 40px));
max-height: min(70vh, 560px);
overflow-y: auto;
padding: 8px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 12px 30px rgb(0 0 0 / 28%);
}
.player-search-result {
.site-search-panel input {
margin: 0;
height: 42px;
padding: 8px 14px;
border-radius: 999px;
background: var(--surface-hi);
border-color: var(--line);
/* 16px keeps iOS from zooming the page when the box takes focus. */
font-size: 16px;
}
.site-search-panel input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
outline: none;
}
.site-search-group {
margin-top: 8px;
}
.site-search-heading,
.site-search-empty {
margin: 6px 8px 4px;
color: var(--muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.site-search-empty {
margin: 12px 8px 6px;
font-size: 0.9rem;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
.site-search-result {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
gap: 10px;
padding: 8px;
border: 0;
@@ -1825,22 +1903,35 @@ button[type='submit']:disabled {
font-family: var(--body);
}
.player-search-result:hover,
.player-search-result:focus-visible {
.site-search-result > span {
min-width: 0;
}
.site-search-result:hover,
.site-search-result:focus-visible {
background: var(--surface-hi);
}
.player-search-avatar {
.site-search-avatar,
.site-search-thumb {
width: 36px;
height: 36px;
flex: none;
object-fit: cover;
border-radius: 50%;
border: 1px solid var(--line);
background: var(--surface-hi);
}
.player-search-name {
.site-search-avatar {
border-radius: 50%;
}
.site-search-thumb {
width: 54px;
border-radius: 6px;
}
.site-search-name {
display: block;
overflow: hidden;
font-weight: 600;
@@ -1849,7 +1940,7 @@ button[type='submit']:disabled {
white-space: nowrap;
}
.player-search-handle {
.site-search-sub {
display: block;
margin-top: 2px;
color: var(--muted);
@@ -1860,11 +1951,32 @@ button[type='submit']:disabled {
white-space: nowrap;
}
/* The search bar is a third thing in a row built for two, so the nav wraps when it runs
out of room rather than overflowing. No breakpoint: wrapping only happens when the row
doesn't fit, so wide screens are untouched. */
.nav {
flex-wrap: wrap;
/* The account page's banner upload, in the profile banner's shape so what's uploaded
looks as it will across the top of the profile. */
.banner-upload {
display: block;
margin: 12px 0 0;
border-radius: 9px;
border: 1px solid var(--line);
}
.banner-preview {
width: 100%;
height: 220px;
object-fit: cover;
background:
radial-gradient(
circle at 80% 20%,
color-mix(in srgb, var(--accent) 24%, transparent),
transparent 45%
),
linear-gradient(135deg, var(--surface-hi), var(--surface));
}
@media (max-width: 640px) {
.banner-preview {
height: 140px;
}
}
/* ---- Public player profiles -------------------------------------------- */
@@ -2074,3 +2186,19 @@ button[type='submit']:disabled {
border-radius: 3px;
border: 1px solid var(--line);
}
/* The login card's second door, under the password form: an "or" and the Discord button,
separated from the form by a rule so the two aren't read as one. */
.alt-signin {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.alt-signin > .muted:first-child {
margin: 0;
}
+8
View File
@@ -144,6 +144,14 @@ export type Env = SharedHonoEnv & {
* all closes the claim, exactly as an unset one does.
*/
DISCORD_BENEFITS_ROLE_IDS?: string
/**
* Discord role ids whose holders have Rec Room Studio upload access without the
* account's `hasStudio` flag (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`; the var
* `auth` stamps `betastudio` from). `/api/studio-access` answers `granted` through the
* same `hasStudioAccess`, so the Studio settings page agrees with the token. The staff
* list and its add/remove still manage the flag alone.
*/
STUDIO_ROLLOUT?: string
/**
* The bot user's token for the scheduled role refresh (discord-roles.ts). Optional in
* effect: the binding must exist for the deploy, but a placeholder value just leaves the
+51
View File
@@ -55,6 +55,15 @@ export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize'
*/
export const SCOPES = 'identify guilds.members.read'
/**
* What "Sign in with Discord" asks for: the user's id and nothing else. The sign-in has no
* question to ask about the guild — it only needs to know WHO, so the account linked to that
* Discord user can be signed into (or made). Asking for the member read here would put a
* permission on the consent screen that the sign-in never uses; the benefits claim is where
* roles are read, with its own consent.
*/
export const LOGIN_SCOPES = 'identify'
/** Everything the claim needs configured. Resolved per request; see `discordConfig`. */
export interface DiscordConfig {
/** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */
@@ -170,6 +179,15 @@ export async function readSecret(secret: SecretsStoreSecret, name: string): Prom
*/
export const redirectUri = (request: Request): string => new URL('/claim', request.url).toString()
/**
* The sign-in's return leg — its own registered redirect, so Discord sends a sign-in back to
* a page that knows it is one. `/claim` can't double for it: that page needs a session
* before it does anything, and a sign-in is how the session gets made. Same derivation,
* same byte-for-byte rule, same reason the SPA doesn't get to supply it. See App.tsx.
*/
export const loginRedirectUri = (request: Request): string =>
new URL('/login/discord', request.url).toString()
/**
* Swap an authorization code for an access token. Returns null on any refusal — a code
* that was already spent, expired (they live ~1 minute), issued to another app, or paired
@@ -278,6 +296,39 @@ export async function fetchGuildMembership(
}
}
/** Who a token belongs to — the `identify` scope's one answer. */
export interface DiscordUser {
/** The Discord user's id (a snowflake, kept as a string — it exceeds 2^53). */
id: string
/** Their Discord username, for the confirmation line. Display only, never stored. */
username: string
}
/**
* The token owner's own Discord user, or null when Discord won't say or can't be reached.
* The sign-in's one read: it needs the id, and `identify` grants exactly `GET /users/@me`.
*/
export async function fetchUser(accessToken: string): Promise<DiscordUser | null> {
try {
const res = await fetch(`${API_BASE}/users/@me`, {
headers: { authorization: `Bearer ${accessToken}` },
})
if (!res.ok) {
logger.info('discord did not return the token owner', { status: res.status })
return null
}
const user = (await res.json()) as { id?: unknown; username?: unknown }
if (typeof user.id !== 'string' || user.id === '') {
logger.error('discord returned a user with no id')
return null
}
return { id: user.id, username: typeof user.username === 'string' ? user.username : '' }
} catch (err) {
logger.error('could not reach discord to read the token owner', { error: String(err) })
return null
}
}
/**
* Hand the access token back to Discord once the roles have been read.
*
+1 -1
View File
@@ -14,7 +14,7 @@ export const DISCORD_INVITE = 'https://join.recflare.net'
export const DOWNLOAD_URL = 'https://github.com/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/7rvx0Cv9p'
export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/6tSy6tIxY'
/** The public source repo, linked from the homepage and footer. */
export const SOURCE_REPO = 'https://github.com/orgs/recflare/repositories'
+23
View File
@@ -4,6 +4,7 @@ import {
clearDormSaveBlob,
clearPasswordHash,
createGift,
deleteRoomInstancesByRoom,
getAccount,
getImageById,
getOnlinePlayerIds,
@@ -1016,6 +1017,28 @@ export async function giftRoomTokensHandler(c: Context<App>) {
return c.json({ roomId, amount, paid, skipped })
}
/**
* Delete every instance of a room from `room_instance`, from the room's own page. For a
* room whose sessions have gone bad: the next player to matchmake in gets a fresh one.
* Clearing a room with no instances succeeds and deletes nothing.
*/
export async function clearRoomInstancesHandler(c: Context<App>) {
const roomId = Number(c.req.param('roomId'))
if (!Number.isInteger(roomId) || roomId <= 0) {
return c.json({ error: 'A numeric room id is required' }, 400)
}
const deleted = await deleteRoomInstancesByRoom(c.env.DB, roomId)
await recordPlayerAudit(c, 'clear_room_instances', { roomId, deleted })
logger.info('staff cleared room instances', {
moderatorId: staffId(c),
roomId,
deletedCount: deleted.length,
})
return c.json({ roomId, deleted })
}
/**
* Send tokens to EVERYONE ONLINE right now — the server-wide drop behind the account page's
* "Token drop" tab, beside the coach broadcast and the maintenance notice.
+80 -11
View File
@@ -1,4 +1,6 @@
import {
AVATAR_STUDIO_ACCESS,
avatarStudioFlags,
getAccount,
getAccountByUsername,
listStudioAccounts,
@@ -8,6 +10,8 @@ import {
import { logger } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { hasStudioAccess } from '../../auth/src/platform-db'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } from './context'
@@ -20,25 +24,55 @@ import type { App } from './context'
* the signed-in player. Staff add and remove people through the routes under
* `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation.
* The grant is the account's `hasStudio` flag; `auth` stamps `betastudio` from it
* on the next Studio sign-in or refresh. Who granted what is on `audit_log`.
* on the next Studio sign-in or refresh. Who granted what is on `audit_log`. A Discord
* role in the `STUDIO_ROLLOUT` var grants the same access without the flag
* (`hasStudioAccess`); the status below reports it, the staff list does not — that list
* is the accounts staff added by hand.
*/
/** The signed-in player's own flag, and nothing else. 401 with no session. */
/** Whether the signed-in player can upload — flag or rollout role. 401 with no session. */
export async function studioAccessStatusHandler(c: Context<App>) {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
const account = await getAccount(c.env.DB, accountId)
return c.json({ granted: account?.hasStudio === true })
const flags = await avatarStudioFlags(c.env.DB, accountId)
return c.json({
...Object.fromEntries(
Object.entries(AVATAR_STUDIO_ACCESS).map(([kind, mask]) => [kind, (flags & mask) === mask])
),
granted: await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT),
avatar: (flags & 7) !== 0,
screenshare: account?.hasScreenshare === true,
})
}
type AccessKind = 'studio' | 'avatar' | 'screenshare' | keyof typeof AVATAR_STUDIO_ACCESS
function accessKind(value: unknown): AccessKind | null {
if (value === undefined) return 'studio'
if (value === 'studio' || value === 'avatar' || value === 'screenshare') return value
return typeof value === 'string' && Object.hasOwn(AVATAR_STUDIO_ACCESS, value)
? (value as AccessKind)
: null
}
function avatarMask(kind: AccessKind): number {
return kind === 'avatar'
? 7
: Object.hasOwn(AVATAR_STUDIO_ACCESS, kind)
? AVATAR_STUDIO_ACCESS[kind as keyof typeof AVATAR_STUDIO_ACCESS]
: 0
}
/** Every account with the flag set. */
export async function listStudioAccessHandler(c: Context<App>) {
const accounts = await listStudioAccounts(c.env.DB)
const kind = accessKind(c.req.query('kind'))
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
const accounts = await listStudioAccounts(c.env.DB, kind)
return c.json({
accounts: accounts.map((account) => ({
accountId: account.accountId,
username: account.username,
displayName: account.displayName,
enabled: kind !== 'screenshare' || account.hasScreenshare === true,
})),
})
}
@@ -53,7 +87,9 @@ export async function grantStudioAccessHandler(c: Context<App>) {
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return c.json({ error: 'Expected a JSON body' }, 400)
}
const posted = body as { username?: unknown; accountId?: unknown }
const posted = body as { username?: unknown; accountId?: unknown; kind?: unknown }
const kind = accessKind(posted.kind)
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
const username =
typeof posted.username === 'string' ? posted.username.trim().replace(/^@/, '') : ''
const accountId = postedAccountId(posted.accountId)
@@ -72,9 +108,15 @@ export async function grantStudioAccessHandler(c: Context<App>) {
return c.json({ error: 'Name a player by username or account id' }, 400)
}
const alreadyGranted = account.hasStudio === true
if (!alreadyGranted) await updateAccount(c.env.DB, account.accountId, { hasStudio: true })
await recordAudit(c, 'grant_studio_access', {
const alreadyGranted =
avatarMask(kind) !== 0
? ((await avatarStudioFlags(c.env.DB, account.accountId)) & avatarMask(kind)) ===
avatarMask(kind)
: kind === 'screenshare'
? account.hasScreenshare === true
: account.hasStudio === true
await setAccess(c, account.accountId, kind, true)
await recordAudit(c, `grant_${kind}_access`, {
playerId: account.accountId,
username: account.username,
alreadyGranted,
@@ -93,15 +135,22 @@ export async function grantStudioAccessHandler(c: Context<App>) {
* account is a 404.
*/
export async function revokeStudioAccessHandler(c: Context<App>) {
const kind = accessKind(c.req.query('kind'))
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
const accountId = Number(c.req.param('id'))
if (!Number.isInteger(accountId) || accountId <= 0) {
return c.json({ error: 'A numeric player id is required' }, 400)
}
const account = await getAccount(c.env.DB, accountId)
if (!account) return c.json({ error: 'No player has that account id' }, 404)
const removed = account.hasStudio === true
if (removed) await updateAccount(c.env.DB, accountId, { hasStudio: false })
await recordAudit(c, 'revoke_studio_access', {
const removed =
avatarMask(kind) !== 0
? ((await avatarStudioFlags(c.env.DB, accountId)) & avatarMask(kind)) !== 0
: kind === 'screenshare'
? account.hasScreenshare === true
: account.hasStudio === true
await setAccess(c, accountId, kind, false)
await recordAudit(c, `revoke_${kind}_access`, {
playerId: accountId,
username: account.username,
removed,
@@ -109,6 +158,26 @@ export async function revokeStudioAccessHandler(c: Context<App>) {
return c.json({ accountId, granted: false, removed })
}
async function setAccess(c: Context<App>, accountId: number, kind: AccessKind, enabled: boolean) {
const mask = avatarMask(kind)
if (mask !== 0) {
const now = new Date().toISOString()
await c.env.DB.prepare(
`INSERT INTO avatar_studio_access (account_id, flags, created_at, modified_at)
VALUES (?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET
flags = ${enabled ? 'flags | ?' : 'flags & ~?'}, modified_at = excluded.modified_at`
)
.bind(accountId, enabled ? mask : 0, now, now, mask)
.run()
} else {
await updateAccount(
c.env.DB,
accountId,
kind === 'screenshare' ? { hasScreenshare: enabled } : { hasStudio: enabled }
)
}
}
/** A positive integer account id from a JSON field, or null when it isn't one. */
function postedAccountId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value
+411 -10
View File
@@ -14,6 +14,7 @@ import {
updateAccount,
} from '@repo/domain/src/accounts-db'
import { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-db'
import { AVATAR_STUDIO_SCHEMA, avatarStudioFlags } from '@repo/domain/src/avatar-studio'
import { PlatformType } from '@repo/domain/src/enums'
import { getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain/src/gifts-db'
import {
@@ -22,7 +23,11 @@ import {
setPresence,
} from '@repo/domain/src/presence-db'
import { getProgression, PROGRESSION_SCHEMA_DDL } from '@repo/domain/src/progression-db'
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
import {
createRoomInstance,
getRoomInstancesByRoom,
ROOM_INSTANCE_SCHEMA_DDL,
} from '@repo/domain/src/room-instance-db'
// Banning a player moves them into their own dorm, which is a room (created on demand) with
// a subroom — so those tables have to be here, as they are on the shared database.
import { getOrCreateDormRoom, ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
@@ -44,9 +49,12 @@ import {
countAccountsForPlatformIdentity,
getLinksForAccount,
getLinksForPlatform,
hasRecordedRoles,
isPlatformIdentityLinked,
linkPlatformIdentity,
PLATFORM_SCHEMA_DDL,
recordFirstRoles,
unlinkPlatform,
} from '../../../../auth/src/platform-db'
import {
BALANCE_SCHEMA_DDL,
@@ -68,7 +76,13 @@ import {
INVENTORY_CUSTOM_SCHEMA_DDL,
ownedCustomAvatarItemIds,
} from '../../../../econ/src/inventory-custom-db'
import { discordConfig, parseRoleIds, qualifies } from '../../discord'
import {
discordConfig,
loginRedirectUri,
parseRoleIds,
qualifies,
redirectUri,
} from '../../discord'
import { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles'
import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
@@ -119,6 +133,7 @@ beforeAll(async () => {
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// `account` likewise: owned by `auth`, read and (for the benefits claim) written here.
for (const stmt of ACCOUNT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of AVATAR_STUDIO_SCHEMA) await env.DB.prepare(stmt).run()
// And `platform_account`, where a claimed Discord identity is linked.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// `report` and `warning` are owned (and migrated) by `api`; www serves the staff panel
@@ -167,6 +182,8 @@ it('advertises signup and where the other workers live', async () => {
// even be started. Note the client id is NOT leaked by a closed config.
benefitsEnabled: false,
discordAuthorizeUrl: null,
// "Sign in with Discord" is on the same switch, and closed with it.
discordLoginUrl: null,
hosts: {
auth: 'https://auth.rec.example.com',
accounts: 'https://accounts.rec.example.com',
@@ -669,16 +686,220 @@ it('reports where an account stands on benefits', async () => {
headers: { authorization: `Bearer ${token}` },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ hasPlus: false, linked: false })
// No email either: the unlink's one precondition, served alongside so the page can say
// so first. Nothing linked, so there is nothing to unlink yet.
expect(await res.json()).toEqual({
hasPlus: false,
linked: false,
discordLinked: false,
hasEmail: false,
})
// A bare link — what "Sign in with Discord" leaves on an account it just made — is NOT a
// claim: no roles were ever recorded, so the page must still offer the claim. It IS a
// Discord on the account, though, so `discordLinked` says so: the unlink is offered for
// it as much as for a claim's link.
await updateAccount(env.DB, 4003, { hasPlus: true })
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` },
})
// `linked` says THAT a Discord identity is attached, never which one — the id is of no
// use to the page, and an account's linked identities have no business on the wire.
expect(await res.json()).toEqual({ hasPlus: true, linked: true })
expect(await res.json()).toEqual({
hasPlus: true,
linked: false,
discordLinked: true,
hasEmail: false,
})
// Claimed (roles recorded, even none). `linked` says THAT a Discord identity has claimed,
// never which one — the id is of no use to the page, and an account's linked identities
// have no business on the wire.
await updateAccount(env.DB, 4003, { email: 'p@example.com' })
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001', [])
res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` },
})
expect(await res.json()).toEqual({
hasPlus: true,
linked: true,
discordLinked: true,
hasEmail: true,
})
})
// ---- Discord unlink --------------------------------------------------------
// Taking the Discord off an account. The link may be the only way in — an account made by
// "Sign in with Discord" has no email until the player sets one — so the unlink is refused
// until the account has an address to be recovered through, decided from the row and never
// from the page. A password is NOT required: a passwordless account sets one in game.
const unlink = (token: string) =>
SELF.fetch('https://example.com/api/benefits/unlink', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: '{}',
})
it('requires a valid session to unlink a discord', async () => {
expect((await unlink('')).status).toBe(401)
const forged = await generateToken('4020', '', 4, 'not-the-real-secret')
expect((await unlink(forged)).status).toBe(401)
})
it('refuses to unlink a discord until the account has an email address', async () => {
const token = await tokenFor(4021)
await linkPlatformIdentity(env.DB, 4021, PlatformType.Discord, '99021')
// No email — and a password doesn't stand in for one.
await updateAccount(env.DB, 4021, { passwordHash: 'salt:hash' })
const res = await unlink(token)
expect(res.status).toBe(403)
expect(await res.json()).toEqual({
error:
'Add an email address to your account before unlinking Discord, so the account can be recovered without it.',
})
// Refused means untouched: the link is still there.
await expect(isPlatformIdentityLinked(env.DB, 4021, PlatformType.Discord, '99021')).resolves.toBe(
true
)
// An email alone, no password, is enough.
await updateAccount(env.DB, 4021, { passwordHash: undefined, email: 'p@example.com' })
expect((await unlink(token)).status).toBe(200)
await expect(isPlatformIdentityLinked(env.DB, 4021, PlatformType.Discord, '99021')).resolves.toBe(
false
)
})
it('unlinks every discord on the account, and takes plus and studio with it', async () => {
const token = await tokenFor(4022)
await updateAccount(env.DB, 4022, {
username: 'Leaver',
email: 'p@example.com',
hasPlus: true,
hasStudio: true,
})
// A claimed link and (the once-only guard aside) a second identity, both Discord — and a
// Steam login that must survive: only the Discord goes.
await linkPlatformIdentity(env.DB, 4022, PlatformType.Discord, '99022', ['r1'])
await linkPlatformIdentity(env.DB, 4022, PlatformType.Discord, '99023')
await linkPlatformIdentity(env.DB, 4022, PlatformType.Steam, '76561000000000022')
const res = await unlink(token)
expect(res.status).toBe(200)
// The status as it now stands, in `/api/benefits/status`'s shape: the grants went with
// the link.
expect(await res.json()).toEqual({
hasPlus: false,
linked: false,
discordLinked: false,
hasEmail: true,
})
expect((await getLinksForAccount(env.DB, 4022)).map((l) => [l.platform, l.platformId])).toEqual([
[PlatformType.Steam, '76561000000000022'],
])
// The identity is free again: the once-only guard counts nobody holding it now.
await expect(
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, '99022')
).resolves.toBe(0)
// The account remembers it CLAIMED (a link with roles recorded was removed), so a re-link
// won't be a "first" one to the gift — see the test below. Plus and Studio are CLEARED —
// dropped from the blob, not written false — and the rest of the row is untouched.
const after = await getAccount(env.DB, 4022)
expect(after?.hasClaimedDiscord).toBe(true)
expect(after?.hasPlus).toBeUndefined()
expect(after?.hasStudio).toBeUndefined()
expect(after?.username).toBe('Leaver')
expect(after?.email).toBe('p@example.com')
// Nothing left to unlink.
const again = await unlink(token)
expect(again.status).toBe(404)
expect(await again.json()).toEqual({ error: 'No Discord account is linked to this account.' })
})
// Removing a BARE link (a Discord sign-in's, no roles recorded) is not a claim to remember:
// that player's first claim, on whatever account they next link, is still their first.
it('does not remember a claim when the unlinked discord never claimed', async () => {
const token = await tokenFor(4023)
await updateAccount(env.DB, 4023, { email: 'p@example.com' })
await linkPlatformIdentity(env.DB, 4023, PlatformType.Discord, '99024')
expect((await unlink(token)).status).toBe(200)
expect((await getAccount(env.DB, 4023))?.hasClaimedDiscord).toBeUndefined()
// But a claim that found NO roles is still a claim — `[]` is recorded, NULL is not — and
// `unlinkPlatform`'s parsed rows can't tell the two apart, which is why the route asks
// the raw column before it deletes.
await linkPlatformIdentity(env.DB, 4023, PlatformType.Discord, '99024', [])
expect((await unlink(token)).status).toBe(200)
expect((await getAccount(env.DB, 4023))?.hasClaimedDiscord).toBe(true)
})
// The helper itself: returns what it removed (so the caller knows what the rows carried),
// and an account with nothing on that platform yields an empty list and no write.
it('removes every link on one platform and returns them', async () => {
await linkPlatformIdentity(env.DB, 4024, PlatformType.Discord, '99025', ['r1', 'r2'])
await linkPlatformIdentity(env.DB, 4024, PlatformType.Steam, '76561000000000024')
await expect(unlinkPlatform(env.DB, 4024, PlatformType.Oculus)).resolves.toEqual([])
const removed = await unlinkPlatform(env.DB, 4024, PlatformType.Discord)
expect(removed.map((l) => [l.platformId, l.roles])).toEqual([['99025', ['r1', 'r2']]])
await expect(unlinkPlatform(env.DB, 4024, PlatformType.Discord)).resolves.toEqual([])
expect((await getLinksForAccount(env.DB, 4024)).map((l) => l.platform)).toEqual([
PlatformType.Steam,
])
})
// How the claim decides "first claim" now that a Discord link can predate it: a link made by
// "Sign in with Discord" has no roles, and writing the first roles onto it is a one-shot
// flip — the single-winner write the gift is gated on, which a fresh link gets from its
// INSERT instead. Exercised at the helpers, since the route runs through discord.com.
it('tells a sign-in’s bare link from a claim, and records the first roles once', async () => {
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(false)
await linkPlatformIdentity(env.DB, 4010, PlatformType.Discord, '99010')
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(false)
// Nothing to flip on a link that isn't there.
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99011', ['r1'])).resolves.toBe(
false
)
// The first write wins; the second finds roles already recorded and writes nothing.
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99010', ['r1'])).resolves.toBe(
true
)
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99010', ['r2'])).resolves.toBe(
false
)
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(true)
const [link] = await getLinksForAccount(env.DB, 4010)
expect(link.roles).toEqual(['r1'])
// A claim on a FRESH link records roles in the insert itself — an empty list included.
await linkPlatformIdentity(env.DB, 4011, PlatformType.Discord, '99012', [])
await expect(hasRecordedRoles(env.DB, 4011, PlatformType.Discord)).resolves.toBe(true)
})
// ---- Sign in with Discord ------------------------------------------------
// Closed on the same switch as the claim, and refused the same way: the half-configured
// app the tests run with has a client id and secret and nothing else.
it('refuses a discord sign-in while the discord app is only half configured', async () => {
const res = await SELF.fetch('https://example.com/api/login/discord', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: 'whatever' }),
})
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ error: 'Signing in with Discord is not available here.' })
})
// The two return legs are different registered redirects, both derived from the request's
// own origin (never the body). A sign-in can't come back to `/claim`: that page wants a
// session before it does anything, and a sign-in is how one gets made.
it('sends a sign-in back to its own redirect, derived from the request origin', () => {
const request = new Request('https://play.example.com/api/config')
expect(redirectUri(request)).toBe('https://play.example.com/claim')
expect(loginRedirectUri(request)).toBe('https://play.example.com/login/discord')
})
// The once-only guard the claim is built on. Without it, one Discord member holding the
@@ -872,6 +1093,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
'/api/staff/players/1/username-changes',
'/api/staff/players/1/clear-password',
'/api/staff/players/1/clear-dorm',
'/api/staff/rooms/1/clear-instances',
'/api/staff/studio-access',
'/api/staff/players/1/grant-plus',
]
@@ -2257,6 +2479,31 @@ it('clears a player’s dorm by publishing an empty save over the newest one', a
])
})
it('clears every instance of a room, for any staff member', async () => {
const make = (roomId: number, photonRoomId: string) =>
createRoomInstance(env.DB, { ownerAccountId: 8110, roomId, photonRoomId })
const a = await make(7740, 'clear-a')
const b = await make(7740, 'clear-b')
const other = await make(7741, 'clear-other')
// A moderator, not only a developer, may clear a room.
const res = await staffPost('/api/staff/rooms/7740/clear-instances', 8101, {})
expect(res.status).toBe(200)
const body = (await res.json()) as { roomId: number; deleted: number[] }
expect(body.roomId).toBe(7740)
expect(body.deleted.toSorted()).toEqual([a.roomInstanceId, b.roomInstanceId].toSorted())
expect(await getRoomInstancesByRoom(env.DB, 7740)).toEqual([])
// Another room's instances are untouched.
expect((await getRoomInstancesByRoom(env.DB, 7741)).map((i) => i.roomInstanceId)).toEqual([
other.roomInstanceId,
])
// Clearing an empty room succeeds and deletes nothing.
const again = await staffPost('/api/staff/rooms/7740/clear-instances', 8101, {})
expect(await again.json()).toEqual({ roomId: 7740, deleted: [] })
expect((await staffPost('/api/staff/rooms/abc/clear-instances', 8101, {})).status).toBe(400)
})
it('grants a player Plus, developers only', async () => {
await updateAccount(env.DB, 8330, { username: 'Plusless' })
const hasPlus = async () =>
@@ -3022,7 +3269,28 @@ it('tells a signed-in player whether they can upload, and nobody else', async ()
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ granted: false })
expect(await res.json()).toMatchObject({ granted: false, avatar: false, screenshare: false })
// A Discord role named in STUDIO_ROLLOUT grants the same access without the flag, and the
// page reads it from the same helper `auth` stamps `betastudio` from, so the two agree.
// The account has to exist: a link alone, with no account behind it, grants nothing.
await updateAccount(env.DB, 8601, { username: 'StudioFan' })
await linkPlatformIdentity(env.DB, 8601, PlatformType.Discord, '500000000000008601', [
'1077000000000000031',
])
const original = env.STUDIO_ROLLOUT
try {
env.STUDIO_ROLLOUT = '1077000000000000031'
const rolled = await SELF.fetch('https://example.com/api/studio-access', {
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
})
expect(await rolled.json()).toMatchObject({ granted: true, avatar: false, screenshare: false })
// The flag itself never moved; the staff list is still empty of this account.
expect((await getAccount(env.DB, 8601))?.hasStudio).not.toBe(true)
} finally {
if (original === undefined) delete env.STUDIO_ROLLOUT
else env.STUDIO_ROLLOUT = original
}
})
it('lets staff add, list, and remove studio upload access', async () => {
@@ -3059,13 +3327,13 @@ it('lets staff add, list, and remove studio upload access', async () => {
const mine = await SELF.fetch('https://example.com/api/studio-access', {
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
})
expect(await mine.json()).toEqual({ granted: true })
expect(await mine.json()).toMatchObject({ granted: true, avatar: false, screenshare: false })
const list = await staffGet('/api/staff/studio-access', 8110)
expect(list.status).toBe(200)
const body = (await list.json()) as { accounts: Array<{ accountId: number }> }
expect(body.accounts).toEqual([
{ accountId: 8601, username: 'StudioFan', displayName: expect.any(String) },
{ accountId: 8601, username: 'StudioFan', displayName: expect.any(String), enabled: true },
])
expect((await getAccount(env.DB, 8601))?.hasStudio).toBe(true)
@@ -3082,7 +3350,7 @@ it('lets staff add, list, and remove studio upload access', async () => {
const after = await SELF.fetch('https://example.com/api/studio-access', {
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
})
expect(await after.json()).toEqual({ granted: false })
expect(await after.json()).toMatchObject({ granted: false, avatar: false, screenshare: false })
expect(await auditRows('grant_studio_access', 8601)).toEqual([
{
@@ -3158,3 +3426,136 @@ it('lets staff opt themselves in and out of the sandbox pool, and only themselve
[8120, false],
])
})
it('manages Avatar Studio flags and screen sharing independently, with staff authorization', async () => {
await updateAccount(env.DB, 8611, { username: 'AvatarFan', hasStudio: true })
await updateAccount(env.DB, 8612, { username: 'ScreenFan' })
const url = '/api/staff/studio-access'
for (const kind of ['avatar', 'screenshare']) {
const denied = await SELF.fetch(`https://example.com${url}`, {
method: 'POST',
headers: {
authorization: `Bearer ${await tokenFor(8612)}`,
'content-type': 'application/json',
},
body: JSON.stringify({ accountId: 8612, kind }),
})
expect(denied.status).toBe(403)
expect((await SELF.fetch(`https://example.com${url}?kind=${kind}`)).status).toBe(401)
const deniedDelete = await SELF.fetch(`https://example.com${url}/8611?kind=${kind}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${await tokenFor(8612)}` },
})
expect(deniedDelete.status).toBe(403)
}
expect((await staffPost(url, 8110, { accountId: 8611, kind: 'unknown' })).status).toBe(400)
expect((await staffGet(`${url}?kind=unknown`, 8110)).status).toBe(400)
expect((await staffDelete(`${url}/8611?kind=unknown`, 8110)).status).toBe(400)
expect((await staffPost(url, 8110, { accountId: 99999999, kind: 'avatar' })).status).toBe(404)
await env.DB.prepare('INSERT INTO avatar_studio_access VALUES (?, ?, ?, ?)')
.bind(8611, 9, 'before', 'before')
.run()
expect((await staffPost(url, 8110, { username: '@AvatarFan', kind: 'avatar' })).status).toBe(200)
expect(await avatarStudioFlags(env.DB, 8611)).toBe(15)
const repeated = await staffPost(url, 8110, { accountId: 8611, kind: 'avatar' })
expect(await repeated.json()).toMatchObject({ alreadyGranted: true })
const avatarList = await staffGet(`${url}?kind=avatar`, 8110)
expect(await avatarList.json()).toMatchObject({ accounts: [{ accountId: 8611, enabled: true }] })
expect((await getAccount(env.DB, 8611))?.hasScreenshare).toBeUndefined()
await staffDelete(`${url}/8611?kind=avatar`, 8110)
expect(await avatarStudioFlags(env.DB, 8611)).toBe(8)
expect((await getAccount(env.DB, 8611))?.hasStudio).toBe(true)
expect((await staffPost(url, 8110, { username: 'ScreenFan', kind: 'screenshare' })).status).toBe(
200
)
expect((await getAccount(env.DB, 8612))?.hasScreenshare).toBe(true)
const screenList = await staffGet(`${url}?kind=screenshare`, 8110)
expect(await screenList.json()).toMatchObject({ accounts: [{ accountId: 8612, enabled: true }] })
const status = await SELF.fetch('https://example.com/api/studio-access', {
headers: { authorization: `Bearer ${await tokenFor(8612)}` },
})
expect(await status.json()).toMatchObject({ granted: false, avatar: false, screenshare: true })
await staffDelete(`${url}/8612?kind=screenshare`, 8110)
expect((await getAccount(env.DB, 8612))?.hasScreenshare).toBe(false)
expect(await (await staffGet(`${url}?kind=screenshare`, 8110)).json()).toEqual({ accounts: [] })
expect(await auditRows('grant_avatar_access', 8611)).toHaveLength(2)
expect(await auditRows('revoke_screenshare_access', 8612)).toHaveLength(1)
})
it('grants and revokes each Avatar Studio feature without changing other permissions', async () => {
const features = {
'avatar-items': 1,
'avatar-costumes': 2,
'avatar-badges': 4,
'avatar-consumables': 8,
}
const url = '/api/staff/studio-access'
await updateAccount(env.DB, 8621, {
username: 'FeatureFan',
hasStudio: true,
hasScreenshare: false,
})
await env.DB.prepare('INSERT INTO avatar_studio_access VALUES (?, ?, ?, ?)')
.bind(8621, 16, 'original', 'original')
.run()
let expected = 16
const status = async () => {
const response = await SELF.fetch('https://example.com/api/studio-access', {
headers: { authorization: `Bearer ${await tokenFor(8621)}` },
})
expect(response.status).toBe(200)
return response.json() as Promise<Record<string, boolean>>
}
for (const [kind, mask] of Object.entries(features)) {
expect((await status())[kind]).toBe(false)
const denied = await SELF.fetch(`https://example.com${url}`, {
method: 'POST',
headers: {
authorization: `Bearer ${await tokenFor(8621)}`,
'content-type': 'application/json',
},
body: JSON.stringify({ accountId: 8621, kind }),
})
expect(denied.status).toBe(403)
const deniedDelete = await SELF.fetch(`https://example.com${url}/8621?kind=${kind}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${await tokenFor(8621)}` },
})
expect(deniedDelete.status).toBe(403)
expect((await staffPost(url, 8110, { username: '@FeatureFan', kind })).status).toBe(200)
expected |= mask
expect(await avatarStudioFlags(env.DB, 8621)).toBe(expected)
const repeated = await staffPost(url, 8110, { accountId: 8621, kind })
expect(await repeated.json()).toMatchObject({ alreadyGranted: true })
expect((await status())[kind]).toBe(true)
const list = await staffGet(`${url}?kind=${kind}`, 8110)
expect(await list.json()).toMatchObject({
accounts: expect.arrayContaining([
expect.objectContaining({ accountId: 8621, enabled: true }),
]),
})
}
for (const [kind, mask] of Object.entries(features)) {
expect((await staffDelete(`${url}/8621?kind=${kind}`, 8110)).status).toBe(200)
expected &= ~mask
expect(await avatarStudioFlags(env.DB, 8621)).toBe(expected)
expect((await status())[kind]).toBe(false)
for (const [otherKind, otherMask] of Object.entries(features)) {
expect((await status())[otherKind]).toBe((expected & otherMask) !== 0)
}
const list = await staffGet(`${url}?kind=${kind}`, 8110)
const remaining = (await list.json()) as { accounts: Array<{ accountId: number }> }
expect(remaining.accounts.some((a) => a.accountId === 8621)).toBe(false)
const repeat = await staffDelete(`${url}/8621?kind=${kind}`, 8110)
expect(await repeat.json()).toMatchObject({ removed: false })
expect(await auditRows(`grant_${kind}_access`, 8621)).toHaveLength(2)
expect(await auditRows(`revoke_${kind}_access`, 8621)).toHaveLength(2)
}
expect((await getAccount(env.DB, 8621))?.hasStudio).toBe(true)
expect((await getAccount(env.DB, 8621))?.hasScreenshare).toBe(false)
expect(
await env.DB.prepare('SELECT created_at FROM avatar_studio_access WHERE account_id=8621').first(
'created_at'
)
).toBe('original')
})
+239 -20
View File
@@ -6,7 +6,7 @@ import { PlatformType } from '@repo/domain/src/enums'
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
import { getStatSeries } from '@repo/domain/src/stats-db'
import { intVar, logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { generateIdentityAssertion, validateAndGetAccountId } from '@repo/jwt'
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
// Discord identity is stored there as a PlatformType.Discord link — it is an account ↔
@@ -17,8 +17,11 @@ import { validateAndGetAccountId } from '@repo/jwt'
import {
countAccountsForPlatformIdentity,
getLinksForAccount,
hasRecordedRoles,
isPlatformIdentityLinked,
linkPlatformIdentity,
recordFirstRoles,
unlinkPlatform,
} from '../../auth/src/platform-db'
// The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is
// econ's cron paid on demand: the first time an account links its Discord, the claim hands
@@ -31,6 +34,9 @@ import {
discordConfig,
exchangeCode,
fetchGuildMembership,
fetchUser,
LOGIN_SCOPES,
loginRedirectUri,
qualifies,
redirectUri,
revokeToken,
@@ -45,6 +51,7 @@ import {
bansInForceHandler,
clearDormHandler,
clearPasswordHandler,
clearRoomInstancesHandler,
createReportHandler,
getReportHandler,
giftItemHandler,
@@ -132,14 +139,27 @@ const claimant = async (c: Context<App>): Promise<number | null> =>
* (see App.tsx). Everything else about the request is fixed by the server.
*/
function authorizeUrl(request: Request, clientId: string): string {
return consentUrl(clientId, SCOPES, redirectUri(request))
}
/**
* The consent URL for "Sign in with Discord": the same app, the narrower `identify` scope,
* and the sign-in's own return leg (`/login/discord`). Served beside the claim's URL so the
* SPA never assembles either.
*/
function loginAuthorizeUrl(request: Request, clientId: string): string {
return consentUrl(clientId, LOGIN_SCOPES, loginRedirectUri(request))
}
function consentUrl(clientId: string, scope: string, redirect: string): string {
const url = new URL(AUTHORIZE_URL)
url.search = new URLSearchParams({
client_id: clientId,
response_type: 'code',
scope: SCOPES,
redirect_uri: redirectUri(request),
scope,
redirect_uri: redirect,
// Skip Discord's "you've already authorized this app, continue?" interstitial on a
// repeat claim; the player has already pressed a button that says what this does.
// repeat visit; the player has already pressed a button that says what this does.
prompt: 'none',
}).toString()
return url.toString()
@@ -195,6 +215,10 @@ const app = new Hono<App>()
// ids never leave the worker, since it's the worker that asks Discord the question.
benefitsEnabled: discord !== null,
discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : null,
// "Sign in with Discord", on the SAME switch: the sign-in resolves (or makes) the
// account through the Discord link, and only the claim ever writes one with roles, so
// an operator who hasn't set the claim up has no reason to offer the door either.
discordLoginUrl: discord ? loginAuthorizeUrl(c.req.raw, discord.clientId) : null,
hosts: {
auth: authBase(c.env),
accounts: accountsBase(c.env),
@@ -344,6 +368,90 @@ const app = new Hono<App>()
return c.json(token)
})
// ---- Sign in with Discord -----------------------------------------------
/**
* Redeem a Discord authorization code from the SIGN-IN consent (`/login/discord`), prove
* who the Discord user is, and ask `auth` for a session on the account linked to them —
* or, when there is none, for a brand-new account linked to them.
*
* The browser sends ONLY the code, as it does for the claim, and for the same reason: the
* exchange needs the client secret. What is different is what comes after. The claim acts
* on a session the caller already has; this is how one GETS a session, so there is no
* bearer token and nothing here writes to the account. Instead the proven identity is
* signed into an ASSERTION with the shared JWT_SECRET and posted to `auth`'s
* `identity_assertion` grant, which resolves it through the link table exactly as
* `cached_login` resolves a Steam ticket, and answers the ordinary token body. Resolving
* the account — and minting one — stays `auth`'s: www never picks an account id.
*
* Posted over the service binding with the browser's real IP forwarded, like signup, so
* the per-IP cap on the sign-up path counts the browser and not Cloudflare. The token
* response is relayed verbatim; `created: true` on it is the SPA's cue to say an account
* was just made and that setting a password is what makes it usable in the game.
*/
.post('/api/login/discord', async (c) => {
const config = await discordConfig(c.env)
if (!config) return c.json({ error: 'Signing in with Discord is not available here.' }, 403)
type LoginBody = { code?: string }
const { code } = await c.req.json<LoginBody>().catch(() => ({}) as LoginBody)
if (!code) return c.json({ error: 'No Discord authorization code was provided.' }, 400)
const accessToken = await exchangeCode(config, code, loginRedirectUri(c.req.raw))
if (accessToken === null) {
return c.json(
{ error: 'That Discord sign-in could not be completed. Please try again.' },
400
)
}
const user = await fetchUser(accessToken)
await revokeToken(config, accessToken)
if (user === null) {
return c.json(
{ error: 'That Discord sign-in could not be completed. Please try again.' },
400
)
}
const jwtSecret = await c.env.JWT_SECRET.get().catch(() => '')
if (jwtSecret === '') {
logger.error('cannot sign an identity assertion: JWT_SECRET is unreadable')
return c.json({ error: authUnreachable('login') }, 502)
}
const assertion = await generateIdentityAssertion(
{ platform: PlatformType.Discord, platformId: user.id },
jwtSecret
)
const res = await postAuthForm(
c.env,
'/connect/token',
{ grant_type: 'identity_assertion', assertion },
{ clientIp: c.req.header('cf-connecting-ip') }
).catch(() => null)
if (res === null) {
logger.error('could not reach auth to redeem an identity assertion')
return c.json({ error: authUnreachable('login') }, 502)
}
if (!res.ok) {
const failure = await readAuthError(res, 'login')
logger.info('auth refused a discord sign-in', {
status: res.status,
upstream: failure.upstream,
})
return c.json({ error: failure.message }, failure.status)
}
const token = (await res.json().catch(() => null)) as {
access_token?: string
created?: boolean
} | null
if (!token?.access_token) {
logger.error('auth answered a discord sign-in with no access_token')
return c.json({ error: authUnreachable('login') }, 502)
}
logger.info(token.created ? 'created an account from a discord sign-in' : 'discord sign-in')
return c.json({ ...token, discordUsername: user.username })
})
// ---- Discord benefits claim ---------------------------------------------
/**
@@ -358,16 +466,106 @@ const app = new Hono<App>()
.get('/api/benefits/status', async (c) => {
const accountId = await claimant(c)
if (accountId === null) return c.body(null, 401)
const [account, links] = await Promise.all([
const [account, claimed, links] = await Promise.all([
getAccount(c.env.DB, accountId),
hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord),
getLinksForAccount(c.env.DB, accountId),
])
return c.json({
hasPlus: account?.hasPlus ?? false,
// Whether this account is already tied to a Discord identity — not WHICH one. The
// player knows their own Discord; the id is of no use to the page and every reason
// to keep an account's linked identities off the wire.
linked: links.some((link) => link.platform === PlatformType.Discord),
// Whether this account has CLAIMED through a Discord identity — a link with roles
// recorded — not WHICH one. The player knows their own Discord; the id is of no use to
// the page and every reason to keep an account's linked identities off the wire. A
// bare link, which "Sign in with Discord" leaves on a new account, reads as not yet
// claimed: the page then offers the claim, which is what that player still has to do.
linked: claimed,
// Whether ANY Discord identity is on the account, claimed or bare — what the unlink
// button keys on, since a sign-in's bare link is as much a link to take off as a
// claim's. Still never WHICH one.
discordLinked: links.some((link) => link.platform === PlatformType.Discord),
// The unlink's one precondition (see `/api/benefits/unlink`), served beside the link
// so the page can say so before the button fails. The self DTO carries the address
// too; this is the server's own reading of the same row the unlink will decide from.
hasEmail: !!account?.email,
})
})
/**
* Take the account's Discord off it: every `PlatformType.Discord` link goes, whether a
* claim wrote it or "Sign in with Discord" did. Afterwards that Discord opens no account —
* signing in with it makes a NEW one — and the benefits claim's once-only guard lets it
* claim on another account.
*
* Refused unless the account has an EMAIL ADDRESS. The link may be the only way into the
* account: an account made by "Sign in with Discord" has no password and no email until
* the player sets them, and removing its link would leave nothing to recover the account
* by. An address is enough — it is what the account can be recovered through — so a
* password is NOT required: a player with an email and no password sets one in game, as
* any passwordless account does. Checked here, on the row, and not trusted from the page
* (which shows the same flag from `/api/benefits/status` so it can say so before anyone
* presses anything).
*
* The grants the link carried go with it. `hasPlus` and `hasStudio` are both cleared:
* the Discord roles are what the claim granted Plus for and what the Studio rollout reads,
* and a player taking the link off is giving that proof back — unlike a role that merely
* lapses, which the claim deliberately never revokes for. This clears an operator's
* grant too (`runx admin grant-plus`/`grant-studio` write the same flags, and nothing on
* the row says which wrote them); the page warns, and staff can re-grant. Both reach the
* game on the NEXT token, like every account flag.
*
* What it does NOT do is forget that the account has claimed. A link with roles recorded
* was a claim, and the first-link supporter gift keys on "never claimed before", read from
* the link table — which would read as never-linked once the row is gone. So a claimed
* link's removal stamps `hasClaimedDiscord` on the account, and the claim consults both;
* otherwise link → unlink → link would pay the one-time box on every cycle.
*
* Not gated on the Discord configuration: the link is in the table whether or not the
* operator still runs the claim, and a player can always take it off. Answers the same
* shape `/api/benefits/status` does, as the account now stands.
*/
.post('/api/benefits/unlink', async (c) => {
const accountId = await claimant(c)
if (accountId === null) return c.body(null, 401)
const account = await getAccount(c.env.DB, accountId)
if (!account?.email) {
return c.json(
{
error:
'Add an email address to your account before unlinking Discord, so the account can be recovered without it.',
},
403
)
}
// Whether what's being removed includes a CLAIM — a link with `role` recorded, even as
// `[]`. Asked of the raw column BEFORE the delete: the parsed `PlatformLink` reads a
// NULL role and an empty list both as `[]`, and only the column tells a sign-in's bare
// link from a claim that found no roles.
const claimed = await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord)
const removed = await unlinkPlatform(c.env.DB, accountId, PlatformType.Discord)
if (removed.length === 0) {
return c.json({ error: 'No Discord account is linked to this account.' }, 404)
}
// One write: remember the claim (when there was one), and take back what the link
// carried. `undefined` drops the key from the blob, which is how "no Plus" is spelled.
await updateAccount(c.env.DB, accountId, {
hasClaimedDiscord: claimed || account.hasClaimedDiscord === true ? true : undefined,
hasPlus: undefined,
hasStudio: undefined,
})
logger.info('unlinked a discord from an account', {
accountId,
links: removed.length,
claimed,
hadPlus: account.hasPlus === true,
hadStudio: account.hasStudio === true,
})
return c.json({
hasPlus: false,
linked: false,
discordLinked: false,
hasEmail: true,
})
})
@@ -399,7 +597,8 @@ const app = new Hono<App>()
* Nothing here ever REVOKES Plus: losing the Discord role later leaves the flag set.
* That's deliberate for now — a sweep would need a bot token to enumerate the guild,
* which this design specifically avoids — but it does mean the flag records "held the
* role once", not "holds it today".
* role once", not "holds it today". The player's own unlink (`/api/benefits/unlink`) is
* the one thing that takes it back.
*
* The FIRST time an account links a Discord, the claim also pays the supporter gift
* econ's cron would (`DISCORD_ROLE_TOKENS`, the amount for the best of the roles just
@@ -456,12 +655,6 @@ const app = new Hono<App>()
PlatformType.Discord,
membership.userId
)
// Whether this claim is the account's FIRST Discord link — what the one-time gift below
// keys on. Decided before the write, from the table: an account re-claiming with the
// same Discord is not first (`alreadyMine`), and neither is one that already carries a
// DIFFERENT Discord identity, which the once-only guard doesn't stop. Only asked when
// it could be true, to spare the re-claim a query.
let firstLink = false
if (!alreadyMine) {
const claimedElsewhere = await countAccountsForPlatformIdentity(
c.env.DB,
@@ -477,9 +670,32 @@ const app = new Hono<App>()
409
)
}
const links = await getLinksForAccount(c.env.DB, accountId)
firstLink = !links.some((link) => link.platform === PlatformType.Discord)
}
// Whether this is the account's FIRST CLAIM — what the one-time gift below keys on.
// Decided before the write, from the table: a claim is recorded as roles on a Discord
// link, so an account with any Discord link carrying roles (this identity's, or a
// DIFFERENT one the once-only guard doesn't stop) has claimed before. A link with NO
// roles has not: that is what "Sign in with Discord" leaves on an account it just made,
// and that player's first claim is still their first.
//
// AND from the account: an unlink removes a claimed link's row, and stamps
// `hasClaimedDiscord` on the account so the table reading as never-linked afterwards
// doesn't make the next link a "first" one. Both are asked; either says not-first.
const firstClaim =
(await getAccount(c.env.DB, accountId))?.hasClaimedDiscord !== true &&
!(await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord))
// On a link that already exists without roles, writing them is the claim's one-shot
// write — the single-winner guard a fresh link gets from its INSERT below. Asked only
// when the link is the caller's: on anyone else's it would be refused above.
const recordedFirst =
alreadyMine &&
(await recordFirstRoles(
c.env.DB,
accountId,
PlatformType.Discord,
membership.userId,
membership.roles
))
// The link is written for EVERY guild member, qualifying role or not, and it is
// idempotent: INSERT OR IGNORE (so `linkedAt` keeps the FIRST claim's time), with the
@@ -519,13 +735,14 @@ const app = new Hono<App>()
}
// The one-time supporter gift: the box econ's cron would hand this member, paid now,
// once. Gated on the insert having happened too (`created`), so two claims racing on
// once. Gated on a single-winner write having happened too — the insert (`created`), or
// the first roles onto a sign-in's bare link (`recordedFirst`) — so two claims racing on
// one fresh account can't both pay. Best-effort AFTER Plus is granted and the link is
// written: a grant that fails partway is logged with what to check, as econ's cron
// logs its own, and the claim still answers — the player got what they came for, and
// a retry that also failed partway is how a balance gets credited twice.
let tokensAwarded: number | null = null
if (created && firstLink) {
if ((created || recordedFirst) && firstClaim) {
try {
const role = await grantDiscordRoleGift(
c.env,
@@ -597,6 +814,8 @@ const app = new Hono<App>()
.post('/api/staff/players/:id/gift-xp', requireDeveloper, giftXpHandler)
// Everyone standing in a room, across all of its instances.
.post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler)
// Delete every live instance of a room — for one whose sessions are stuck. All staff.
.post('/api/staff/rooms/:roomId/clear-instances', clearRoomInstancesHandler)
// The account page's token drop: the same gift to everyone online at once.
.post('/api/staff/online/gift-tokens', requireDeveloper, giftOnlineTokensHandler)
// Its sibling: the same gift to every account whose Discord link holds a role, offline
+61 -8
View File
@@ -11,6 +11,7 @@
* it from `@repo/domain` (each uses the subset it needs).
*/
import { AVATAR_STUDIO_ACCESS } from './avatar-studio'
import { bindPlaceholders, chunkForBinds } from './d1-binds'
/**
@@ -168,7 +169,10 @@ export interface Account {
*
* So never read a Discord link as a precondition for Plus, and never revoke one because
* the other is missing: a manually granted account has `hasPlus` and no link at all, and
* that is a normal, supported state.
* that is a normal, supported state. The one thing that clears it besides an operator is
* the player UNLINKING their Discord (`www` `POST /api/benefits/unlink`), which clears
* this and `hasStudio` together — the player is handing back the proof — and does so
* whoever set them, since nothing on the row says which.
*
* Nothing reads this per request. `auth` stamps it into every token it mints as the
* `rn.plus` claim, and `econ` decides the CampusCard and the subscriber discount from
@@ -177,6 +181,21 @@ export interface Account {
* real: the website's claim page warns about it, and so does `grant-plus`.
*/
hasPlus?: boolean
/**
* Whether this account has EVER claimed its Discord benefits — had roles recorded on a
* `PlatformType.Discord` link by `www`'s `POST /api/benefits/claim` — even if that link is
* gone. Set only by the website's Discord UNLINK, when the link it removes carries roles:
* while the link exists the link table itself says so (`hasRecordedRoles`), and a link
* that never claimed has nothing to remember.
*
* What it protects is the first-link supporter gift. The claim pays that box the FIRST
* time an account links a Discord, deciding "first" from the link table — which, after an
* unlink, reads as if the account had never linked. Without this flag, link → unlink →
* link would pay the one-time gift on every cycle. Nothing else reads it: `hasPlus` stands
* on its own (above), and the once-only-per-Discord-user guard is the link table's.
* Absent/false means no claim remembered.
*/
hasClaimedDiscord?: boolean
/**
* Whether this account may upload from Rec Room Studio. The editor decides Full access
* by reading the token for a `role` claim of exactly `betastudio`, which `auth` stamps
@@ -188,6 +207,8 @@ export interface Account {
* open keeps its old access until it signs in again or refreshes.
*/
hasStudio?: boolean
/** Staff override for screen sharing. Absent means screen sharing is disabled. */
hasScreenshare?: boolean
}
interface AccountRow {
@@ -290,12 +311,29 @@ export async function getAccountByUsername(
export const SEARCH_LIMIT = 20
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
export const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
/**
* The smallest string that sorts after every string beginning with `prefix` under
* SQLite's default BINARY (byte-wise) collation: the prefix followed by U+10FFFF, whose
* UTF-8 encoding (F4 8F BF BF) is the highest valid sequence. The pair
* `>= prefix AND < prefixUpperBound(prefix)` is the prefix match as a range.
*/
export const prefixUpperBound = (prefix: string): string => `${prefix}\u{10FFFF}`
/**
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered
* alphabetically. Backed by the indexed `username_lower` generated column, so the
* `name%` LIKE stays index-friendly. Returns up to `limit` matches.
* alphabetically. Returns up to `limit` matches.
*
* The prefix is applied as a RANGE on the indexed `username_lower` generated column
* (`>= name AND < name+U+10FFFF`), not as `LIKE 'name%'`. SQLite only turns a LIKE into
* an index seek when the LIKE is case-sensitive or the index is NOCASE, and never when
* it carries an ESCAPE clause, so the LIKE form was a full index scan on every call —
* 48k rows read per keystroke in the site's search bar, tens of millions a day on D1's
* meter. The range is a seek (`SEARCH … (username_lower>? AND username_lower<?)`) and
* walks the index in `ORDER BY` order, so the LIMIT stops it after `limit` rows. The
* LIKE stays as a residual filter on those rows so the match is exactly "begins with",
* with the user's `%`/`_` taken literally.
*/
export async function searchAccounts(
db: D1Database,
@@ -306,9 +344,11 @@ export async function searchAccounts(
if (q === '') return []
const { results } = await db
.prepare(
`SELECT data FROM account WHERE username_lower LIKE ?1 ESCAPE '\\' ORDER BY username_lower LIMIT ?2`
`SELECT data FROM account
WHERE username_lower >= ?1 AND username_lower < ?2 AND username_lower LIKE ?3 ESCAPE '\\'
ORDER BY username_lower LIMIT ?4`
)
.bind(`${escapeLike(q)}%`, limit)
.bind(q, prefixUpperBound(q), `${escapeLike(q)}%`, limit)
.all<AccountRow>()
return parseAll(results)
}
@@ -463,10 +503,23 @@ export async function listInfluencerIds(db: D1Database): Promise<number[]> {
* Every account flagged `hasStudio`, lowest id first — the website's staff list. A JSON
* scan rather than a generated column: the list is short and only staff read it.
*/
export async function listStudioAccounts(db: D1Database): Promise<Account[]> {
export async function listStudioAccounts(
db: D1Database,
kind: 'studio' | 'avatar' | 'screenshare' | keyof typeof AVATAR_STUDIO_ACCESS = 'studio'
): Promise<Account[]> {
const mask =
kind === 'avatar'
? 7
: Object.hasOwn(AVATAR_STUDIO_ACCESS, kind)
? AVATAR_STUDIO_ACCESS[kind as keyof typeof AVATAR_STUDIO_ACCESS]
: 0
const { results } = await db
.prepare(
`SELECT data FROM account WHERE json_extract(data, '$.hasStudio') = 1 ORDER BY account_id`
mask !== 0
? `SELECT data FROM account WHERE account_id IN (SELECT account_id FROM avatar_studio_access WHERE (flags & ${mask}) != 0) ORDER BY account_id`
: kind === 'screenshare'
? `SELECT data FROM account WHERE json_extract(data, '$.hasScreenshare') = 1 ORDER BY account_id`
: `SELECT data FROM account WHERE json_extract(data, '$.hasStudio') = 1 ORDER BY account_id`
)
.all<AccountRow>()
return parseAll(results)
+156
View File
@@ -0,0 +1,156 @@
/** Shared persisted Studio authoring state. Existing storefront items remain unchanged. */
export const AVATAR_STUDIO_SCHEMA = [
`CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`,
`CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id)`,
`CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL)`,
]
export interface AvatarStudioItem {
CustomAvatarItemId: string
CreatorAccountId: number
OutfitType: number
Accessibility: number
CurrentSaves: Array<Record<string, unknown>>
[key: string]: unknown
}
export interface AvatarStudioSave {
id: number
item_id: string
body_type: number
unity_asset_id: string
source_filename: string
source_hash: string
thumbnail: string
moderation_image: string | null
configuration: string
description: string | null
update_current: number
created_at: string
}
/** Independent authoring permissions reported by Studio's access override. */
export const AVATAR_STUDIO_ACCESS = {
'avatar-items': 1,
'avatar-costumes': 2,
'avatar-badges': 4,
'avatar-consumables': 8,
} as const
export async function avatarStudioFlags(db: D1Database, accountId: number): Promise<number> {
const row = await db
.prepare('SELECT flags FROM avatar_studio_access WHERE account_id=?')
.bind(accountId)
.first<{ flags: number }>()
return row?.flags ?? 0
}
export const AVATAR_STUDIO_SLOTS = [
0, 2, 3, 10, 20, 100, 101, 102, 103, 104, 200, 203, 300, 301, 500, 501, 502, 503, 504, 505, 506,
507, 508, 509, 510, 511, 512, 1000,
]
export function avatarItemAccessBit(outfitType: number): number {
return outfitType === 1000 ? 4 : outfitType >= 500 && outfitType <= 512 ? 2 : 1
}
export async function avatarStudioItem(
db: D1Database,
id: string
): Promise<AvatarStudioItem | null> {
const row = await db
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
.bind(id)
.first<{ data: string }>()
return row ? (JSON.parse(row.data) as AvatarStudioItem) : null
}
export async function avatarStudioSave(
db: D1Database,
itemId: string,
id: number
): Promise<AvatarStudioSave | null> {
return db
.prepare('SELECT * FROM avatar_studio_save WHERE item_id=? AND id=?')
.bind(itemId, id)
.first<AvatarStudioSave>()
}
export async function avatarSaveDto(db: D1Database, row: AvatarStudioSave, target = 0) {
const { results } = await db
.prepare(
'SELECT unity_asset_id AS UnityAssetId,target AS Target,version AS Version,filename AS Filename,hash AS Hash,kind FROM unity_asset WHERE unity_asset_id=?'
)
.bind(row.unity_asset_id)
.all<{
UnityAssetId: string
Target: number
Version: number
Filename: string
Hash: string
kind: string
}>()
const main = results.find((a) => a.Target === target && a.kind === 'main')
const material = results.find((a) => a.Target === target && a.kind === 'material')
return {
CustomAvatarItemSaveId: row.id,
CustomAvatarItemId: row.item_id,
UnityAssetId: row.unity_asset_id,
BodyType: row.body_type,
CreatedAt: row.created_at,
ModifiedAt: row.created_at,
Description: row.description,
ThumbnailFileName: row.thumbnail,
AdditionalConfiguration: row.configuration,
SourceUnityAssetFilename: row.source_filename,
SourceUnityAssetHash: row.source_hash,
ModerationImageFileName: row.moderation_image,
UnityAsset: main?.Filename ?? null,
UnityAssetHash: main?.Hash ?? null,
UnityAsset2: material?.Filename ?? null,
UnityAsset2Hash: material?.Hash ?? null,
BakedAssets: results.filter((a) => a.kind !== 'stripped').map(({ kind: _kind, ...a }) => a),
}
}
/** Publish exactly this built revision for its body type, preserving other body types. */
export async function publishAvatarSave(
db: D1Database,
itemId: string,
saveId: number,
automatic = false,
attempt = 0
): Promise<boolean> {
const item = await avatarStudioItem(db, itemId)
const save = await avatarStudioSave(db, itemId, saveId)
if (!item || !save) return false
const dto = await avatarSaveDto(db, save)
// Badge images are uploaded and published directly by the SDK, without a Unity build.
if (!dto.UnityAsset && item.OutfitType !== 1000) return false
const quest = await avatarSaveDto(db, save, 2)
const {
SourceUnityAssetFilename: _source,
SourceUnityAssetHash: _hash,
BakedAssets: _baked,
...published
} = dto
// JSON mutation is conditional on the complete prior snapshot: concurrent body-type
// publications cannot overwrite each other or resurrect a deleted item.
const prior = JSON.stringify(item)
item.CurrentSaves = [
...(item.CurrentSaves ?? []).filter((s) => s.BodyType !== save.body_type),
{
...published,
OutfitType: item.OutfitType,
QuestUnityAssetHash: quest.UnityAssetHash,
QuestUnityAsset2Hash: quest.UnityAsset2Hash,
},
]
item.ModifiedAt = new Date().toISOString()
item.ThumbnailImageFilename = save.thumbnail
if (item.OutfitType === 1000)
item.CustomBadgeMetadata = { RoomId: JSON.parse(save.configuration)?.d?.d?.r ?? 0 }
const result = await db
.prepare(
`UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)
AND (?=0 OR ?=(SELECT MAX(id) FROM avatar_studio_save WHERE item_id=? AND body_type=?))`
)
.bind(JSON.stringify(item), itemId, prior, automatic ? 1 : 0, saveId, itemId, save.body_type)
.run()
if (!result.meta.changes && attempt < 5 && (await avatarStudioItem(db, itemId)))
return publishAvatarSave(db, itemId, saveId, automatic, attempt + 1)
return result.meta.changes > 0
}
+1
View File
@@ -31,3 +31,4 @@ export * from './outfits-db'
export * from './progression-db'
export * from './relationships-db'
export * from './validation'
export * from './avatar-studio'
+19
View File
@@ -476,6 +476,25 @@ export async function deleteEmptyRoomInstances(
return results.map((r) => r.id)
}
/**
* Delete EVERY instance of a room, occupied or not — the staff "Clear instances" button
* for a room whose sessions are stuck. Presence is left alone: a player still in a
* deleted instance keeps their row until it expires or they move, and their next
* matchmake lands them in a fresh instance.
*
* Returns the ids deleted.
*/
export async function deleteRoomInstancesByRoom(db: D1Database, roomId: number): Promise<number[]> {
const { results } = await db
.prepare(
`DELETE FROM room_instance WHERE room_id = ?1
RETURNING json_extract(data, '$.roomInstanceId') AS id`
)
.bind(roomId)
.all<{ id: number }>()
return results.map((r) => r.id)
}
/**
* The oldest joinable public instance of a room (not private, not full, joins
* enabled, not already in progress, not set to `Ignore`) that is running `gameVersion`,
+130 -12
View File
@@ -17,6 +17,7 @@
* database read-only and uses `getRoomById`. Each imports the subset it needs.
*/
import { escapeLike, prefixUpperBound } from './accounts-db'
import { chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
import { Accessibility, Role } from './enums'
import { countPlayersByRoom } from './presence-db'
@@ -72,6 +73,13 @@ export const ROOM_SCHEMA_DDL: string[] = [
// feeds all order by it eventually; the WHERE clause is the point, not the key.
`CREATE INDEX IF NOT EXISTS idx_room_public ON room (room_id)
WHERE is_dorm IS NOT 1 AND accessibility = 1`,
// The searchable rooms by lowercased name (migrations/0032_room_search_name.sql) — what
// {@link searchRoomsByName} seeks. PARTIAL over exactly {@link SEARCHABLE_WHERE}, so the
// walk never steps over a dorm or a private room: the rows it reads are the rows it
// serves. `idx_rooms_name_lower` can't do that — it holds every dorm, and a prefix seek
// on it checks each private room it passes against the blob.
`CREATE INDEX IF NOT EXISTS idx_room_search_name ON room (name_lower)
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1`,
// A room's tags, one row per tag (migrations/0013_room_tag.sql). Modelled on the
// `api` worker's `event_tag`, and the table is AUTHORITATIVE: `serializeRoom` strips
// `Tags` from the blob and the reads re-attach it, the same arrangement `subroom` and
@@ -292,6 +300,35 @@ export function roomRoles(room: Room): RoomRole[] {
}))
}
/**
* Whether a `Roles` entry records a role the account actually HOLDS, as opposed to one it has
* only been offered. An invite is an entry with `Role` {@link Role.None} and the offer on
* `InvitedRole`; it stays that way until the player accepts ({@link answerRoomRoleInvite})
* or declines (which drops the entry). Tolerates the raw blob shape — older rooms carry
* entries without a `Role` key, which read as None, and a malformed entry is not a role.
*/
function holdsRole(entry: unknown): boolean {
if (entry === null || typeof entry !== 'object') return false
return Number((entry as Partial<RoomRole>).Role ?? Role.None) !== Role.None
}
/**
* The room as SERVED, with pending invites dropped from its `Roles` — a shallow copy; the
* room passed in is untouched, so a caller can keep writing from it. Entries are kept in the
* blob's own shape (not normalised through {@link roomRoles}), so a room that had no
* pending invite serializes exactly as before. A room with no `Roles` array is returned as
* is.
*
* Apply this at the response boundary ONLY, never to a room that is about to be written
* back. Rooms are stored as whole blobs and every writer rewrites the blob from the loaded
* room, so dropping the pending entries any earlier would silently erase every open invite
* on the room's next unrelated edit (a renamed room, a new load screen).
*/
export function withConfirmedRoles<T extends Room>(room: T): T {
if (!Array.isArray(room.Roles)) return room
return { ...room, Roles: room.Roles.filter(holdsRole) }
}
/**
* The statements that bring `room_role` in line with a room's `Roles`: drop the room's rows,
* then insert each entry whole, as {@link roomRoles} reads it. Every entry is stored,
@@ -1924,6 +1961,19 @@ export async function saveSubRoomData(
createdAt: new Date().toISOString(),
unityAssetId: input.unityAssetId,
})
// A live client save changes the scene data while reusing the baked Studio
// asset. Its export manifest belongs to that asset and is needed again on the
// save's immediate reload (prefabs/materials live in separate bundles).
// A new Studio source upload must wait for its own cloud-build manifest.
if (
!input.unitySource &&
typeof input.unityAssetId === 'string' &&
typeof previous?.UnityAssetId === 'string' &&
input.unityAssetId.toLowerCase() === previous.UnityAssetId.toLowerCase() &&
previous.ServerData != null
) {
saveData.ServerData = previous.ServerData
}
if (input.unitySource) {
saveData.StudioSourceAsset = input.unitySource
saveData.SavedOnPlatform = null
@@ -2450,7 +2500,7 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
* it.
*/
const serializeRoom = (room: Room): string => {
const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room
const { SubRooms: _subRooms, Tags: _tags, Badges: _badges, Stats: stats, ...rest } = room
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
}
@@ -2512,13 +2562,21 @@ export async function attachStudioUnityAssets(
db: D1Database,
saves: SubRoomDataSave[]
): Promise<void> {
const ids = [
...new Set(
saves
.map((save) => save.UnityAssetId)
.filter((id): id is string => typeof id === 'string' && id !== '')
),
]
// The cloud build partitions prefab/material exports into separate bundles.
// The main scene keeps external references to them, so the loader must receive
// every export, including shared dependencies, before it instantiates the scene.
const assetIds = (save: SubRoomDataSave): string[] => {
if (typeof save.UnityAssetId !== 'string' || save.UnityAssetId === '') return []
const serverData = save.ServerData as { Exports?: unknown } | null | undefined
const exports = Array.isArray(serverData?.Exports) ? serverData.Exports : []
const ids = new Set([save.UnityAssetId.toLowerCase()])
for (const item of exports) {
const id = (item as { u?: unknown } | null)?.u
if (typeof id === 'string' && id !== '') ids.add(id.toLowerCase())
}
return [...ids]
}
const ids = [...new Set(saves.flatMap(assetIds))]
if (ids.length === 0) return
const builds = (await listUnityAssetBuilds(db, ids)).filter((build) =>
isStudioBundleFilename(build.Filename)
@@ -2533,7 +2591,7 @@ export async function attachStudioUnityAssets(
for (const save of saves) {
const id = save.UnityAssetId
if (typeof id !== 'string') continue
const baked = bakedUnityAssets(byAsset.get(id.toLowerCase()) ?? [])
const baked = bakedUnityAssets(assetIds(save).flatMap((assetId) => byAsset.get(assetId) ?? []))
if (baked.length === 0) continue
save.UnitySubAssets = baked
}
@@ -2842,10 +2900,31 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
// game's Nullable<DateTime> decoder and discards the entire owned-room list.
if (room.PublishedAt === '') room.PublishedAt = null
}
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms)])
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms), attachBadges(db, rooms)])
return rooms
}
/** Published room-linked image badges; drafts remain on the creator's Studio shelf. */
async function attachBadges(db: D1Database, rooms: Room[]): Promise<void> {
if (!rooms.length) return
// `custom_avatar_item` is owned by api's migrations (0015, badge index 0035), both long
// applied to the shared database; this no longer probes `sqlite_master` for it.
// Served by `idx_custom_avatar_item_badge_room` (api migrations/0035), a PARTIAL index over
// `outfit_type = 1000` keyed by the `json_extract(data,'$.CustomBadgeMetadata.RoomId')`
// expression. SQLite matches both only as written: drop or rephrase either term and this
// goes back to scanning every custom avatar item on every room page.
const rows = await db
.prepare(
"SELECT data,json_extract(data,'$.CustomBadgeMetadata.RoomId') AS room_id FROM custom_avatar_item WHERE outfit_type=1000 AND accessibility<>0 AND json_array_length(json_extract(data,'$.CurrentSaves'))>0 AND json_extract(data,'$.CustomBadgeMetadata.RoomId') IN (SELECT value FROM json_each(?))"
)
.bind(JSON.stringify(rooms.map(roomIdOf)))
.all<{ data: string; room_id: number }>()
for (const room of rooms)
room.Badges = rows.results
.filter((r) => r.room_id === roomIdOf(room))
.map((r) => JSON.parse(r.data))
}
/** A single subroom of a room (columns authoritative), or null if it doesn't exist. */
export async function getSubRoom(
db: D1Database,
@@ -3275,6 +3354,13 @@ export async function countRoomsByCreator(db: D1Database, accountId: number): Pr
* room the player made. A room matching BOTH halves appears once — the roles half is an
* EXISTS, not a join.
*
* A PENDING invite is not a job. An invited account holds a `Roles` entry with `Role` 0 and
* the offer on `InvitedRole` until it answers, and that entry is indexed like any other —
* so without the `role <> 0` guard a room showed up in the player's "rooms you contribute
* to" (and, through `ownedby/me`, was considered for "rooms you own") the moment someone
* invited them, before they had accepted anything. Only a held role counts here; the offer
* reaches them as a `RoomCoOwnerInvited` message, not through this list.
*
* Roles live inside the room blob, so the roles half reads `room_role`, the trigger-kept
* index of them, rather than a `json_each` over every room's `$.Roles` (which scanned and
* parsed the whole table on every call). Both halves are index lookups, and SQLite runs
@@ -3286,9 +3372,9 @@ export async function getContributedRooms(db: D1Database, accountId: number): Pr
.prepare(
`SELECT ${ROOM_COLUMNS} FROM room
WHERE creator_account_id = ?1
OR room_id IN (SELECT room_id FROM room_role WHERE account_id = ?1)`
OR room_id IN (SELECT room_id FROM room_role WHERE account_id = ?1 AND role <> ?2)`
)
.bind(accountId)
.bind(accountId, Role.None)
.all<RoomRow>()
return (await hydrateRooms(db, parseAll(results))).filter((r) => r.IsDorm !== true)
}
@@ -3613,6 +3699,38 @@ export async function searchRooms(
}
}
/**
* Searchable rooms whose name BEGINS with `query` (case-insensitive), alphabetically, at
* most `take` — the website's header search, which asks on every pause in typing.
*
* Not {@link searchRooms}: that is the game's search, a SUBSTRING match no index can
* serve, so it reads every searchable room per call. This is a range seek on
* `idx_room_search_name` (`>= q AND < q+U+10FFFF`, as {@link searchAccounts} does for
* usernames), walked in `ORDER BY` order so the LIMIT stops it: D1 bills `take` rows
* however many rooms exist. The WHERE repeats {@link SEARCHABLE_WHERE} verbatim because
* SQLite only uses a partial index whose condition the query states; the LIKE is a
* residual so `%`/`_` match literally. Rooms come back un-hydrated (no subrooms, tags or
* badges) — the caller draws a name, a picture and the counters.
*/
export async function searchRoomsByName(
db: D1Database,
query: string,
take: number
): Promise<Room[]> {
const q = query.trim().toLowerCase()
if (q === '' || take <= 0) return []
const { results } = await db
.prepare(
`SELECT ${ROOM_COLUMNS} FROM room
WHERE ${SEARCHABLE_WHERE}
AND name_lower >= ?1 AND name_lower < ?2 AND name_lower LIKE ?3 ESCAPE '\\'
ORDER BY name_lower LIMIT ?4`
)
.bind(q, prefixUpperBound(q), `${escapeLike(q)}%`, take)
.all<RoomRow>()
return parseAll(results).filter(isSearchable)
}
/**
* Search suggestions for the box the player is typing in
* (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms.
+5 -1
View File
@@ -7,7 +7,11 @@ export {
generateToken,
generateStudioBuildValidationToken,
validateStudioBuildValidationToken,
studioBuildValidationId,
generatePhotonAuthToken,
generateIdentityAssertion,
validateIdentityAssertion,
IDENTITY_ASSERTION_TTL_SECONDS,
TOKEN_TTL_SECONDS,
} from './jwt'
export type { PhotonAuthClaims } from './jwt'
export type { IdentityAssertion, PhotonAuthClaims } from './jwt'
+93 -6
View File
@@ -18,7 +18,7 @@ import { GAME_VERSION, gameBuild } from '@repo/domain'
// last a day so the client doesn't have to refresh it.
export const TOKEN_TTL_SECONDS = 86400
/** A build host can check component text, but cannot act as an account. */
/** A build host can validate content and its job's grants, but cannot act as an account. */
export async function generateStudioBuildValidationToken(
buildId: string,
secret: string
@@ -39,19 +39,27 @@ export async function validateStudioBuildValidationToken(
request: Request,
secret: string
): Promise<boolean> {
return (await studioBuildValidationId(request, secret)) !== null
}
/** Signed job identity for read-only, job-scoped validation routes. */
export async function studioBuildValidationId(
request: Request,
secret: string
): Promise<string | null> {
const header = request.headers.get('Authorization') ?? ''
if (!header.startsWith('Bearer ')) return false
if (!header.startsWith('Bearer ')) return null
try {
const payload = await verify(header.slice(7), secret, 'HS256')
return (
payload.sub === undefined &&
return payload.sub === undefined &&
payload.aud === 'studio-build-validation' &&
payload.scope === 'studio.build.validate' &&
typeof payload.buildId === 'string' &&
/^[1-9]\d*$/.test(payload.buildId)
)
? (payload.buildId as string)
: null
} catch {
return false
return null
}
}
@@ -320,3 +328,82 @@ export async function generateToken(
secret
)
}
/**
* The `aud` of an identity assertion — what tells one apart from every other token this
* key signs. An access token's audience is the auth host; a Studio build token's is
* `studio-build-validation`. Checked on validation, so neither can be replayed as an
* assertion, and an assertion — which carries no `sub` — satisfies none of the
* `validateAndGet*` readers either.
*/
const IDENTITY_ASSERTION_AUDIENCE = 'recflare:identity-assertion'
/**
* How long an assertion is good for. It is minted and redeemed within one request on the
* server side (www proves the identity, then posts the assertion to auth), so a minute
* covers clock skew between two Workers and nothing else.
*/
export const IDENTITY_ASSERTION_TTL_SECONDS = 60
/** A platform identity one worker has verified and is vouching for to `auth`. */
export interface IdentityAssertion {
/** PlatformType int — Discord (101) today. */
platform: number
/** The platform's own id for the user — a Discord snowflake, as a string. */
platformId: string
}
/**
* Mint a short-lived, signed statement that the caller has VERIFIED this platform
* identity — the credential behind `auth`'s `identity_assertion` grant.
*
* This exists so a worker other than `auth` can prove an identity `auth` cannot: www
* completes a Discord OAuth2 code exchange (which needs the Discord client secret, bound
* only to www) and hands `auth` the result, rather than `auth` learning Discord. Signing it
* with the shared `JWT_SECRET` adds no trust that didn't exist — a holder of that key can
* already mint access tokens outright — and it means the grant works the same over the
* service binding and over the public hostname. The assertion names an identity, never an
* account: resolving it to one (or minting one) stays `auth`'s decision.
*/
export async function generateIdentityAssertion(
identity: IdentityAssertion,
secret: string
): Promise<string> {
const now = Math.floor(Date.now() / 1000)
return sign(
{
aud: IDENTITY_ASSERTION_AUDIENCE,
platform: identity.platform,
platform_id: identity.platformId,
iat: now,
nbf: now,
exp: now + IDENTITY_ASSERTION_TTL_SECONDS,
jti: crypto.randomUUID(),
},
secret,
'HS256'
)
}
/**
* Validate an identity assertion and return the identity it vouches for, or null when
* the token is malformed, mis-signed, expired, or not an assertion at all. The audience
* check is what refuses an access token presented here, and a missing `sub` is required
* for the same reason in the other direction: an assertion must never pass as a session.
*/
export async function validateIdentityAssertion(
token: string,
secret: string
): Promise<IdentityAssertion | null> {
try {
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
if (payload.aud !== IDENTITY_ASSERTION_AUDIENCE || payload.sub !== undefined) return null
const platform = payload.platform
const platformId = payload.platform_id
if (typeof platform !== 'number' || !Number.isInteger(platform) || platform < 0) return null
if (typeof platformId !== 'string' || platformId === '') return null
return { platform, platformId }
} catch {
return null
}
}