Compare commits

..

8 Commits

Author SHA1 Message Date
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
59 changed files with 3955 additions and 235 deletions
+39 -3
View File
@@ -227,9 +227,12 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000 # RECFLARE_DISCORD_GUILD_ID=1077000000000000000
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002 # RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
# #
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer # Also add https://<your domain>/claim AND https://<your domain>/login/discord to the app's
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT # Redirects in the Discord developer portal, or the exchange is refused. The second is the
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved. # 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 # 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. # 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. # link Discord at /claim, and come back.
# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002 # 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 # 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 # 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 # `<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 # 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. # 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 # 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 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 `betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
normally manage this from the website (`/settings/recroomstudio`); this is the 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 ```sh
bun runx admin grant-studio --username alice 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 > with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
> the secret per request. > 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! Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
```bash ```bash
+34
View File
@@ -347,6 +347,40 @@ const app = new Hono<App>()
} }
) )
// Exact lookup by username — what the site's `/u/<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 ------------------------------------------------ // ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route. // Register the static `bulk` path before the `/account/:id` param route.
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts) .get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
@@ -153,6 +153,24 @@ describe('public endpoints', () => {
expect(await empty.json()).toEqual([]) 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 () => { test('GET /account/:id/bio returns an empty bio', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`) const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
expect(await res.json()).toEqual({ accountId: 7, bio: '' }) expect(await res.json()).toEqual({ accountId: 7, bio: '' })
@@ -689,6 +707,7 @@ describe('auth-gated endpoints', () => {
'GET /account/bulk', 'GET /account/bulk',
'GET /account/me', 'GET /account/me',
'GET /account/search', 'GET /account/search',
'GET /account/username/{name}',
'GET /account/{id}', 'GET /account/{id}',
'GET /account/{id}/bio', 'GET /account/{id}/bio',
'GET /accountprivacysettings/{id}', '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 { accountRoutes } from './routes/account'
import { avatarRoutes } from './routes/avatar' import { avatarRoutes } from './routes/avatar'
import { avatarStudioRoutes } from './routes/avatar-studio'
import { configRoutes } from './routes/config' import { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events' import { eventRoutes } from './routes/events'
import { gameplayRoutes } from './routes/gameplay' import { gameplayRoutes } from './routes/gameplay'
@@ -56,6 +57,7 @@ const app = new Hono<App>({ strict: false })
.route('/', configRoutes) .route('/', configRoutes)
.route('/', socialRoutes) .route('/', socialRoutes)
.route('/', progressionRoutes) .route('/', progressionRoutes)
.route('/', avatarStudioRoutes)
.route('/', avatarRoutes) .route('/', avatarRoutes)
.route('/', gameplayRoutes) .route('/', gameplayRoutes)
.route('/', eventRoutes) .route('/', eventRoutes)
+9 -1
View File
@@ -29,7 +29,7 @@
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load' 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[] = [ export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS custom_avatar_item ( `CREATE TABLE IF NOT EXISTS custom_avatar_item (
data TEXT NOT NULL, 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 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)`, `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 UnityAssetHash: string
UnityAsset2: string | null UnityAsset2: string | null
UnityAsset2Hash: 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). */ /** 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, ...save,
UnityAsset: questAsset(save.UnityAsset), UnityAsset: questAsset(save.UnityAsset),
UnityAsset2: questAsset(save.UnityAsset2), 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`. Grant those flags with `runx admin grant-developer` / `grant-moderator`.
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload, `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. 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 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 `runx admin grant-studio`). A player already signed into Studio keeps the old token
until the next sign-in or refresh. until the next sign-in or refresh.
+170 -32
View File
@@ -37,7 +37,12 @@ import {
withNotFound, withNotFound,
withOnError, withOnError,
} from '@repo/hono-helpers' } 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 // 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. // module is plain D1 queries with no runtime deps, so it imports cleanly here.
@@ -65,12 +70,15 @@ import {
TokenResponse, TokenResponse,
} from './openapi' } from './openapi'
import { import {
accountHoldsDiscordRole,
countAccountsForPlatformIdentity, countAccountsForPlatformIdentity,
getLinksForAccount, getLinksForIdentity,
getLinksForPlatformId, getLinksForPlatformId,
getLinksForPlatformIdentity, getLinksForPlatformIdentity,
hasStudioAccess,
isPlatformIdentityLinked, isPlatformIdentityLinked,
linkPlatformIdentity, linkPlatformIdentity,
parseRoleIdList,
} from './platform-db' } from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db' import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket' import { verifySteamTicket } from './steam-ticket'
@@ -108,6 +116,21 @@ const TOKEN_SCOPE =
*/ */
const BLOCKED_DESCRIPTION = 'this device or network is blocked' 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 * 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 * 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 * `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). * whitespace (`parseRoleIdList`, the grammar `www`'s parseRoleIds reads
* Unset or blank parses to an empty list, which `accountMayUseRolloutBuild` treats as a * DISCORD_BENEFITS_ROLE_IDS with). Unset or blank parses to an empty list, which
* gate closed to everyone but staff. * `accountMayUseRolloutBuild` treats as a gate closed to everyone but staff.
*/ */
function rolloutRoleIds(raw: string | undefined): string[] { const rolloutRoleIds = parseRoleIdList
return (raw ?? '')
.split(/[\s,]+/)
.map((id) => id.trim())
.filter((id) => id !== '')
}
/** /**
* Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator * 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> { ): Promise<boolean> {
const account = await getAccount(db, accountId) const account = await getAccount(db, accountId)
if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true
if (roleIds.length === 0) return false return accountHoldsDiscordRole(db, accountId, roleIds)
const links = await getLinksForAccount(db, accountId)
return links.some(
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
)
} }
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */ /** New players start in the Orientation room (RoomId 13) — the new-user flow. */
@@ -373,25 +387,25 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
* is unconditional (even with no account resolved). The rest are the operator-granted * is unconditional (even with no account resolved). The rest are the operator-granted
* extras, plus `junior` off the account's own `isJunior` flag. * extras, plus `junior` off the account's own `isJunior` flag.
* *
* `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it. * `betastudio` rides on `studio` — the account's Studio access as `hasStudioAccess`
* RecFlare Studio treats that exact claim as Full access, which is what lets the editor * resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding
* upload; staff set the flag from the website. `influencer` rides on `isInfluencer` * a role in the `STUDIO_ROLLOUT` var — and developer does not imply it. RecFlare Studio
* (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup * treats that exact claim as Full access, which is what lets the editor upload. The
* answers it. Order is stable so tokens are deterministic: screenshare, developer, * caller resolves it (it costs a link read) so this stays a pure projection of the
* moderator, junior, betastudio, then influencer. * 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( function accountRoles(
account: Pick< account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer'> | null,
Account, studio: boolean
'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer'
> | null
): string[] { ): string[] {
const roles = ['screenshare'] const roles = ['screenshare']
if (!account) return roles if (!account) return roles
if (account.isDeveloper) roles.push('developer') if (account.isDeveloper) roles.push('developer')
if (account.isModerator) roles.push('moderator') if (account.isModerator) roles.push('moderator')
if (account.isJunior) roles.push('junior') if (account.isJunior) roles.push('junior')
if (account.hasStudio) roles.push('betastudio') if (studio) roles.push('betastudio')
if (account.isInfluencer) roles.push('influencer') if (account.isInfluencer) roles.push('influencer')
return roles return roles
} }
@@ -443,7 +457,13 @@ function oauthError(
* already been authenticated — here, because the player approved a Studio code * already been authenticated — here, because the player approved a Studio code
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`). * 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 clientIp = c.req.header('cf-connecting-ip') ?? ''
const ban = await resolveBan(c.env.DB, accountId, { const ban = await resolveBan(c.env.DB, accountId, {
identity: { ip: clientIp }, identity: { ip: clientIp },
@@ -473,7 +493,7 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
roleAccount.platformId ?? '', roleAccount.platformId ?? '',
accountPlatform(roleAccount), accountPlatform(roleAccount),
jwtSecret, jwtSecret,
accountRoles(roleAccount), accountRoles(roleAccount, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)),
accountPrivileges(roleAccount), accountPrivileges(roleAccount),
GAME_VERSION, GAME_VERSION,
roleAccount.hasPlus === true roleAccount.hasPlus === true
@@ -516,9 +536,109 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
refresh_token: refreshToken, refresh_token: refreshToken,
scope: TOKEN_SCOPE, scope: TOKEN_SCOPE,
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=', 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) { async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.body(null, 401) if (id === null) return c.body(null, 401)
@@ -922,7 +1042,7 @@ const app = new Hono<App>()
tags: ['Token'], tags: ['Token'],
summary: 'OAuth token endpoint — issues a JWT', summary: 'OAuth token endpoint — issues a JWT',
description: [ 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.', 'selected by `grant_type`. Every grant returns the same body on success.',
'', '',
'**`create_account`** — mints a new account with an auto-assigned random username', '**`create_account`** — mints a new account with an auto-assigned random username',
@@ -945,6 +1065,17 @@ const app = new Hono<App>()
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The', '**`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.', '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`) —', '**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
'identifies the account by `username` or numeric `account_id` and requires the', '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,', 'matching `password`. An account with no stored hash cannot be logged into at all,',
@@ -974,7 +1105,8 @@ const app = new Hono<App>()
'powers refresh on every login and every refresh grant. `junior` rides along for an', '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', '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', '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', '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', 'treats that claim as permission to upload; `developer` does not confer it. A junior',
'also carries', 'also carries',
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the', 'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
@@ -1072,6 +1204,9 @@ const app = new Hono<App>()
} }
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT) 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 // `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. // grant overrides them below with what was stored when the token was issued.
let platformId = typeof body.platform_id === 'string' ? body.platform_id : '' let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
@@ -1565,7 +1700,10 @@ const app = new Hono<App>()
platformId, platformId,
platform, platform,
jwtSecret, jwtSecret,
accountRoles(roleAccount), accountRoles(
roleAccount,
await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)
),
accountPrivileges(roleAccount), accountPrivileges(roleAccount),
version, version,
// Rec Room Plus, off the same account read as the roles above — `econ` decides the // 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. * Logins on every other build are never touched by this.
*/ */
DISCORD_ROLLOUT_ROLE_IDS?: string 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: * 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 * a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means
+10 -1
View File
@@ -143,10 +143,12 @@ export const TokenRequest = z.object({
'refresh_token', 'refresh_token',
'password', 'password',
'urn:ietf:params:oauth:grant-type:device_code', 'urn:ietf:params:oauth:grant-type:device_code',
'identity_assertion',
]) ])
.describe( .describe(
'Anything unrecognised (including absent) is treated as a password grant, except the ' + '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'), account_id: z.string().optional().describe('Numeric account id, as a string'),
username: z username: z
@@ -183,6 +185,13 @@ export const TokenRequest = z.object({
.string() .string()
.optional() .optional()
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'), .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 device_id: z
.string() .string()
.optional() .optional()
+141
View File
@@ -314,6 +314,147 @@ export async function getLinksForPlatformId(
return results.map(toLink) 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). */ /** Every platform identity linked to an account (a player's PC and headset, say). */
export async function getLinksForAccount( export async function getLinksForAccount(
db: D1Database, db: D1Database,
+200 -1
View File
@@ -7,6 +7,7 @@ import '../../auth.app'
import { import {
AUDIT_LOG_SCHEMA_DDL, AUDIT_LOG_SCHEMA_DDL,
GAME_VERSION, GAME_VERSION,
getAccount,
getAccountsByDeviceId, getAccountsByDeviceId,
hashPassword, hashPassword,
PRESENCE_SCHEMA_DDL, PRESENCE_SCHEMA_DDL,
@@ -16,8 +17,14 @@ import {
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
updateAccount, updateAccount,
} from '@repo/domain' } 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 { import {
banFromReport, banFromReport,
createReport, createReport,
@@ -102,6 +109,9 @@ beforeAll(async () => {
// Report table (owned by the api worker) — a ban is a report row with `banned` set; // 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. // the token grant reads it for the evasion arms.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run() 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() for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
}) })
@@ -818,6 +828,53 @@ describe('auth worker routes', () => {
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio') 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', 'screenshare', '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 // 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 // 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 // subscriber discount from this claim ALONE and never reads the account, so if this
@@ -2579,3 +2636,145 @@ describe('staff login audit', () => {
expect(await staffLogins(9605)).toHaveLength(1) 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')}`) (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. // Editable Unity source manifests. Baked bundles are served separately.
.get( .get(
'/roomunityasset/:filename{.+}', '/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 * a build is stored under it. Null when no build is, or the table has not been migrated
* yet (the rooms worker owns it). * 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 { try {
// Inlined rather than imported so this worker does not take a dependency on the // Inlined rather than imported so this worker does not take a dependency on the
// domain package. // domain package.
const row = await db const row = await env.DB.prepare(
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`) `SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`
)
.bind(filename) .bind(filename)
.first<{ stored: number }>() .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) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
if (message.toLowerCase().includes('no such table')) return null 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>) { async function serveUnityAsset(c: Context<App>) {
const name = c.req.param('filename') ?? '' const name = c.req.param('filename') ?? ''
if (name.includes('..')) return c.body(null, 400) 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}`) return serveAsset(c, key ?? `unityasset/${name}`)
} }
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */ /** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> { async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
if (name.includes('..')) return new Response(null, { status: 400 }) 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}`) const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
if (!head) return new Response(null, { status: 404 }) if (!head) return new Response(null, { status: 404 })
+38
View File
@@ -12,6 +12,32 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com' 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', () => { describe('cdn endpoints', () => {
test('GET / reports service status', async () => { test('GET / reports service status', async () => {
const res = await exports.default.fetch(`${ORIGIN}/`) const res = await exports.default.fetch(`${ORIGIN}/`)
@@ -357,6 +383,18 @@ describe('cdn endpoints', () => {
expect(head.status).toBe(200) expect(head.status).toBe(200)
expect(head.headers.get('content-length')).toBe('4') expect(head.headers.get('content-length')).toBe('4')
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0)) 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((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
expect( expect(
@@ -26,6 +26,7 @@ import {
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
} from '@repo/domain' } 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 { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
import { import {
banFromReport, banFromReport,
@@ -289,6 +290,9 @@ beforeAll(async () => {
// Platform identity links (owned by the auth worker) — a ban also reaches the // Platform identity links (owned by the auth worker) — a ban also reaches the
// accounts sharing a proven identity with the banned one. // accounts sharing a proven identity with the banned one.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run() 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 -1
View File
@@ -962,9 +962,15 @@ function pickBakedBundle(
target: number | null target: number | null
): { filename: string; hash: string | null } | null { ): { filename: string; hash: string | null } | null {
if (!Array.isArray(save.UnitySubAssets)) return null if (!Array.isArray(save.UnitySubAssets)) return null
const rootId = typeof save.UnityAssetId === 'string' ? save.UnityAssetId.toLowerCase() : null
const rows = save.UnitySubAssets.filter( const rows = save.UnitySubAssets.filter(
(item): item is { Target?: unknown; Filename?: unknown; Hash?: unknown } => (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 = const chosen =
target === null target === null
+140
View File
@@ -34,6 +34,7 @@ import {
UNITY_ASSET_SCHEMA_DDL, UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain' } 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 INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db' import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db'
import { NotificationType } from '../../../../notify/src/notification-types' import { NotificationType } from '../../../../notify/src/notification-types'
@@ -134,6 +135,7 @@ beforeAll(async () => {
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run() 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. // 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 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() 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. // 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() for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -6329,6 +6331,84 @@ 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,
})),
})
}
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 () => { it('loads baked bundles onto the room and serves the unity asset', async () => {
await reset() await reset()
@@ -7185,3 +7265,63 @@ describe('Studio source revision permissions', () => {
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' }) 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', 8: 'room',
9: 'roomunityasset', 9: 'roomunityasset',
10: 'unityasset', 10: 'unityasset',
13: 'avatar',
14: 'avatarunityasset',
} }
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */ /** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
@@ -380,6 +382,7 @@ const app = new Hono<App>()
contentType = image.contentType contentType = image.contentType
} }
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, { await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
customMetadata: { ownerId: String(id) },
httpMetadata: { contentType }, httpMetadata: { contentType },
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the // 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 // 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. // storage worker's validation accepts it.
const TEST_SECRET = 'test-signing-key' 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 { function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = '' 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; reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status;
`PUT /cloud-builds/cancel` cancels a queued or running job. `PUT /cloud-builds/cancel` cancels a queued or running job.
Apply migration `0003_studio_cloud_build_job.sql`. Configure a private Apply migration `0003_studio_cloud_build_job.sql`. Create the private
`CLOUD_BUILD_KEY` and run the outbound worker from `recflare/cloud-building` with `CLOUD_BUILD_KEY` in the shared Secrets Store (the one `JWT_SECRET` lives in — see
the matching credential. Without a key, creation returns an explicit unavailable `.env.example`) and run the outbound worker from `recflare/cloud-building` with the
response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and Android), and same value as its `STUDIO_WORKER_KEY`. The binding must exist for the worker to
`CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169. Match installed editor modules to deploy; a value of `off` disables cloud building, and creation then returns an
the enabled targets. 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` The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish`
routes lease jobs to that worker. Only validated UnityFS artifacts for the 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 is retained. Completion links the submitted save; auto-publish does not replace
a newer draft. Failed, cancelled, or expired jobs never become successful builds. 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 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 or roles. Bulk component-text validation and job-scoped grant reads accept this
accepts this credential. It cannot authenticate as an account or publish a room. 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);
+318 -30
View File
@@ -1,11 +1,18 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { import {
AVATAR_STUDIO_SLOTS,
avatarItemAccessBit,
avatarStudioFlags,
avatarStudioItem,
avatarStudioSave,
canManageRoom,
canManageRoomById, canManageRoomById,
getRoomById, getRoomById,
getSubRoom, getSubRoom,
getSubRoomSaveById, getSubRoomSaveById,
publicStudioBundleFilename, publicStudioBundleFilename,
publishAvatarSave,
studioBundleR2Key, studioBundleR2Key,
} from '@repo/domain' } from '@repo/domain'
import { import {
@@ -16,13 +23,28 @@ import {
import { parseMultipart } from './multipart' 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 ( 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', 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 lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
)` )`
interface Job { 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 buildId: string
roomId: number roomId: number
subRoomId: number subRoomId: number
@@ -34,7 +56,8 @@ interface Job {
targets: number[] targets: number[]
manifest: { manifest: {
Version: number Version: number
MainSceneProjectRelativePath: string MainSceneProjectRelativePath?: string
MainPrefabProjectRelativePath?: string
ExportPartitionVersion: number ExportPartitionVersion: number
Files: Array<{ n: string; p: string; g?: string }> Files: Array<{ n: string; p: string; g?: string }>
Exports?: Array<{ e: string; d?: 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 fail = (error: string) => ({ success: false, error, value: null })
const ID = /^[1-9]\d*$/ const ID = /^[1-9]\d*$/
const guid = () => crypto.randomUUID() const guid = () => crypto.randomUUID()
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
interface Artifact { interface Artifact {
field: string field: string
unityAssetId: string unityAssetId: string
@@ -84,7 +108,6 @@ interface CloudBuildRecord {
unity_asset_id: string unity_asset_id: string
created_by_account_id: number created_by_account_id: number
} }
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
function wire(row: CloudBuildRecord) { function wire(row: CloudBuildRecord) {
return { return {
cloudBuildId: row.cloud_build_id, cloudBuildId: row.cloud_build_id,
@@ -99,7 +122,21 @@ function wire(row: CloudBuildRecord) {
: null, : 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 if (!key) return false
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`)) const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
const got = await crypto.subtle.digest( const got = await crypto.subtle.digest(
@@ -116,7 +153,48 @@ async function pendingBuild(db: D1Database, roomId: number, subRoomId: number, s
.bind(roomId, subRoomId, saveId) .bind(roomId, subRoomId, saveId)
.first<CloudBuildRecord>() .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>() 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) => { .post('/cloud-builds/create', async (c) => {
const secret = await c.env.JWT_SECRET.get() const secret = await c.env.JWT_SECRET.get()
const accountId = await validateAndGetAccountId(c.req.raw, secret) 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 (accountId === null) return c.body(null, 401)
if (!roles?.includes('betastudio')) if (!roles?.includes('betastudio'))
return c.json(fail('Studio upload authorization is required.'), 403) 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 form = await c.req.parseBody()
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key])) const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id)))) 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 form = await c.req.parseBody()
const id = textField(form.cloudBuildId) const id = textField(form.cloudBuildId)
const row = await c.env.DB.prepare( 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) .bind(id)
.first<{ room_id: number }>() .first<{ room_id: number; created_by_account_id: number }>()
if (!row || !(await canManageRoomById(c.env.DB, row.room_id, accountId))) 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) return c.json(fail('No accessible build.'), 404)
await c.env.DB.batch([ await c.env.DB.batch([
c.env.DB.prepare( c.env.DB.prepare(
@@ -289,7 +372,7 @@ const app = new Hono<App>()
return c.json(ok(null)) return c.json(ok(null))
}) })
.use('/internal/cloud-builds/*', async (c, next) => { .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() return next()
}) })
.post('/internal/cloud-builds/claim', async (c) => { .post('/internal/cloud-builds/claim', async (c) => {
@@ -325,17 +408,13 @@ const app = new Hono<App>()
}) })
.post('/internal/cloud-builds/:id/heartbeat', async (c) => { .post('/internal/cloud-builds/:id/heartbeat', async (c) => {
const { leaseToken } = await c.req.json<{ leaseToken: string }>() 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( 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>?" "UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
) )
.bind( .bind(leaseUntil, c.req.param('id'), leaseToken, new Date().toISOString())
new Date(Date.now() + 5 * 60 * 1000).toISOString(),
c.req.param('id'),
leaseToken,
new Date().toISOString()
)
.run() .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) => { .post('/internal/cloud-builds/:id/finish', async (c) => {
const form = await parseMultipart(c.req.raw) const form = await parseMultipart(c.req.raw)
@@ -359,6 +438,26 @@ const app = new Hono<App>()
]) ])
return c.json(ok(null)) 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)]) const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
let entries: unknown let entries: unknown
try { try {
@@ -368,7 +467,10 @@ const app = new Hono<App>()
} }
if (!Array.isArray(entries) || !entries.every(isArtifact)) if (!Array.isArray(entries) || !entries.every(isArtifact))
return c.json(fail('Invalid build artifact metadata.'), 400) 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) return c.json(fail('Missing build artifacts.'), 400)
const seen = new Set<string>() const seen = new Set<string>()
for (const entry of entries) { for (const entry of entries) {
@@ -377,8 +479,10 @@ const app = new Hono<App>()
if ( if (
!assets.has(entry.unityAssetId) || !assets.has(entry.unityAssetId) ||
!job.targets.includes(entry.target) || !job.targets.includes(entry.target) ||
!['main', 'stripped'].includes(entry.kind) || !(job.kind === 'avatar' ? ['main', 'stripped', 'material'] : ['main', 'stripped']).includes(
entry.version !== 5 || entry.kind
) ||
entry.version !== (job.kind === 'avatar' && entry.kind === 'material' ? 6 : 5) ||
!(file instanceof File) || !(file instanceof File) ||
file.size === 0 || file.size === 0 ||
seen.has(key) seen.has(key)
@@ -390,6 +494,11 @@ const app = new Hono<App>()
for (const target of job.targets) for (const target of job.targets)
if (!seen.has(`${asset}:${target}:main`)) if (!seen.has(`${asset}:${target}:main`))
return c.json(fail('A required asset bundle is missing.'), 400) 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[] = [] const writes: D1PreparedStatement[] = []
for (const entry of entries) { for (const entry of entries) {
const file = form[entry.field] as File const file = form[entry.field] as File
@@ -416,8 +525,11 @@ const app = new Hono<App>()
entry.target === 2 ? 'android' : 'windows', entry.target === 2 ? 'android' : 'windows',
entry.kind entry.kind
) )
const filename = baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`) const filename =
const key = studioBundleR2Key(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) await c.env.CDN_ASSETS.put(key, bytes)
keys.push(key) keys.push(key)
writes.push( writes.push(
@@ -439,6 +551,7 @@ const app = new Hono<App>()
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports })) await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
keys.push(serverDataKey) keys.push(serverDataKey)
// The exact submitted save is linked; a newer draft must never be replaced. // The exact submitted save is linked; a newer draft must never be replaced.
if (job.kind !== 'avatar')
writes.push( writes.push(
c.env.DB.prepare( 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')" "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')"
@@ -451,6 +564,7 @@ const app = new Hono<App>()
lease.lease_token lease.lease_token
) )
) )
if (job.kind !== 'avatar') {
writes.push( writes.push(
c.env.DB.prepare( c.env.DB.prepare(
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?) `UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
@@ -481,6 +595,8 @@ const app = new Hono<App>()
) )
) )
} }
}
writes.push( writes.push(
c.env.DB.prepare( 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')" "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'" "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) ).bind(id, lease.lease_token)
) )
// Recheck the lease and target inside the publication transaction. R2 // Recheck authority, lease and target inside the final publication transaction.
// uploads can outlast a lease, and a room can be deleted while compiling.
const publicationTime = new Date().toISOString() 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([ const results = await c.env.DB.batch([
c.env.DB.prepare( c.env.DB.prepare(
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL `UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
WHERE cloud_build_id=? AND lease_token=? AND status='publishing' WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
AND (lease_until<=? OR NOT EXISTS ( AND (lease_until<=? OR NOT EXISTS (${targetExists}))`
SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id ).bind(id, lease.lease_token, publicationTime, ...targetBindings),
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),
c.env.DB.prepare( 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 ( WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')` SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
).bind(publicationTime, id, id), ).bind(publicationTime, id, id),
@@ -512,7 +650,7 @@ const app = new Hono<App>()
]) ])
if (!results[results.length - 1].meta.changes) { if (!results[results.length - 1].meta.changes) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key))) 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) { } catch (error) {
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key))) await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
@@ -523,6 +661,156 @@ const app = new Hono<App>()
.run() .run()
throw error 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)) return c.json(ok(null))
}) })
export default app 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 // Shared `recflare-cdn` bucket. Local Studio builds land under
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind. // `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
CDN_ASSETS: R2Bucket 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_TARGETS?: string
CLOUD_BUILD_TEMPLATE_VERSION?: string CLOUD_BUILD_TEMPLATE_VERSION?: string
/** R2 key of an operator-published template catalog; unset disables downloads. */ /** R2 key of an operator-published template catalog; unset disables downloads. */
STUDIO_DISTRIBUTION_MANIFEST?: string 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 */ /** 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 end = new Date(start.getTime() + 60 * 60 * 1000)
const row = await db const row = await db
.prepare( .prepare(
`SELECT COUNT(*) AS total, `SELECT SUM(CASE WHEN room_id!=0 THEN 1 ELSE 0 END) AS total,
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS failed 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 FROM studio_cloud_build
WHERE created_by_account_id = ?1 WHERE created_by_account_id = ?1
AND started_at >= ?2 AND started_at < ?3` AND started_at >= ?2 AND started_at < ?3`
) )
.bind(accountId, start.toISOString(), end.toISOString()) .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 { return {
accountId, accountId,
startTime: start.toISOString(), startTime: start.toISOString(),
endTime: end.toISOString(), endTime: end.toISOString(),
subRoomTotalBuildCount: Number(row?.total ?? 0), subRoomTotalBuildCount: Number(row?.total ?? 0),
subRoomFailedBuildCount: Number(row?.failed ?? 0), subRoomFailedBuildCount: Number(row?.failed ?? 0),
avatarItemTotalBuildCount: 0, avatarItemTotalBuildCount: Number(row?.avatar_total ?? 0),
avatarItemFailedBuildCount: 0, avatarItemFailedBuildCount: Number(row?.avatar_failed ?? 0),
} }
} }
+79 -1
View File
@@ -3,13 +3,15 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { import {
canManageRoomById, canManageRoomById,
getAccount,
getPlayerIdsInRoomSubRoom, getPlayerIdsInRoomSubRoom,
getRoomCoOwnerIds, getRoomCoOwnerIds,
getSubRoom, getSubRoom,
} from '@repo/domain' } from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers' 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 cloudBuilds from './cloud-builds'
import distribution from './distribution' import distribution from './distribution'
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds' import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
@@ -30,6 +32,13 @@ function positiveId(raw: string | undefined): number | null {
return Number.isSafeInteger(id) ? id : 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 BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
const UPLOAD_FIRST = const UPLOAD_FIRST =
'Upload the room before building locally. The latest save is where this build is recorded.' '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) => { .get('/', async (c) => {
return c.text('hello, world!') 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 // 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. // list is the SDK's supported no-recommendations state, not a failed lookup.
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([])) .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, UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain' } from '@repo/domain'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds' import cloudBuilds, { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context' import type { Env } from '../../context'
@@ -49,7 +49,7 @@ async function auth(id = 42, studio = true) {
} }
beforeAll(async () => { beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key') 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' env.CLOUD_BUILD_TARGETS = '0'
for (const sql of [ for (const sql of [
...ROOM_SCHEMA_DDL, ...ROOM_SCHEMA_DDL,
@@ -468,3 +468,138 @@ it('does not complete a build whose submitted save was deleted during compilatio
.run() .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", "binding": "JWT_SECRET",
"store_id": "local", "store_id": "local",
"secret_name": "JWT_SECRET" "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, "upload_source_maps": true,
+34 -5
View File
@@ -220,11 +220,40 @@ won't pick up newly created secrets until it restarts.
which one is missing. `wrangler tail www` shows it. which one is missing. `wrangler tail www` shows it.
In the [Discord developer portal](https://discord.com/developers/applications), 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 add `https://<your domain>/claim` **and** `https://<your domain>/login/discord` to the
for byte: `www` derives the redirect URI from the incoming request's own origin app's **Redirects**. Each has to match byte for byte: `www` derives the redirect URI
(never from the request body, which would turn the client secret into a redemption from the incoming request's own origin (never from the request body, which would turn
oracle for someone else's app), so add `http://localhost:5173/claim` too if you the client secret into a redemption oracle for someone else's app), so add the
want the flow to work under `pnpm turbo dev`. `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) ### Discord role refresh (cron)
+8
View File
@@ -40,6 +40,14 @@ const AUTH_MESSAGES: Record<string, string> = {
'That platform cannot be verified — only Steam and Meta are supported.', 'That platform cannot be verified — only Steam and Meta are supported.',
'no linked account for this platform identity': '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.', '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.', '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 // 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 — // person evading a ban and the housemate of one — the IP arm cannot tell them apart —
+285 -41
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 — // 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. // see api.ts for why they're a module of their own rather than defined here.
import { import {
accountByUsername,
accountIdForUsername, accountIdForUsername,
call, call,
hasToken, hasToken,
@@ -30,11 +31,12 @@ import {
import { DeviceLinkPage } from './DeviceLink' import { DeviceLinkPage } from './DeviceLink'
import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item' import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item'
import { ModerationPage, PlayerBanStatus } from './Moderation' import { ModerationPage, PlayerBanStatus } from './Moderation'
import { PumpleBossFight } from './PumpleBossFight'
import { StatsPage } from './Stats' import { StatsPage } from './Stats'
import { StudioAccessPage } from './StudioAccess' import { StudioAccessPage } from './StudioAccess'
import type { ReactNode } from 'react' 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 * 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`. * benefits are off. The `state` nonce is appended here — see `startDiscordAuth`.
*/ */
discordAuthorizeUrl: string | null 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`). */ /** The private self DTO from `accounts` (`GET /account/me`). */
@@ -592,21 +600,52 @@ const claimBenefits = (code: string): Promise<ClaimResult> =>
*/ */
const OAUTH_STATE_KEY = 'rf_discord_state' 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. * Send the browser to Discord's consent screen.
* *
* A real navigation, not a client-side route — Discord is another origin. The `state` is * 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 * 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() const state = crypto.randomUUID()
sessionStorage.setItem(OAUTH_STATE_KEY, state) 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) const url = new URL(authorizeUrl)
url.searchParams.set('state', state) url.searchParams.set('state', state)
window.location.assign(url.toString()) 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 * 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 * — so a session without the role is rejected there (403) even though the UI shows no
@@ -952,16 +991,6 @@ function roomIdFromPath(path: string): number | null {
return match ? Number.parseInt(match[1], 10) : 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 * 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 — * viewer's own (see fetchPlayerPhotos), which is the one way a private one gets here —
@@ -991,10 +1020,9 @@ async function searchPlayers(query: string): Promise<PublicAccount[]> {
) )
} }
/** The profile page's account: an exact lookup, not the search bar's prefix query. */
const fetchPublicAccount = (username: string): Promise<PublicAccount | null> => const fetchPublicAccount = (username: string): Promise<PublicAccount | null> =>
searchPlayers(username).then( accountByUsername(username)
(matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null
)
/** /**
* The photos a player has taken. Sent WITH the session token when there is one: `api` * The photos a player has taken. Sent WITH the session token when there is one: `api`
@@ -1777,11 +1805,18 @@ function halloweenActive(): boolean {
} }
/** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */ /** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */
const PUMPKIN_SRC = const PUMPKIN_FRAMES = [
'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=' new URL('./pumple-frames/frame-00.png', import.meta.url).href,
const PUMPKIN_SCALE = 3 new URL('./pumple-frames/frame-01.png', import.meta.url).href,
const PUMPKIN_W = 23 * PUMPKIN_SCALE new URL('./pumple-frames/frame-02.png', import.meta.url).href,
const PUMPKIN_H = 25 * PUMPKIN_SCALE 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_LINES = ['boo!', 'hehe', 'hey!', 'spooky!', 'happy halloween!', 'ow!', '🎃']
const PUMPKIN_CSS = ` const PUMPKIN_CSS = `
@@ -1793,16 +1828,7 @@ const PUMPKIN_CSS = `
image-rendering: pixelated; image-rendering: pixelated;
transform-origin: 50% 100%; transform-origin: 50% 100%;
} }
.rf-pumpkin.is-walking img { /* The animated GIF provides its own walking frames. */
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; }
}
.rf-pumpkin-bubble { position: absolute; left: 50%; transform: translateX(-50%); margin: 6px 0; padding: 3px 8px; .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; border-radius: 10px; background: #fff3dc; color: #2a1608; font-size: 12px; font-weight: 600;
white-space: nowrap; opacity: 0; transition: opacity 0.15s; } white-space: nowrap; opacity: 0; transition: opacity 0.15s; }
@@ -1816,13 +1842,15 @@ const PUMPKIN_CSS = `
function LilPumpkin() { function LilPumpkin() {
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
const flipRef = useRef<HTMLDivElement>(null) const flipRef = useRef<HTMLDivElement>(null)
const spriteRef = useRef<HTMLImageElement>(null)
const bubbleRef = useRef<HTMLDivElement>(null) const bubbleRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const root = rootRef.current const root = rootRef.current
const flip = flipRef.current const flip = flipRef.current
const sprite = spriteRef.current
const bubble = bubbleRef.current const bubble = bubbleRef.current
if (!root || !flip || !bubble) return if (!root || !flip || !sprite || !bubble) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
root.style.display = 'none' root.style.display = 'none'
return return
@@ -1841,12 +1869,73 @@ function LilPumpkin() {
let jy = 0 let jy = 0
let vy = 0 let vy = 0
let walkTime = 0 let walkTime = 0
let walkDistance = 0
let walkFrame = 0
let mouseX = -1 let mouseX = -1
let mouseY = -1 let mouseY = -1
let mouseAt = -Infinity let mouseAt = -Infinity
let speechTimer = 0 let speechTimer = 0
let last = performance.now() let last = performance.now()
let raf = 0 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)` root.style.transform = `translate(${x}px, ${y}px)`
@@ -1870,14 +1959,30 @@ function LilPumpkin() {
const onLeave = () => { const onLeave = () => {
mouseAt = -Infinity mouseAt = -Infinity
} }
let rapidClickCount = 0
let rapidClickStart = 0
const onClick = (e: MouseEvent) => { const onClick = (e: MouseEvent) => {
const top = y + jy const top = y + jy
const bob = 0
const hit = const hit =
e.clientX >= x - 6 && e.clientX >= x - 45 &&
e.clientX <= x + PUMPKIN_W + 6 && e.clientX <= x + PUMPKIN_W + 45 &&
e.clientY >= top - 6 && e.clientY >= top - bob - 45 &&
e.clientY <= top + PUMPKIN_H + 6 e.clientY <= top - bob + PUMPKIN_H + 45
if (!hit) return 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 if (jy === 0) vy = -430
say(PUMPKIN_LINES[Math.floor(Math.random() * PUMPKIN_LINES.length)]) say(PUMPKIN_LINES[Math.floor(Math.random() * PUMPKIN_LINES.length)])
} }
@@ -1903,11 +2008,11 @@ function LilPumpkin() {
const dy = mouseY - (y + PUMPKIN_H / 2) const dy = mouseY - (y + PUMPKIN_H / 2)
const dist = Math.hypot(dx, dy) const dist = Math.hypot(dx, dy)
if (Math.abs(dx) > 4) facing = dx > 0 ? 1 : -1 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. // 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()) tx = clamp(mouseX - (dx / dist) * 80 - PUMPKIN_W / 2, 0, maxX())
ty = clamp(mouseY - (dy / dist) * 80 - PUMPKIN_H / 2, 0, maxY()) ty = clamp(mouseY - (dy / dist) * 80 - PUMPKIN_H / 2, 0, maxY())
speed = 150 speed = 310
} else { } else {
tx = x tx = x
ty = y ty = y
@@ -1920,7 +2025,7 @@ function LilPumpkin() {
ty = Math.random() * maxY() ty = Math.random() * maxY()
} }
} else { } else {
speed = 60 speed = 145
} }
const mx = tx - x const mx = tx - x
@@ -1932,6 +2037,15 @@ function LilPumpkin() {
x += (mx / md) * step x += (mx / md) * step
y += (my / md) * step y += (my / md) * step
moving = true 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 if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1
} else if (speed > 0 && !following) { } else if (speed > 0 && !following) {
pause = 1 + Math.random() * 2.5 pause = 1 + Math.random() * 2.5
@@ -1980,7 +2094,7 @@ function LilPumpkin() {
<div className="rf-pumpkin" ref={rootRef}> <div className="rf-pumpkin" ref={rootRef}>
<div className="rf-pumpkin-bubble" ref={bubbleRef} /> <div className="rf-pumpkin-bubble" ref={bubbleRef} />
<div ref={flipRef} style={{ transformOrigin: '50% 100%' }}> <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>
</div> </div>
</> </>
@@ -2085,6 +2199,7 @@ export function App() {
turnstileSiteKey: null, turnstileSiteKey: null,
benefitsEnabled: false, benefitsEnabled: false,
discordAuthorizeUrl: null, discordAuthorizeUrl: null,
discordLoginUrl: null,
}) })
setAccount(null) setAccount(null)
}) })
@@ -2102,7 +2217,12 @@ export function App() {
return ( return (
<> <>
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} /> <NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
{path === '/login' || path === '/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 // 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 // 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 // the URL is linkable. Unknown paths fall back to index.html (see the assets
@@ -2175,6 +2295,7 @@ export function App() {
)} )}
<SiteFooter /> <SiteFooter />
<HalloweenOverlay /> <HalloweenOverlay />
<PumpleBossFight />
</> </>
) )
} }
@@ -2563,6 +2684,7 @@ function LoginPage({
} }
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
const discordLoginUrl = config?.discordLoginUrl ?? null
return ( return (
<main className="shell"> <main className="shell">
@@ -2609,6 +2731,25 @@ function LoginPage({
your Steam ID — set a password on it and it signs in here too. your Steam ID — set a password on it and it signs in here too.
</p> </p>
<LoginForm onAuthed={authed} /> <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 {/* 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. 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. */} Gated on the same key, so it can't point at a door that isn't there. */}
@@ -2627,6 +2768,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. */ /** The signed-in account page. Redirects to sign-in when there's no session. */
function AccountPage({ function AccountPage({
account, account,
+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

+25 -13
View File
@@ -219,23 +219,32 @@ export function useAction() {
} }
/** /**
* Resolve an `@username` to the account id the workers address a player by. * 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
* `accounts` serves no exact-name lookup, so this goes through the PREFIX search and * prefix search, which this once went through and filtered client-side; one profile view
* keeps only an exact (case-insensitive) hit: a prefix match is a different player, and * then cost a 20-row index walk, and the search bar's typeahead already keeps that query
* acting on whoever happened to sort first would be worse than refusing. The exact name * busy enough. A 404 is the one non-ok answer that means "no such player"; anything else
* always sorts first among its own prefixes, so it's inside the search limit whenever it * is a real failure and throws.
* exists. */
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> { export async function accountIdForUsername(input: string): Promise<number> {
const name = input.trim().replace(/^@/, '') const name = input.trim().replace(/^@/, '')
if (name === '') throw new Error('Enter a username.') if (name === '') throw new Error('Enter a username.')
const matches = await call<Array<{ accountId?: number; username?: string }>>( const found = await accountByUsername(name)
`${where().accounts}/account/search?name=${encodeURIComponent(name)}`
)
const found = Array.isArray(matches)
? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase())
: undefined
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`) if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
return found.accountId return found.accountId
} }
@@ -245,6 +254,9 @@ export interface PublicAccount {
accountId: number accountId: number
username: string username: string
displayName: 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

+16
View File
@@ -2074,3 +2074,19 @@ button[type='submit']:disabled {
border-radius: 3px; border-radius: 3px;
border: 1px solid var(--line); 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. * all closes the claim, exactly as an unset one does.
*/ */
DISCORD_BENEFITS_ROLE_IDS?: string 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 * 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 * 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' 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`. */ /** Everything the claim needs configured. Resolved per request; see `discordConfig`. */
export interface DiscordConfig { export interface DiscordConfig {
/** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */ /** 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() 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 * 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 * 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. * Hand the access token back to Discord once the roles have been read.
* *
+8 -3
View File
@@ -8,6 +8,8 @@ import {
import { logger } from '@repo/hono-helpers' import { logger } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
import { hasStudioAccess } from '../../auth/src/platform-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { Account } from '@repo/domain' import type { Account } from '@repo/domain'
import type { App } from './context' import type { App } from './context'
@@ -20,15 +22,18 @@ import type { App } from './context'
* the signed-in player. Staff add and remove people through the routes under * the signed-in player. Staff add and remove people through the routes under
* `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation. * `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation.
* The grant is the account's `hasStudio` flag; `auth` stamps `betastudio` from it * 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>) { export async function studioAccessStatusHandler(c: Context<App>) {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401) if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
const account = await getAccount(c.env.DB, accountId) const account = await getAccount(c.env.DB, accountId)
return c.json({ granted: account?.hasStudio === true }) return c.json({ granted: await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT) })
} }
/** Every account with the flag set. */ /** Every account with the flag set. */
+95 -3
View File
@@ -44,9 +44,11 @@ import {
countAccountsForPlatformIdentity, countAccountsForPlatformIdentity,
getLinksForAccount, getLinksForAccount,
getLinksForPlatform, getLinksForPlatform,
hasRecordedRoles,
isPlatformIdentityLinked, isPlatformIdentityLinked,
linkPlatformIdentity, linkPlatformIdentity,
PLATFORM_SCHEMA_DDL, PLATFORM_SCHEMA_DDL,
recordFirstRoles,
} from '../../../../auth/src/platform-db' } from '../../../../auth/src/platform-db'
import { import {
BALANCE_SCHEMA_DDL, BALANCE_SCHEMA_DDL,
@@ -68,7 +70,13 @@ import {
INVENTORY_CUSTOM_SCHEMA_DDL, INVENTORY_CUSTOM_SCHEMA_DDL,
ownedCustomAvatarItemIds, ownedCustomAvatarItemIds,
} from '../../../../econ/src/inventory-custom-db' } 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 { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles'
import { DOCUMENTED_SERVICES } from '../../docs' import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links' import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
@@ -167,6 +175,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. // even be started. Note the client id is NOT leaked by a closed config.
benefitsEnabled: false, benefitsEnabled: false,
discordAuthorizeUrl: null, discordAuthorizeUrl: null,
// "Sign in with Discord" is on the same switch, and closed with it.
discordLoginUrl: null,
hosts: { hosts: {
auth: 'https://auth.rec.example.com', auth: 'https://auth.rec.example.com',
accounts: 'https://accounts.rec.example.com', accounts: 'https://accounts.rec.example.com',
@@ -671,16 +681,77 @@ it('reports where an account stands on benefits', async () => {
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ hasPlus: false, linked: false }) expect(await res.json()).toEqual({ hasPlus: false, linked: 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.
await updateAccount(env.DB, 4003, { hasPlus: true }) await updateAccount(env.DB, 4003, { hasPlus: true })
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001') await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
res = await SELF.fetch('https://example.com/api/benefits/status', { res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` }, headers: { authorization: `Bearer ${token}` },
}) })
// `linked` says THAT a Discord identity is attached, never which one — the id is of no expect(await res.json()).toEqual({ hasPlus: true, linked: false })
// use to the page, and an account's linked identities have no business on the wire.
// 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 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 }) expect(await res.json()).toEqual({ hasPlus: true, linked: true })
}) })
// 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 // The once-only guard the claim is built on. Without it, one Discord member holding the
// role could walk it around every RecFlare account they own; with it, the second claim is // role could walk it around every RecFlare account they own; with it, the second claim is
// refused and the first account keeps the benefit. Exercised at the two lookups the route // refused and the first account keeps the benefit. Exercised at the two lookups the route
@@ -3023,6 +3094,27 @@ it('tells a signed-in player whether they can upload, and nobody else', async ()
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ granted: false }) expect(await res.json()).toEqual({ granted: 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()).toEqual({ granted: true })
// 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 () => { it('lets staff add, list, and remove studio upload access', async () => {
+140 -21
View File
@@ -6,7 +6,7 @@ import { PlatformType } from '@repo/domain/src/enums'
import { countOnlinePlayers } from '@repo/domain/src/presence-db' import { countOnlinePlayers } from '@repo/domain/src/presence-db'
import { getStatSeries } from '@repo/domain/src/stats-db' import { getStatSeries } from '@repo/domain/src/stats-db'
import { intVar, logger, withDefaultCors, withOnError } from '@repo/hono-helpers' 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 // 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 ↔ // Discord identity is stored there as a PlatformType.Discord link — it is an account ↔
@@ -16,9 +16,10 @@ import { validateAndGetAccountId } from '@repo/jwt'
// verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS). // verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS).
import { import {
countAccountsForPlatformIdentity, countAccountsForPlatformIdentity,
getLinksForAccount, hasRecordedRoles,
isPlatformIdentityLinked, isPlatformIdentityLinked,
linkPlatformIdentity, linkPlatformIdentity,
recordFirstRoles,
} from '../../auth/src/platform-db' } from '../../auth/src/platform-db'
// The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is // 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 // econ's cron paid on demand: the first time an account links its Discord, the claim hands
@@ -31,6 +32,9 @@ import {
discordConfig, discordConfig,
exchangeCode, exchangeCode,
fetchGuildMembership, fetchGuildMembership,
fetchUser,
LOGIN_SCOPES,
loginRedirectUri,
qualifies, qualifies,
redirectUri, redirectUri,
revokeToken, revokeToken,
@@ -132,14 +136,27 @@ const claimant = async (c: Context<App>): Promise<number | null> =>
* (see App.tsx). Everything else about the request is fixed by the server. * (see App.tsx). Everything else about the request is fixed by the server.
*/ */
function authorizeUrl(request: Request, clientId: string): string { 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) const url = new URL(AUTHORIZE_URL)
url.search = new URLSearchParams({ url.search = new URLSearchParams({
client_id: clientId, client_id: clientId,
response_type: 'code', response_type: 'code',
scope: SCOPES, scope,
redirect_uri: redirectUri(request), redirect_uri: redirect,
// Skip Discord's "you've already authorized this app, continue?" interstitial on a // 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', prompt: 'none',
}).toString() }).toString()
return url.toString() return url.toString()
@@ -195,6 +212,10 @@ const app = new Hono<App>()
// ids never leave the worker, since it's the worker that asks Discord the question. // ids never leave the worker, since it's the worker that asks Discord the question.
benefitsEnabled: discord !== null, benefitsEnabled: discord !== null,
discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : 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: { hosts: {
auth: authBase(c.env), auth: authBase(c.env),
accounts: accountsBase(c.env), accounts: accountsBase(c.env),
@@ -344,6 +365,90 @@ const app = new Hono<App>()
return c.json(token) 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 --------------------------------------------- // ---- Discord benefits claim ---------------------------------------------
/** /**
@@ -358,16 +463,18 @@ const app = new Hono<App>()
.get('/api/benefits/status', async (c) => { .get('/api/benefits/status', async (c) => {
const accountId = await claimant(c) const accountId = await claimant(c)
if (accountId === null) return c.body(null, 401) if (accountId === null) return c.body(null, 401)
const [account, links] = await Promise.all([ const [account, claimed] = await Promise.all([
getAccount(c.env.DB, accountId), getAccount(c.env.DB, accountId),
getLinksForAccount(c.env.DB, accountId), hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord),
]) ])
return c.json({ return c.json({
hasPlus: account?.hasPlus ?? false, hasPlus: account?.hasPlus ?? false,
// Whether this account is already tied to a Discord identity — not WHICH one. The // Whether this account has CLAIMED through a Discord identity — a link with roles
// player knows their own Discord; the id is of no use to the page and every reason // recorded — not WHICH one. The player knows their own Discord; the id is of no use to
// to keep an account's linked identities off the wire. // the page and every reason to keep an account's linked identities off the wire. A
linked: links.some((link) => link.platform === PlatformType.Discord), // 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,
}) })
}) })
@@ -456,12 +563,6 @@ const app = new Hono<App>()
PlatformType.Discord, PlatformType.Discord,
membership.userId 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) { if (!alreadyMine) {
const claimedElsewhere = await countAccountsForPlatformIdentity( const claimedElsewhere = await countAccountsForPlatformIdentity(
c.env.DB, c.env.DB,
@@ -477,9 +578,26 @@ const app = new Hono<App>()
409 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.
const firstClaim = !(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 // 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 // idempotent: INSERT OR IGNORE (so `linkedAt` keeps the FIRST claim's time), with the
@@ -519,13 +637,14 @@ const app = new Hono<App>()
} }
// The one-time supporter gift: the box econ's cron would hand this member, paid now, // 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 // 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 // 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 // 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. // a retry that also failed partway is how a balance gets credited twice.
let tokensAwarded: number | null = null let tokensAwarded: number | null = null
if (created && firstLink) { if ((created || recordedFirst) && firstClaim) {
try { try {
const role = await grantDiscordRoleGift( const role = await grantDiscordRoleGift(
c.env, c.env,
+23 -4
View File
@@ -292,10 +292,27 @@ export const SEARCH_LIMIT = 20
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */ /** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&') 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.
*/
const prefixUpperBound = (prefix: string): string => `${prefix}\u{10FFFF}`
/** /**
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered * Prefix-search accounts by username (case-insensitive, "begins with"), ordered
* alphabetically. Backed by the indexed `username_lower` generated column, so the * alphabetically. Returns up to `limit` matches.
* `name%` LIKE stays index-friendly. 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( export async function searchAccounts(
db: D1Database, db: D1Database,
@@ -306,9 +323,11 @@ export async function searchAccounts(
if (q === '') return [] if (q === '') return []
const { results } = await db const { results } = await db
.prepare( .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>() .all<AccountRow>()
return parseAll(results) return parseAll(results)
} }
+148
View File
@@ -0,0 +1,148 @@
/** 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
}
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 './progression-db'
export * from './relationships-db' export * from './relationships-db'
export * from './validation' export * from './validation'
export * from './avatar-studio'
+39 -10
View File
@@ -2450,7 +2450,7 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
* it. * it.
*/ */
const serializeRoom = (room: Room): string => { 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) }) return JSON.stringify({ ...rest, Stats: storedStats(stats) })
} }
@@ -2512,13 +2512,21 @@ export async function attachStudioUnityAssets(
db: D1Database, db: D1Database,
saves: SubRoomDataSave[] saves: SubRoomDataSave[]
): Promise<void> { ): Promise<void> {
const ids = [ // The cloud build partitions prefab/material exports into separate bundles.
...new Set( // The main scene keeps external references to them, so the loader must receive
saves // every export, including shared dependencies, before it instantiates the scene.
.map((save) => save.UnityAssetId) const assetIds = (save: SubRoomDataSave): string[] => {
.filter((id): id is string => typeof id === 'string' && id !== '') 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 if (ids.length === 0) return
const builds = (await listUnityAssetBuilds(db, ids)).filter((build) => const builds = (await listUnityAssetBuilds(db, ids)).filter((build) =>
isStudioBundleFilename(build.Filename) isStudioBundleFilename(build.Filename)
@@ -2533,7 +2541,7 @@ export async function attachStudioUnityAssets(
for (const save of saves) { for (const save of saves) {
const id = save.UnityAssetId const id = save.UnityAssetId
if (typeof id !== 'string') continue 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 if (baked.length === 0) continue
save.UnitySubAssets = baked save.UnitySubAssets = baked
} }
@@ -2842,10 +2850,31 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
// game's Nullable<DateTime> decoder and discards the entire owned-room list. // game's Nullable<DateTime> decoder and discards the entire owned-room list.
if (room.PublishedAt === '') room.PublishedAt = null 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 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. */ /** A single subroom of a room (columns authoritative), or null if it doesn't exist. */
export async function getSubRoom( export async function getSubRoom(
db: D1Database, db: D1Database,
+5 -1
View File
@@ -7,7 +7,11 @@ export {
generateToken, generateToken,
generateStudioBuildValidationToken, generateStudioBuildValidationToken,
validateStudioBuildValidationToken, validateStudioBuildValidationToken,
studioBuildValidationId,
generatePhotonAuthToken, generatePhotonAuthToken,
generateIdentityAssertion,
validateIdentityAssertion,
IDENTITY_ASSERTION_TTL_SECONDS,
TOKEN_TTL_SECONDS, TOKEN_TTL_SECONDS,
} from './jwt' } 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. // last a day so the client doesn't have to refresh it.
export const TOKEN_TTL_SECONDS = 86400 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( export async function generateStudioBuildValidationToken(
buildId: string, buildId: string,
secret: string secret: string
@@ -39,19 +39,27 @@ export async function validateStudioBuildValidationToken(
request: Request, request: Request,
secret: string secret: string
): Promise<boolean> { ): 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') ?? '' const header = request.headers.get('Authorization') ?? ''
if (!header.startsWith('Bearer ')) return false if (!header.startsWith('Bearer ')) return null
try { try {
const payload = await verify(header.slice(7), secret, 'HS256') const payload = await verify(header.slice(7), secret, 'HS256')
return ( return payload.sub === undefined &&
payload.sub === undefined &&
payload.aud === 'studio-build-validation' && payload.aud === 'studio-build-validation' &&
payload.scope === 'studio.build.validate' && payload.scope === 'studio.build.validate' &&
typeof payload.buildId === 'string' && typeof payload.buildId === 'string' &&
/^[1-9]\d*$/.test(payload.buildId) /^[1-9]\d*$/.test(payload.buildId)
) ? (payload.buildId as string)
: null
} catch { } catch {
return false return null
} }
} }
@@ -320,3 +328,82 @@ export async function generateToken(
secret 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
}
}