Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 619b4c772c | |||
| f6214fb824 | |||
| 44704fec51 | |||
| fc6cf31591 | |||
| 4709c390fd | |||
| eeb652fef7 | |||
| d819bdbe3d | |||
| 207138ef97 | |||
| f2c16f198a | |||
| d14d96f489 | |||
| 95538e89ae | |||
| 5f34028267 | |||
| b02a646892 | |||
| 8397997234 | |||
| 4e2cdd87eb | |||
| d1e69bfd14 | |||
| 09e40af6b7 | |||
| f9600c1836 | |||
| 3da35a8692 |
@@ -227,9 +227,12 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000
|
||||
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
|
||||
#
|
||||
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer
|
||||
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT
|
||||
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
|
||||
# Also add https://<your domain>/claim AND https://<your domain>/login/discord to the app's
|
||||
# Redirects in the Discord developer portal, or the exchange is refused. The second is the
|
||||
# login page's "Sign in with Discord" (same switch): it signs into the account the Discord is
|
||||
# linked to, or CREATES one — the player then sets a password on the website to sign in to
|
||||
# the game, including on a Quest with no Meta account. Granting Plus takes effect on the
|
||||
# player's NEXT sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
|
||||
#
|
||||
# OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a
|
||||
# bot token, so `platform_account.role` tracks the guild instead of the day they claimed.
|
||||
@@ -276,6 +279,18 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# link Discord at /claim, and come back.
|
||||
# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002
|
||||
|
||||
# OPTIONAL: a staged rollout of Rec Room Studio to Discord roles (`auth`, `studio`, `www`).
|
||||
# The Discord ROLE ids whose holders get Studio upload access — `auth` stamps the
|
||||
# `betastudio` role into their token exactly as it does for an account staff flagged
|
||||
# `hasStudio` on the website, and `studio`'s access lookups and the website's Studio
|
||||
# settings page say "granted" for them too. The flag keeps working beside this, so staff
|
||||
# can still add individuals. Same rules as ROLE_IDS above: snowflakes, not names; commas, no
|
||||
# spaces; ANY one qualifies; roles come from the snapshot the benefits claim and the daily
|
||||
# sweep keep, so a newly granted role counts once the sweep runs (or the player re-claims),
|
||||
# and the player's Studio token picks it up on their next sign-in or refresh. UNSET — or
|
||||
# parsing to no roles — grants nobody: the flag alone decides.
|
||||
# RECFLARE_STUDIO_ROLLOUT=1077000000000000001,1077000000000000002
|
||||
|
||||
# OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the
|
||||
# RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
|
||||
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
|
||||
@@ -289,3 +304,24 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
|
||||
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
|
||||
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
|
||||
|
||||
# Rec Room Studio (`studio`). Cloud builds are configured OUTSIDE this file: the credential
|
||||
# the build host (recflare/cloud-building, its STUDIO_WORKER_KEY) presents to claim jobs
|
||||
# lives in the shared Secrets Store, like JWT_SECRET above — not as a var, since every var
|
||||
# here reaches every worker in plaintext. The binding has to resolve for `studio` to
|
||||
# deploy, so create it even if you run no build host: the literal `off` disables cloud
|
||||
# builds (the editor is told "Cloud building is not configured").
|
||||
#
|
||||
# wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
|
||||
# --scopes workers --remote
|
||||
#
|
||||
# The knobs ARE vars. TARGETS is the Unity build targets a job is built for (0 Windows,
|
||||
# 2 Android), commas, no spaces; the build host needs the matching editor modules.
|
||||
# TEMPLATE_VERSION is the Studio template a job is built against, and the build host
|
||||
# claims ONLY jobs for its own configured version (171 by default there, 169 here), so
|
||||
# a mismatch leaves jobs queued forever with no error on either side — set this to the
|
||||
# template the host has installed. DISTRIBUTION_MANIFEST is the R2 key of the template
|
||||
# catalog the editor downloads from; unset disables downloads (see apps/studio/README.md).
|
||||
# RECFLARE_CLOUD_BUILD_TARGETS=0,2
|
||||
# RECFLARE_CLOUD_BUILD_TEMPLATE_VERSION=169
|
||||
# RECFLARE_STUDIO_DISTRIBUTION_MANIFEST=roomunityasset/studio-distribution/catalog-v171.json
|
||||
|
||||
@@ -56,7 +56,8 @@ bun runx admin grant-moderator --username alice --remote
|
||||
Sets the account's `hasStudio` flag, which `auth` stamps into the token as the
|
||||
`betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
|
||||
normally manage this from the website (`/settings/recroomstudio`); this is the
|
||||
operator's way in. Takes effect on the player's next Studio sign-in or refresh.
|
||||
operator's way in. Takes effect on the player's next Studio sign-in or refresh. To open
|
||||
Studio to a whole Discord role instead, name it in `RECFLARE_STUDIO_ROLLOUT` (`.env`).
|
||||
|
||||
```sh
|
||||
bun runx admin grant-studio --username alice
|
||||
|
||||
@@ -140,6 +140,17 @@ wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes
|
||||
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
|
||||
> the secret per request.
|
||||
|
||||
The `studio` worker binds one more, `CLOUD_BUILD_KEY`: the credential the Rec Room
|
||||
Studio cloud-building host ([recflare/cloud-building](https://github.com/recflare/cloud-building),
|
||||
its `STUDIO_WORKER_KEY`) presents to claim queued builds. Same rule — it must exist for
|
||||
`studio` to deploy. Create it as the literal `off` to run without cloud builds; put a
|
||||
long random string in later when you set up a build host, and give that host the same
|
||||
string.
|
||||
|
||||
```bash
|
||||
wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY --scopes workers --remote
|
||||
```
|
||||
|
||||
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getAccount,
|
||||
getAccountByUsername,
|
||||
getAccountsByIds,
|
||||
SEARCH_LIMIT,
|
||||
searchAccounts,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
@@ -337,16 +338,61 @@ const app = new Hono<App>()
|
||||
description: 'Username prefix; empty matches nothing meaningful',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'take',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: `How many to return (default and max ${SEARCH_LIMIT}); fewer reads fewer rows`,
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(AccountDto.array(), 'Matching public accounts') },
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.query('name') ?? ''
|
||||
const accounts = await searchAccounts(c.env.DB, name)
|
||||
// The seek stops at the LIMIT, so a caller that draws five (the site's header
|
||||
// search) is billed five rows rather than the default twenty.
|
||||
const take = Number.parseInt(c.req.query('take') ?? '', 10)
|
||||
const limit = Number.isNaN(take) ? SEARCH_LIMIT : Math.max(1, Math.min(take, SEARCH_LIMIT))
|
||||
const accounts = await searchAccounts(c.env.DB, name, limit)
|
||||
return c.json(accounts.map(toAccountDto))
|
||||
}
|
||||
)
|
||||
|
||||
// Exact lookup by username — what the site's `/user/<name>` profile pages and the staff
|
||||
// `@username` fields resolve a player through. An indexed equality on `username_lower`;
|
||||
// before this existed they went through the prefix search above and filtered the
|
||||
// exact hit client-side, which cost a 20-row index walk per profile view. Same path
|
||||
// as the official API's `GET /account/username/{username}`.
|
||||
.get(
|
||||
'/account/username/:name',
|
||||
describeRoute({
|
||||
tags: ['Lookup'],
|
||||
summary: 'A single public account, by exact username',
|
||||
description:
|
||||
'Case-insensitive exact match; a leading `@` is ignored. 404 when no account has the name.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'name',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The username, exactly (case-insensitive)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(AccountDto, 'The public account'),
|
||||
404: { description: 'No account has that username' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param('name').trim().replace(/^@/, '')
|
||||
const account = name === '' ? null : await getAccountByUsername(c.env.DB, name)
|
||||
if (!account) return c.json({ error: `There's no player called @${name}.` }, 404)
|
||||
return c.json(toAccountDto(account))
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Bulk / single lookup ------------------------------------------------
|
||||
// Register the static `bulk` path before the `/account/:id` param route.
|
||||
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
|
||||
|
||||
@@ -153,6 +153,24 @@ describe('public endpoints', () => {
|
||||
expect(await empty.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /account/username/:name is an exact, case-insensitive lookup', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/username/coach`)
|
||||
expect(res.status).toBe(200)
|
||||
const account = (await res.json()) as { accountId: number; username: string }
|
||||
expect(account).toMatchObject({ accountId: 1, username: 'Coach' })
|
||||
// A leading `@` is tolerated — staff fields take names in that form.
|
||||
const at = await exports.default.fetch(
|
||||
`${ORIGIN}/account/username/${encodeURIComponent('@Coach')}`
|
||||
)
|
||||
expect(((await at.json()) as { accountId: number }).accountId).toBe(1)
|
||||
// A prefix is NOT a match: that's what `/account/search` is for.
|
||||
const prefix = await exports.default.fetch(`${ORIGIN}/account/username/coa`)
|
||||
expect(prefix.status).toBe(404)
|
||||
const missing = await exports.default.fetch(`${ORIGIN}/account/username/zzzznope`)
|
||||
expect(missing.status).toBe(404)
|
||||
expect(await missing.json()).toEqual({ error: "There's no player called @zzzznope." })
|
||||
})
|
||||
|
||||
test('GET /account/:id/bio returns an empty bio', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
|
||||
expect(await res.json()).toEqual({ accountId: 7, bio: '' })
|
||||
@@ -689,6 +707,7 @@ describe('auth-gated endpoints', () => {
|
||||
'GET /account/bulk',
|
||||
'GET /account/me',
|
||||
'GET /account/search',
|
||||
'GET /account/username/{name}',
|
||||
'GET /account/{id}',
|
||||
'GET /account/{id}/bio',
|
||||
'GET /accountprivacysettings/{id}',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Index the room-badge lookup that hydrates every room list (`attachBadges` in
|
||||
-- packages/domain/src/rooms-db.ts). Every room page — feeds, search, the owned list, a single
|
||||
-- room read — asks this table for the published badges of the rooms on the page, and without an
|
||||
-- index that was a full scan of every custom avatar item (thousands of first-party rows) per
|
||||
-- page. At the time of writing it was half of the database's row reads.
|
||||
--
|
||||
-- PARTIAL, over only the badge rows (`OutfitType` 1000), keyed by the badge's room. SQLite uses
|
||||
-- a partial index only when the query's WHERE contains its term as written, and an expression
|
||||
-- index only when the query names the same expression, so the query must keep BOTH
|
||||
-- `outfit_type = 1000` and `json_extract(data, '$.CustomBadgeMetadata.RoomId')` exactly —
|
||||
-- change either and the index silently stops being used. The key is an expression rather than
|
||||
-- a new generated column so the query reads the same whether or not this migration has run yet
|
||||
-- (the `rooms` worker can deploy ahead of `api`'s migrations).
|
||||
-- Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room
|
||||
ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId'))
|
||||
WHERE outfit_type = 1000;
|
||||
@@ -6,6 +6,7 @@ import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo
|
||||
|
||||
import { accountRoutes } from './routes/account'
|
||||
import { avatarRoutes } from './routes/avatar'
|
||||
import { avatarStudioRoutes } from './routes/avatar-studio'
|
||||
import { configRoutes } from './routes/config'
|
||||
import { eventRoutes } from './routes/events'
|
||||
import { gameplayRoutes } from './routes/gameplay'
|
||||
@@ -56,6 +57,7 @@ const app = new Hono<App>({ strict: false })
|
||||
.route('/', configRoutes)
|
||||
.route('/', socialRoutes)
|
||||
.route('/', progressionRoutes)
|
||||
.route('/', avatarStudioRoutes)
|
||||
.route('/', avatarRoutes)
|
||||
.route('/', gameplayRoutes)
|
||||
.route('/', eventRoutes)
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */
|
||||
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql and 0035_custom_avatar_item_badge_room.sql). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
||||
data TEXT NOT NULL,
|
||||
@@ -45,6 +45,9 @@ export const SCHEMA_DDL: string[] = [
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
||||
// The room-badge lookup (`attachBadges`, packages/domain/src/rooms-db.ts) runs on every room
|
||||
// page; partial over the badge rows, keyed by the badge's room (migrations/0035).
|
||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId')) WHERE outfit_type = 1000`,
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -72,6 +75,9 @@ export interface CustomAvatarItemSave {
|
||||
UnityAssetHash: string
|
||||
UnityAsset2: string | null
|
||||
UnityAsset2Hash: string | null
|
||||
/** Studio builds persist separate target hashes; legacy imports omit these. */
|
||||
QuestUnityAssetHash?: string | null
|
||||
QuestUnityAsset2Hash?: string | null
|
||||
}
|
||||
|
||||
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
|
||||
@@ -356,6 +362,8 @@ export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarIte
|
||||
...save,
|
||||
UnityAsset: questAsset(save.UnityAsset),
|
||||
UnityAsset2: questAsset(save.UnityAsset2),
|
||||
UnityAssetHash: save.QuestUnityAssetHash ?? save.UnityAssetHash,
|
||||
UnityAsset2Hash: save.QuestUnityAsset2Hash ?? save.UnityAsset2Hash,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -63,8 +63,11 @@ claim, so developer/moderator powers refresh on every login and every refresh gr
|
||||
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
|
||||
|
||||
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload,
|
||||
and developer does not include it. It is stamped from the account's `hasStudio` flag.
|
||||
Staff add and remove people at `/settings/recroomstudio` on the website (or
|
||||
and developer does not include it. It is stamped from the account's `hasStudio` flag,
|
||||
or from a Discord link holding a role named in `STUDIO_ROLLOUT`
|
||||
(`RECFLARE_STUDIO_ROLLOUT` in the root `.env`) — a staged Studio rollout to a Discord
|
||||
role, read from the same role snapshot as the build rollout gate. Staff add and remove
|
||||
individual people at `/settings/recroomstudio` on the website (or
|
||||
`runx admin grant-studio`). A player already signed into Studio keeps the old token
|
||||
until the next sign-in or refresh.
|
||||
|
||||
|
||||
@@ -37,7 +37,12 @@ import {
|
||||
withNotFound,
|
||||
withOnError,
|
||||
} from '@repo/hono-helpers'
|
||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||
import {
|
||||
generateToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
validateAndGetAccountId,
|
||||
validateIdentityAssertion,
|
||||
} from '@repo/jwt'
|
||||
|
||||
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
|
||||
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
|
||||
@@ -65,12 +70,15 @@ import {
|
||||
TokenResponse,
|
||||
} from './openapi'
|
||||
import {
|
||||
accountHoldsDiscordRole,
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForAccount,
|
||||
getLinksForIdentity,
|
||||
getLinksForPlatformId,
|
||||
getLinksForPlatformIdentity,
|
||||
hasStudioAccess,
|
||||
isPlatformIdentityLinked,
|
||||
linkPlatformIdentity,
|
||||
parseRoleIdList,
|
||||
} from './platform-db'
|
||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||
import { verifySteamTicket } from './steam-ticket'
|
||||
@@ -108,6 +116,21 @@ const TOKEN_SCOPE =
|
||||
*/
|
||||
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
||||
|
||||
/**
|
||||
* The website's "Sign in with Discord". `www` completes the Discord OAuth2 exchange (it
|
||||
* alone holds the client secret), then posts an `assertion` — an HS256 token signed with
|
||||
* the shared JWT_SECRET naming the platform identity it just proved (see `@repo/jwt`'s
|
||||
* `generateIdentityAssertion`). This grant trusts the assertion the way `cached_login`
|
||||
* trusts a Steam ticket, resolves it through the link table, and issues the ordinary
|
||||
* session. No account linked to the identity means a NEW one is made and linked, so a
|
||||
* Discord account is a way to join without Steam or Meta; the player then sets a password
|
||||
* on the website and signs in with it anywhere — a sideloaded Quest included.
|
||||
*/
|
||||
const IDENTITY_ASSERTION_GRANT = 'identity_assertion'
|
||||
|
||||
/** What the grant answers when one identity opens several accounts; never true today. */
|
||||
const AMBIGUOUS_IDENTITY_DESCRIPTION = 'platform identity is linked to more than one account'
|
||||
|
||||
/**
|
||||
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
|
||||
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
|
||||
@@ -209,16 +232,11 @@ const UNSUPPORTED_BUILD_DESCRIPTION =
|
||||
|
||||
/**
|
||||
* `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or
|
||||
* whitespace (the same grammar `www`'s parseRoleIds reads DISCORD_BENEFITS_ROLE_IDS with).
|
||||
* Unset or blank parses to an empty list, which `accountMayUseRolloutBuild` treats as a
|
||||
* gate closed to everyone but staff.
|
||||
* whitespace (`parseRoleIdList`, the grammar `www`'s parseRoleIds reads
|
||||
* DISCORD_BENEFITS_ROLE_IDS with). Unset or blank parses to an empty list, which
|
||||
* `accountMayUseRolloutBuild` treats as a gate closed to everyone but staff.
|
||||
*/
|
||||
function rolloutRoleIds(raw: string | undefined): string[] {
|
||||
return (raw ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id !== '')
|
||||
}
|
||||
const rolloutRoleIds = parseRoleIdList
|
||||
|
||||
/**
|
||||
* Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator
|
||||
@@ -239,11 +257,7 @@ async function accountMayUseRolloutBuild(
|
||||
): Promise<boolean> {
|
||||
const account = await getAccount(db, accountId)
|
||||
if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true
|
||||
if (roleIds.length === 0) return false
|
||||
const links = await getLinksForAccount(db, accountId)
|
||||
return links.some(
|
||||
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
|
||||
)
|
||||
return accountHoldsDiscordRole(db, accountId, roleIds)
|
||||
}
|
||||
|
||||
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
|
||||
@@ -317,8 +331,8 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
|
||||
/** Whether one account row holds a role. Absent/false both mean no role. */
|
||||
function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean {
|
||||
// Matches accountTokenRoles: screenshare is a feature gate on every account token.
|
||||
if (role === 'screenshare') return true
|
||||
// Match the same staff override used when minting account tokens.
|
||||
if (role === 'screenshare') return account.hasScreenshare === true
|
||||
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
|
||||
}
|
||||
|
||||
@@ -368,30 +382,31 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
|
||||
|
||||
/**
|
||||
* The role names beyond `gameClient` for an account's token `role` claim. Base roles
|
||||
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
|
||||
* client gates the screen-share feature on it and nothing grants it per-account, so it
|
||||
* is unconditional (even with no account resolved). The rest are the operator-granted
|
||||
* extras, plus `junior` off the account's own `isJunior` flag.
|
||||
* (gameClient) are added by generateToken. `screenshare` requires an explicit staff
|
||||
* grant via `hasScreenshare`. The other roles come from account flags and Studio access.
|
||||
*
|
||||
* `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it.
|
||||
* RecFlare Studio treats that exact claim as Full access, which is what lets the editor
|
||||
* upload; staff set the flag from the website. `influencer` rides on `isInfluencer`
|
||||
* (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup
|
||||
* answers it. Order is stable so tokens are deterministic: screenshare, developer,
|
||||
* moderator, junior, betastudio, then influencer.
|
||||
* `betastudio` rides on `studio` — the account's Studio access as `hasStudioAccess`
|
||||
* resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding
|
||||
* a role in the `STUDIO_ROLLOUT` var — and developer does not imply it. RecFlare Studio
|
||||
* treats that exact claim as Full access, which is what lets the editor upload. The
|
||||
* caller resolves it (it costs a link read) so this stays a pure projection of the
|
||||
* account. `influencer` rides on `isInfluencer` (`runx admin grant-influencer`) and lives
|
||||
* only in the token — no `/role/*` lookup answers it. Order is stable so tokens are
|
||||
* deterministic: screenshare, developer, moderator, junior, betastudio, then influencer.
|
||||
*/
|
||||
function accountRoles(
|
||||
account: Pick<
|
||||
Account,
|
||||
'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer'
|
||||
> | null
|
||||
'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer' | 'hasScreenshare'
|
||||
> | null,
|
||||
studio: boolean
|
||||
): string[] {
|
||||
const roles = ['screenshare']
|
||||
const roles: string[] = account?.hasScreenshare === true ? ['screenshare'] : []
|
||||
if (!account) return roles
|
||||
if (account.isDeveloper) roles.push('developer')
|
||||
if (account.isModerator) roles.push('moderator')
|
||||
if (account.isJunior) roles.push('junior')
|
||||
if (account.hasStudio) roles.push('betastudio')
|
||||
if (studio) roles.push('betastudio')
|
||||
if (account.isInfluencer) roles.push('influencer')
|
||||
return roles
|
||||
}
|
||||
@@ -443,7 +458,13 @@ function oauthError(
|
||||
* already been authenticated — here, because the player approved a Studio code
|
||||
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
|
||||
*/
|
||||
async function issueSession(c: Context<App>, accountId: number, grantType: string) {
|
||||
async function issueSession(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
grantType: string,
|
||||
/** Extra top-level fields on the token body — `created: true` on an identity signup. */
|
||||
extra: Record<string, unknown> = {}
|
||||
) {
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
const ban = await resolveBan(c.env.DB, accountId, {
|
||||
identity: { ip: clientIp },
|
||||
@@ -473,7 +494,7 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
|
||||
roleAccount.platformId ?? '',
|
||||
accountPlatform(roleAccount),
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount),
|
||||
accountRoles(roleAccount, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)),
|
||||
accountPrivileges(roleAccount),
|
||||
GAME_VERSION,
|
||||
roleAccount.hasPlus === true
|
||||
@@ -516,9 +537,109 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
|
||||
refresh_token: refreshToken,
|
||||
scope: TOKEN_SCOPE,
|
||||
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `identity_assertion` grant: sign into — or create — the account behind a platform
|
||||
* identity another worker has verified and signed for. See IDENTITY_ASSERTION_GRANT.
|
||||
*
|
||||
* Handled before the password fallback and outside the version/rollout gates, like the
|
||||
* Studio device grant: nothing here posts a `ver`, and the credential is the assertion
|
||||
* alone. The assertion names an IDENTITY, not an account; which account that is (or that
|
||||
* there is none yet) is decided here, from the same table `cached_login` reads, so the
|
||||
* website can never choose an account id for itself.
|
||||
*
|
||||
* Creating an account when the identity opens none is deliberate — it is the Discord
|
||||
* sign-up — and takes the same guards `create_account` does for a verified platform
|
||||
* identity: the evasion arms (a banned player's next move is a new account), the per-IP
|
||||
* cap (`www` forwards the browser's real address over the service binding) and the
|
||||
* per-identity cap. NOT the PASSWORD_SIGNUP switch: that exists because a password account
|
||||
* has no identity but its address, and this one has a Discord user Discord itself vouched
|
||||
* for, one account per user. The new account carries no `platform`/`platformId` on its
|
||||
* blob — only the link — so its DTO and token read like a password account's and the
|
||||
* Discord snowflake stays off the wire, exactly as the benefits claim keeps it.
|
||||
*/
|
||||
async function identityAssertionGrant(c: Context<App>, body: Record<string, unknown>) {
|
||||
const assertion = typeof body.assertion === 'string' ? body.assertion : ''
|
||||
const jwtSecret = await c.env.JWT_SECRET.get()
|
||||
if (jwtSecret === '') {
|
||||
logger.error('refusing an identity assertion: JWT_SECRET is empty')
|
||||
return oauthError(c, 'server_error', 'token signing is not configured', 500)
|
||||
}
|
||||
const identity = assertion ? await validateIdentityAssertion(assertion, jwtSecret) : null
|
||||
if (identity === null) {
|
||||
return oauthError(c, 'invalid_grant', 'invalid or expired identity assertion')
|
||||
}
|
||||
|
||||
const links = await getLinksForIdentity(c.env.DB, identity.platform, identity.platformId)
|
||||
if (links.length > 1) {
|
||||
// The benefits claim keeps a Discord identity on one account, so this is a data
|
||||
// problem rather than a player's choice to make; refused rather than guessed.
|
||||
logger.error('identity assertion names an identity linked to several accounts', {
|
||||
platform: identity.platform,
|
||||
accountIds: links.map((l) => l.accountId),
|
||||
})
|
||||
return oauthError(c, 'invalid_grant', AMBIGUOUS_IDENTITY_DESCRIPTION)
|
||||
}
|
||||
if (links.length === 1) {
|
||||
return issueSession(c, links[0].accountId, IDENTITY_ASSERTION_GRANT)
|
||||
}
|
||||
|
||||
// Nobody yet: the sign-up. Everything below mirrors create_account's platform path.
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
const blocked = await resolveBan(c.env.DB, null, {
|
||||
identity: { ip: clientIp, platform: identity.platform, platformId: identity.platformId },
|
||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||
})
|
||||
if (blocked) {
|
||||
logger.info('identity signup refused: player banned', {
|
||||
via: blocked.via,
|
||||
bannedAccountId: blocked.bannedAccountId,
|
||||
ip: clientIp,
|
||||
platform: identity.platform,
|
||||
})
|
||||
return oauthError(c, 'invalid_grant', BLOCKED_DESCRIPTION)
|
||||
}
|
||||
const maxPerPlatformId = intVar(
|
||||
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
|
||||
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
|
||||
)
|
||||
if (
|
||||
maxPerPlatformId > 0 &&
|
||||
(await countAccountsForPlatformIdentity(c.env.DB, identity.platform, identity.platformId)) >=
|
||||
maxPerPlatformId
|
||||
) {
|
||||
logger.info('identity signup rejected: platform account limit', { platform: identity.platform })
|
||||
return oauthError(c, 'invalid_grant', 'account limit reached for this platform account')
|
||||
}
|
||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||
if (
|
||||
maxPerIp > 0 &&
|
||||
clientIp !== '' &&
|
||||
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
|
||||
) {
|
||||
logger.info('identity signup rejected: per-IP account limit', { ip: clientIp })
|
||||
return oauthError(c, 'invalid_grant', 'too many accounts created from this network')
|
||||
}
|
||||
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: 0,
|
||||
lastLoginTime: new Date().toISOString(),
|
||||
signupIp: clientIp || undefined,
|
||||
lastLoginIp: clientIp || undefined,
|
||||
})
|
||||
await linkPlatformIdentity(c.env.DB, account.accountId, identity.platform, identity.platformId)
|
||||
await placeNewPlayerInOrientation(c.env, account.accountId, 0)
|
||||
logger.info('created an account from an identity assertion', {
|
||||
accountId: account.accountId,
|
||||
platform: identity.platform,
|
||||
ip: clientIp,
|
||||
})
|
||||
return issueSession(c, account.accountId, IDENTITY_ASSERTION_GRANT, { created: true })
|
||||
}
|
||||
|
||||
async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
@@ -922,7 +1043,7 @@ const app = new Hono<App>()
|
||||
tags: ['Token'],
|
||||
summary: 'OAuth token endpoint — issues a JWT',
|
||||
description: [
|
||||
'Issues an access token (plus a single-use refresh token) for one of four grants,',
|
||||
'Issues an access token (plus a single-use refresh token) for one of six grants,',
|
||||
'selected by `grant_type`. Every grant returns the same body on success.',
|
||||
'',
|
||||
'**`create_account`** — mints a new account with an auto-assigned random username',
|
||||
@@ -945,6 +1066,17 @@ const app = new Hono<App>()
|
||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
||||
'platform and platform id come from what was stored at issue time, not the body.',
|
||||
'',
|
||||
'**`identity_assertion`** — the website’s "Sign in with Discord". `www` completes the',
|
||||
'Discord OAuth2 code exchange (it alone holds the client secret) and posts an',
|
||||
'`assertion`: an HS256 token signed with the shared JWT_SECRET naming the platform',
|
||||
'(`101`) and platform id it proved, good for a minute. The identity is resolved through',
|
||||
'the same link table as `cached_login`: one linked account is signed into; none means a',
|
||||
'NEW account is created and linked (the body then carries `created: true`), under the',
|
||||
'evasion check and the per-IP and per-identity signup caps but NOT the PASSWORD_SIGNUP',
|
||||
'switch — the Discord user is the identity a password account lacks. The new account',
|
||||
'has no password until its owner sets one on the website; with one, it signs in',
|
||||
'anywhere the `password` grant does. Several linked accounts is refused.',
|
||||
'',
|
||||
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
||||
'identifies the account by `username` or numeric `account_id` and requires the',
|
||||
'matching `password`. An account with no stored hash cannot be logged into at all,',
|
||||
@@ -972,9 +1104,10 @@ const app = new Hono<App>()
|
||||
'',
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
'powers refresh on every login and every refresh grant. `junior` rides along for an',
|
||||
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
|
||||
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
|
||||
'from the account’s `hasStudio` flag (staff set it on the website). RecFlare Studio',
|
||||
'account flagged `isJunior`. `screenshare` requires a staff grant on the Studio Access',
|
||||
'page. `betastudio` is stamped',
|
||||
'from the account’s `hasStudio` flag (staff set it on the website) or from a Discord',
|
||||
'link holding a role in the `STUDIO_ROLLOUT` var. RecFlare Studio',
|
||||
'treats that claim as permission to upload; `developer` does not confer it. A junior',
|
||||
'also carries',
|
||||
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
|
||||
@@ -1072,6 +1205,9 @@ const app = new Hono<App>()
|
||||
}
|
||||
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT)
|
||||
}
|
||||
// The website's Discord sign-in, likewise before the fallback — and before the
|
||||
// version gates, which it never posts a `ver` for. See identityAssertionGrant.
|
||||
if (grantType === IDENTITY_ASSERTION_GRANT) return identityAssertionGrant(c, body)
|
||||
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
||||
// grant overrides them below with what was stored when the token was issued.
|
||||
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
||||
@@ -1565,7 +1701,10 @@ const app = new Hono<App>()
|
||||
platformId,
|
||||
platform,
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount),
|
||||
accountRoles(
|
||||
roleAccount,
|
||||
await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)
|
||||
),
|
||||
accountPrivileges(roleAccount),
|
||||
version,
|
||||
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
|
||||
|
||||
@@ -64,6 +64,18 @@ export type Env = SharedHonoEnv & {
|
||||
* Logins on every other build are never touched by this.
|
||||
*/
|
||||
DISCORD_ROLLOUT_ROLE_IDS?: string
|
||||
/**
|
||||
* The Discord role ids whose holders get Rec Room Studio upload access — stamped into the
|
||||
* token as `betastudio` exactly as the account's `hasStudio` flag is, so a whole Discord
|
||||
* role can be let into Studio without touching anyone's account. Same grammar as
|
||||
* DISCORD_ROLLOUT_ROLE_IDS above (snowflakes, commas and/or whitespace; ANY one qualifies),
|
||||
* read from the same Discord link snapshot. Set for every worker from the root `.env`
|
||||
* (`RECFLARE_STUDIO_ROLLOUT`): `studio` and `www` read it through the same
|
||||
* `hasStudioAccess`, so what the token says, what the editor's access lookup says and
|
||||
* what the website's `/api/studio-access` says agree. Unset, or naming no role, grants
|
||||
* nobody — the flag alone decides. The flag still works beside it.
|
||||
*/
|
||||
STUDIO_ROLLOUT?: string
|
||||
/**
|
||||
* The client builds a `/connect/token` grant may post as `ver` and still get a token:
|
||||
* a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means
|
||||
|
||||
@@ -143,10 +143,12 @@ export const TokenRequest = z.object({
|
||||
'refresh_token',
|
||||
'password',
|
||||
'urn:ietf:params:oauth:grant-type:device_code',
|
||||
'identity_assertion',
|
||||
])
|
||||
.describe(
|
||||
'Anything unrecognised (including absent) is treated as a password grant, except the ' +
|
||||
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization'
|
||||
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization, ' +
|
||||
'and identity_assertion, the website’s Discord sign-in'
|
||||
),
|
||||
account_id: z.string().optional().describe('Numeric account id, as a string'),
|
||||
username: z
|
||||
@@ -183,6 +185,13 @@ export const TokenRequest = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
|
||||
assertion: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'identity_assertion grant: the HS256 assertion `www` minted with the shared JWT_SECRET ' +
|
||||
'after completing a Discord OAuth2 exchange, naming the platform and platform_id it proved'
|
||||
),
|
||||
device_id: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -292,7 +301,7 @@ export function roleLookup(role: 'developer' | 'moderator' | 'screenshare') {
|
||||
summary: `Whether a player has the ${role} role`,
|
||||
description:
|
||||
role === 'screenshare'
|
||||
? 'Returns a bare JSON boolean. Screenshare is enabled for every existing account, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
|
||||
? 'Returns a bare JSON boolean. Screenshare is disabled by default and requires an explicit staff grant, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
|
||||
: `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
|
||||
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
|
||||
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
|
||||
|
||||
@@ -314,6 +314,147 @@ export async function getLinksForPlatformId(
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every account a platform identity is linked to, on ANY platform — including the ones
|
||||
* {@link CACHED_LOGIN_PLATFORMS} keeps out of the picker.
|
||||
*
|
||||
* This is the unfiltered twin of {@link getLinksForPlatformIdentity}, and it must stay off
|
||||
* every public route: the filter there exists so nobody can learn which account a Discord
|
||||
* user owns by naming their snowflake. The one caller is `auth`'s `identity_assertion`
|
||||
* grant, where the identity being asked about has just been PROVEN by the worker that
|
||||
* minted the assertion — the website's Discord sign-in — so the answer goes to the person
|
||||
* it is about and nobody else. Add a caller only under the same condition.
|
||||
*/
|
||||
export async function getLinksForIdentity(
|
||||
db: D1Database,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
||||
)
|
||||
.bind(platform, platformId)
|
||||
.all<LinkRow>()
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any of the account's links on `platform` has roles RECORDED — a non-null `role`
|
||||
* column, which only the website's benefits claim (and the sweep behind it) ever writes.
|
||||
*
|
||||
* This is what "has this account claimed its Discord benefits" means now that a Discord
|
||||
* link can exist WITHOUT a claim: signing in with Discord (`auth`'s `identity_assertion`
|
||||
* grant) links the identity on a brand-new account with `role` NULL. The claim's one-time
|
||||
* gift and its page both ask this rather than "is there a link", so a player who made
|
||||
* their account from Discord is still owed, and still shown, their first claim.
|
||||
* An EMPTY role list counts as recorded: that is a claim that found no roles.
|
||||
*/
|
||||
export async function hasRecordedRoles(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number
|
||||
): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT 1 AS ok FROM platform_account
|
||||
WHERE account_id = ?1 AND platform = ?2 AND role IS NOT NULL LIMIT 1`
|
||||
)
|
||||
.bind(accountId, platform)
|
||||
.first<{ ok: number }>()
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Record roles on an existing link that has NONE yet (`role` NULL), atomically. True when
|
||||
* this call was the one that did it; false when the link is missing or already carries a
|
||||
* role list — in which case nothing is written, and {@link linkPlatformIdentity}'s refresh
|
||||
* is the caller's next step.
|
||||
*
|
||||
* The one-shot flip is the point: the benefits claim pays its first-claim gift on the
|
||||
* INSERT of a fresh link, which two racing claims cannot both win. A link made by a Discord
|
||||
* sign-in is already inserted by the time the claim comes, so this gives the claim the
|
||||
* same single-winner write on the roles column instead.
|
||||
*/
|
||||
export async function recordFirstRoles(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string,
|
||||
roles: readonly string[]
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const res = await db
|
||||
.prepare(
|
||||
`UPDATE platform_account SET role = ?4
|
||||
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3 AND role IS NULL`
|
||||
)
|
||||
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
|
||||
.run()
|
||||
return res.meta.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of Discord role ids out of an operator var — numeric snowflakes separated by
|
||||
* commas and/or whitespace, the grammar every `*_ROLE_IDS`-shaped var here shares
|
||||
* (`auth`'s DISCORD_ROLLOUT_ROLE_IDS, `STUDIO_ROLLOUT`; `www` reads
|
||||
* DISCORD_BENEFITS_ROLE_IDS the same way). Unset or blank parses to an empty list.
|
||||
*/
|
||||
export function parseRoleIdList(raw: string | undefined): string[] {
|
||||
return (raw ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account's Discord link records ANY of `roleIds`. False with none configured —
|
||||
* the link read is skipped, so a var that names no role grants nobody anything. Roles are
|
||||
* the SNAPSHOT the website's benefits claim and `www`'s daily sweep keep (see
|
||||
* {@link PlatformLink.roles}), so a role granted in Discord today counts once the player
|
||||
* re-claims or the sweep runs — not instantly.
|
||||
*/
|
||||
export async function accountHoldsDiscordRole(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
roleIds: readonly string[]
|
||||
): Promise<boolean> {
|
||||
if (roleIds.length === 0) return false
|
||||
const links = await getLinksForAccount(db, accountId)
|
||||
return links.some(
|
||||
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account has Rec Room Studio upload access — what `auth` stamps into the
|
||||
* token as the `betastudio` role, `studio` answers on its access lookups and `www`'s
|
||||
* `/api/studio-access` reports. Two grants, either suffices:
|
||||
*
|
||||
* - the account's own `hasStudio` flag, set by staff on the website or
|
||||
* `runx admin grant-studio`;
|
||||
* - the STUDIO ROLLOUT: a Discord link holding a role named in the `STUDIO_ROLLOUT` var
|
||||
* (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`, reaching every worker), which hands a
|
||||
* whole Discord role the same access without touching anyone's account. Unset, or
|
||||
* naming no role, it grants nobody — the flag alone decides, as before.
|
||||
*
|
||||
* Every reader goes through this so the three surfaces cannot disagree: a role holder
|
||||
* whom `auth` stamps `betastudio` for must also be told "granted" by the website, and
|
||||
* `studio`'s lookups (which rebuild the role from the account rather than a token) must
|
||||
* see the same answer. The flag is checked first so an account that has it costs no
|
||||
* link read.
|
||||
*/
|
||||
export async function hasStudioAccess(
|
||||
db: D1Database,
|
||||
account: { accountId: number; hasStudio?: boolean } | null | undefined,
|
||||
studioRolloutRoleIds: string | undefined
|
||||
): Promise<boolean> {
|
||||
if (!account) return false
|
||||
if (account.hasStudio === true) return true
|
||||
return accountHoldsDiscordRole(db, account.accountId, parseRoleIdList(studioRolloutRoleIds))
|
||||
}
|
||||
|
||||
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
||||
export async function getLinksForAccount(
|
||||
db: D1Database,
|
||||
@@ -348,6 +489,40 @@ export async function isPlatformIdentityLinked(
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove EVERY link an account holds on one platform, and return the links that were
|
||||
* removed (empty when it held none). The website's Discord unlink is the one caller:
|
||||
* a player taking their Discord off their account, which gives the identity back —
|
||||
* "Sign in with Discord" with it afterwards makes a NEW account, and the benefits claim's
|
||||
* once-only guard lets it claim elsewhere again.
|
||||
*
|
||||
* The removed rows are returned rather than a count because the caller needs to know what
|
||||
* they carried: a link whose `role` was recorded is a CLAIM, and the account must remember
|
||||
* having made one after the row is gone (see `account.hasClaimedDiscord`), or the
|
||||
* first-link gift would pay again on every link/unlink cycle. Read-then-delete, not
|
||||
* `DELETE … RETURNING`, so it runs on every D1 the tests do.
|
||||
*
|
||||
* Not for Steam or Meta: those links are what a cached login redeems, and nothing on the
|
||||
* website removes one. The helper doesn't refuse them — the table has no idea what a
|
||||
* platform is for — so a caller must only ever name a platform nobody logs in with.
|
||||
*/
|
||||
export async function unlinkPlatform(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number
|
||||
): Promise<PlatformLink[]> {
|
||||
const { results } = await db
|
||||
.prepare(`${SELECT_LINK} WHERE account_id = ?1 AND platform = ?2 ORDER BY linked_at`)
|
||||
.bind(accountId, platform)
|
||||
.all<LinkRow>()
|
||||
if (results.length === 0) return []
|
||||
await db
|
||||
.prepare(`DELETE FROM platform_account WHERE account_id = ?1 AND platform = ?2`)
|
||||
.bind(accountId, platform)
|
||||
.run()
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many accounts one platform identity already opens — the count both signup caps
|
||||
* and link caps are enforced against, so an identity can't accumulate accounts by
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../auth.app'
|
||||
import {
|
||||
AUDIT_LOG_SCHEMA_DDL,
|
||||
GAME_VERSION,
|
||||
getAccount,
|
||||
getAccountsByDeviceId,
|
||||
hashPassword,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
@@ -16,8 +17,14 @@ import {
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
|
||||
import {
|
||||
generateIdentityAssertion,
|
||||
generateToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
validateIdentityAssertion,
|
||||
} from '@repo/jwt'
|
||||
|
||||
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||
import {
|
||||
banFromReport,
|
||||
createReport,
|
||||
@@ -102,6 +109,9 @@ beforeAll(async () => {
|
||||
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
|
||||
// the token grant reads it for the evasion arms.
|
||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Custom avatar items (owned by the api worker) — every room read joins the published
|
||||
// room badges from this table.
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
@@ -682,8 +692,8 @@ describe('auth worker routes', () => {
|
||||
expect(payload.iss).toBe('https://auth.recflare.net')
|
||||
expect(payload.aud).toBe('https://auth.recflare.net')
|
||||
expect(payload.role).toContain('gameClient')
|
||||
// screenshare is a feature gate, not a grant — every token carries it.
|
||||
expect(payload.role).toContain('screenshare')
|
||||
// Screen sharing is disabled until staff explicitly grant it.
|
||||
expect(payload.role).not.toContain('screenshare')
|
||||
// A plain adult account carries nothing beyond those — no elevated roles.
|
||||
expect(payload.role).not.toContain('developer')
|
||||
expect(payload.role).not.toContain('moderator')
|
||||
@@ -740,6 +750,37 @@ describe('auth worker routes', () => {
|
||||
// refresh, and the Studio device grant — and a refresh after a revoke drops it. A
|
||||
// token that kept the role for the rest of the day would leave a removed player
|
||||
// uploading.
|
||||
test('screen sharing requires a staff grant on login, refresh, and role lookup', async () => {
|
||||
await seedAccount(9819, 'ScreenSharePlayer')
|
||||
expect((await tokenFor(`account_id=9819&password=${LOGIN_PASSWORD}`)).role).not.toContain(
|
||||
'screenshare'
|
||||
)
|
||||
await updateAccount(env.DB, 9819, { hasScreenshare: true })
|
||||
const granted = await tokenFor(`account_id=9819&password=${LOGIN_PASSWORD}`)
|
||||
expect(granted.role).toContain('screenshare')
|
||||
expect(await (await exports.default.fetch(`${ORIGIN}/role/screenshare/9819`)).json()).toBe(true)
|
||||
const session = await postToken(`account_id=9819&password=${LOGIN_PASSWORD}`)
|
||||
const sessionBody = session.json as { refresh_token: string }
|
||||
await updateAccount(env.DB, 9819, { hasScreenshare: false })
|
||||
const refresh = await exports.default.fetch(`${ORIGIN}/connect/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: sessionBody.refresh_token,
|
||||
}),
|
||||
})
|
||||
expect(refresh.status).toBe(200)
|
||||
const refreshed = (await refresh.json()) as { access_token: string }
|
||||
const payload = JSON.parse(
|
||||
atob(refreshed.access_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
|
||||
)
|
||||
expect(payload.role).not.toContain('screenshare')
|
||||
expect(await (await exports.default.fetch(`${ORIGIN}/role/screenshare/9819`)).json()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /connect/token stamps betastudio only while the account has hasStudio', async () => {
|
||||
// 9811/9812, not 9601–9605: those ids belong to the staff-login audit tests,
|
||||
// which insert with OR IGNORE and then count `staff_login` rows.
|
||||
@@ -751,7 +792,6 @@ describe('auth worker routes', () => {
|
||||
await updateAccount(env.DB, 9811, { hasStudio: true })
|
||||
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
|
||||
'gameClient',
|
||||
'screenshare',
|
||||
'betastudio',
|
||||
])
|
||||
|
||||
@@ -770,7 +810,6 @@ describe('auth worker routes', () => {
|
||||
.run()
|
||||
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
|
||||
'gameClient',
|
||||
'screenshare',
|
||||
'developer',
|
||||
'moderator',
|
||||
'betastudio',
|
||||
@@ -803,7 +842,6 @@ describe('auth worker routes', () => {
|
||||
expect(signedIn.status).toBe(200)
|
||||
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
|
||||
'gameClient',
|
||||
'screenshare',
|
||||
'betastudio',
|
||||
])
|
||||
|
||||
@@ -818,6 +856,53 @@ describe('auth worker routes', () => {
|
||||
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
|
||||
})
|
||||
|
||||
// STUDIO_ROLLOUT opens Studio to a Discord ROLE: a holder is stamped `betastudio` exactly
|
||||
// as a flagged account is, with no account flag involved, from the same Discord link
|
||||
// snapshot the build rollout gate reads. Unset, the flag alone decides.
|
||||
test('POST /connect/token stamps betastudio for a Discord role in STUDIO_ROLLOUT', async () => {
|
||||
const studioRole = '1077000000000000031'
|
||||
const otherRole = '1077000000000000032'
|
||||
await seedAccount(9813, 'StudioRoleHolder')
|
||||
await linkPlatformIdentity(env.DB, 9813, PlatformType.Discord, '500000000000009813', [
|
||||
studioRole,
|
||||
])
|
||||
await seedAccount(9814, 'StudioRoleOutsider')
|
||||
await linkPlatformIdentity(env.DB, 9814, PlatformType.Discord, '500000000000009814', [
|
||||
otherRole,
|
||||
])
|
||||
const login = (id: number) => tokenFor(`account_id=${id}&password=${LOGIN_PASSWORD}`)
|
||||
|
||||
const original = env.STUDIO_ROLLOUT
|
||||
try {
|
||||
// Unset, or naming no role: the link grants nothing.
|
||||
delete env.STUDIO_ROLLOUT
|
||||
expect((await login(9813)).role).not.toContain('betastudio')
|
||||
env.STUDIO_ROLLOUT = ' , '
|
||||
expect((await login(9813)).role).not.toContain('betastudio')
|
||||
|
||||
// Same list grammar as DISCORD_ROLLOUT_ROLE_IDS; any one named role qualifies.
|
||||
env.STUDIO_ROLLOUT = ` ${otherRole}x ,\n${studioRole} `
|
||||
expect((await login(9813)).role).toEqual(['gameClient', 'betastudio'])
|
||||
// A Discord link holding only other roles is not in the rollout.
|
||||
expect((await login(9814)).role).not.toContain('betastudio')
|
||||
// The account flag never moved: this is the var, not a grant written anywhere.
|
||||
expect((await getAccount(env.DB, 9813))?.hasStudio).toBeUndefined()
|
||||
|
||||
// The refresh grant re-resolves it, so ending the rollout drops the claim on the
|
||||
// next refresh — the same path a revoked flag takes.
|
||||
const session = await postToken(`account_id=9813&password=${LOGIN_PASSWORD}`)
|
||||
delete env.STUDIO_ROLLOUT
|
||||
const refreshed = await postToken(
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(session.json.refresh_token as string)}`
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
|
||||
} finally {
|
||||
if (original === undefined) delete env.STUDIO_ROLLOUT
|
||||
else env.STUDIO_ROLLOUT = original
|
||||
}
|
||||
})
|
||||
|
||||
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
|
||||
// the website's Discord benefits claim sets. `econ` decides the CampusCard and the
|
||||
// subscriber discount from this claim ALONE and never reads the account, so if this
|
||||
@@ -888,7 +973,7 @@ describe('auth worker routes', () => {
|
||||
)
|
||||
.run()
|
||||
const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'screenshare', 'junior']))
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'junior']))
|
||||
expect(payload.role).not.toContain('developer')
|
||||
// `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`.
|
||||
expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat'])
|
||||
@@ -907,9 +992,7 @@ describe('auth worker routes', () => {
|
||||
)
|
||||
.run()
|
||||
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload.role).toEqual(
|
||||
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
|
||||
)
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'influencer']))
|
||||
expect(payload.role).not.toContain('developer')
|
||||
|
||||
// Off the flag, off the token — no other flag implies it.
|
||||
@@ -1959,7 +2042,7 @@ describe('auth worker routes', () => {
|
||||
for (const id of [42, 77]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(true)
|
||||
expect(await res.json()).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2579,3 +2662,145 @@ describe('staff login audit', () => {
|
||||
expect(await staffLogins(9605)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// The website's "Sign in with Discord". `www` proves the Discord user (it holds the client
|
||||
// secret) and posts an identity ASSERTION signed with the shared JWT_SECRET; this grant
|
||||
// resolves it through the link table like a cached_login, or makes the account when there
|
||||
// is none. Everything here mints the assertion the way www does, with the test key.
|
||||
describe('identity_assertion grant', () => {
|
||||
const SIGNING_KEY = 'test-signing-key'
|
||||
const assertionFor = (platformId: string, platform = PlatformType.Discord) =>
|
||||
generateIdentityAssertion({ platform, platformId }, SIGNING_KEY)
|
||||
const redeem = async (assertion: string, ip?: string) =>
|
||||
postToken(`grant_type=identity_assertion&assertion=${encodeURIComponent(assertion)}`, ip)
|
||||
|
||||
test('an assertion round-trips, and nothing else passes as one', async () => {
|
||||
const assertion = await assertionFor('700000000000000001')
|
||||
await expect(validateIdentityAssertion(assertion, SIGNING_KEY)).resolves.toEqual({
|
||||
platform: PlatformType.Discord,
|
||||
platformId: '700000000000000001',
|
||||
})
|
||||
// A different key, and an access token signed with the RIGHT key: both refused. The
|
||||
// second is the one that matters — the same secret signs sessions, so the audience is
|
||||
// what keeps a stolen token from being replayed as proof of a platform identity.
|
||||
await expect(validateIdentityAssertion(assertion, 'another-key')).resolves.toBeNull()
|
||||
const session = await generateToken('42', '', 4, SIGNING_KEY)
|
||||
await expect(validateIdentityAssertion(session, SIGNING_KEY)).resolves.toBeNull()
|
||||
// And the other direction: an assertion carries no `sub`, so it is no session.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Bearer ${assertion}`,
|
||||
},
|
||||
body: 'user_code=ABCDEFGH',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('signs into the one account the identity is linked to', async () => {
|
||||
await seedAccount(9400, 'DiscordLinked')
|
||||
// Linked the way the benefits claim links: with roles recorded.
|
||||
await linkPlatformIdentity(env.DB, 9400, PlatformType.Discord, '700000000000009400', [])
|
||||
const res = await redeem(await assertionFor('700000000000009400'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string).sub).toBe('9400')
|
||||
expect(res.json).not.toHaveProperty('created')
|
||||
expect(typeof res.json.refresh_token).toBe('string')
|
||||
})
|
||||
|
||||
test('refuses a missing, forged or replayed-session assertion', async () => {
|
||||
expect((await postToken('grant_type=identity_assertion')).status).toBe(400)
|
||||
const forged = await generateIdentityAssertion(
|
||||
{ platform: PlatformType.Discord, platformId: '700000000000009400' },
|
||||
'not-the-key'
|
||||
)
|
||||
const res = await redeem(forged)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('invalid or expired identity assertion')
|
||||
// An access token for the linked account, presented as the assertion.
|
||||
const session = await generateToken('9400', '', 4, SIGNING_KEY)
|
||||
expect((await redeem(session)).status).toBe(400)
|
||||
})
|
||||
|
||||
test('creates and links an account when the identity opens none, once', async () => {
|
||||
const discordId = '700000000000009401'
|
||||
const first = await redeem(await assertionFor(discordId))
|
||||
expect(first.status).toBe(200)
|
||||
expect(first.json.created).toBe(true)
|
||||
const accountId = Number(decodePayload(first.json.access_token as string).sub)
|
||||
expect(accountId).toBeGreaterThan(0)
|
||||
|
||||
// Linked on the Discord platform, and ONLY there: the blob carries no platform
|
||||
// identity (the snowflake stays off the DTO and the token), like a password account.
|
||||
const links = await getLinksForAccount(env.DB, accountId)
|
||||
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
|
||||
[PlatformType.Discord, discordId],
|
||||
])
|
||||
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ data: string }>()
|
||||
const blob = JSON.parse(row!.data) as Record<string, unknown>
|
||||
expect(blob.platformId).toBeUndefined()
|
||||
expect(blob.passwordHash).toBeUndefined()
|
||||
// Placed in Orientation like any signup.
|
||||
const presence = await env.DB.prepare('SELECT 1 AS ok FROM presence WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first()
|
||||
expect(presence).not.toBeNull()
|
||||
|
||||
// The same Discord again is a sign-IN to that account, not a second account.
|
||||
const again = await redeem(await assertionFor(discordId))
|
||||
expect(again.status).toBe(200)
|
||||
expect(again.json).not.toHaveProperty('created')
|
||||
expect(decodePayload(again.json.access_token as string).sub).toBe(String(accountId))
|
||||
await expect(
|
||||
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
|
||||
).resolves.toBe(1)
|
||||
})
|
||||
|
||||
// The stretch of the feature: a Discord-made account has no password, and a password is
|
||||
// what signs it into the game (a sideloaded Quest has no Meta nonce to offer). The
|
||||
// website's password form posts with an EMPTY old password the first time.
|
||||
test('a Discord-made account can set a password and then sign in with it', async () => {
|
||||
const created = await redeem(await assertionFor('700000000000009402'))
|
||||
const accountId = decodePayload(created.json.access_token as string).sub as string
|
||||
expect((await postToken(`account_id=${accountId}&password=hunter22`)).status).toBe(400)
|
||||
|
||||
const set = await changePassword(
|
||||
'oldPassword=&newPassword=hunter22',
|
||||
created.json.access_token as string
|
||||
)
|
||||
expect(set.status).toBe(200)
|
||||
const login = await postToken(`grant_type=password&account_id=${accountId}&password=hunter22`)
|
||||
expect(login.status).toBe(200)
|
||||
expect(decodePayload(login.json.access_token as string).sub).toBe(accountId)
|
||||
})
|
||||
|
||||
test('refuses an identity that opens several accounts rather than guessing', async () => {
|
||||
await seedAccount(9403, 'TwoLinksA')
|
||||
await seedAccount(9404, 'TwoLinksB')
|
||||
await linkPlatformIdentity(env.DB, 9403, PlatformType.Discord, '700000000000009403', [])
|
||||
await linkPlatformIdentity(env.DB, 9404, PlatformType.Discord, '700000000000009403', [])
|
||||
const res = await redeem(await assertionFor('700000000000009403'))
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('platform identity is linked to more than one account')
|
||||
})
|
||||
|
||||
// The signup caps reach this path as they reach create_account — www forwards the
|
||||
// browser's real address over the service binding, so the per-IP cap counts browsers.
|
||||
test('the per-IP signup cap applies to accounts made this way', async () => {
|
||||
const ip = '198.51.100.77'
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await redeem(await assertionFor(`70000000000000950${i}`), ip)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.json.created).toBe(true)
|
||||
}
|
||||
const fourth = await redeem(await assertionFor('700000000000009509'), ip)
|
||||
expect(fourth.status).toBe(400)
|
||||
expect(fourth.json.error_description).toBe('too many accounts created from this network')
|
||||
// Signing IN from that network is untouched: the cap is on creation.
|
||||
const existing = await redeem(await assertionFor('700000000000009500'), ip)
|
||||
expect(existing.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -362,6 +362,13 @@ const app = new Hono<App>()
|
||||
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
|
||||
)
|
||||
|
||||
// SDK source downloads use an opaque upload capability (no bearer on this
|
||||
// request). Only creator-authenticated save history exposes these filenames.
|
||||
.get('/avatarunityasset/:filename{.+}', (c) => {
|
||||
const name = c.req.param('filename')
|
||||
if (!/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}$/.test(name)) return c.notFound()
|
||||
return serveAsset(c, `avatarunityasset/${name}`)
|
||||
})
|
||||
// Editable Unity source manifests. Baked bundles are served separately.
|
||||
.get(
|
||||
'/roomunityasset/:filename{.+}',
|
||||
@@ -430,15 +437,22 @@ const app = new Hono<App>()
|
||||
* a build is stored under it. Null when no build is, or the table has not been migrated
|
||||
* yet (the rooms worker owns it).
|
||||
*/
|
||||
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
|
||||
async function unityAssetR2Key(env: StudioAssetEnv, filename: string): Promise<string | null> {
|
||||
try {
|
||||
// Inlined rather than imported so this worker does not take a dependency on the
|
||||
// domain package.
|
||||
const row = await db
|
||||
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`
|
||||
)
|
||||
.bind(filename)
|
||||
.first<{ stored: number }>()
|
||||
return row ? filename : null
|
||||
if (!row) return null
|
||||
if (/^(quest\/)?studio-avatar\//.test(filename)) return `avatar/${filename}`
|
||||
// Room builds and consumable exports use the game's /room namespace.
|
||||
// Retain older installations that stored the same filename at the bucket root.
|
||||
if (filename.startsWith('studio/') && (await env.CDN_ASSETS.head(`room/${filename}`)))
|
||||
return `room/${filename}`
|
||||
return filename
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.toLowerCase().includes('no such table')) return null
|
||||
@@ -450,14 +464,14 @@ async function unityAssetR2Key(db: D1Database, filename: string): Promise<string
|
||||
async function serveUnityAsset(c: Context<App>) {
|
||||
const name = c.req.param('filename') ?? ''
|
||||
if (name.includes('..')) return c.body(null, 400)
|
||||
const key = await unityAssetR2Key(c.env.DB, name)
|
||||
const key = await unityAssetR2Key(c.env, name)
|
||||
return serveAsset(c, key ?? `unityasset/${name}`)
|
||||
}
|
||||
|
||||
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
|
||||
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
|
||||
if (name.includes('..')) return new Response(null, { status: 400 })
|
||||
const key = await unityAssetR2Key(env.DB, name)
|
||||
const key = await unityAssetR2Key(env, name)
|
||||
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
|
||||
if (!head) return new Response(null, { status: 404 })
|
||||
|
||||
|
||||
@@ -12,6 +12,32 @@ declare module 'cloudflare:test' {
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
test('serves opaque avatar source manifests and SDK avatar bundle GET/HEAD/ranges', async () => {
|
||||
const source = '2026-10-18/11111111-1111-1111-1111-111111111111'
|
||||
await env.CDN_ASSETS.put('avatarunityasset/' + source, '{"Version":3}')
|
||||
expect(await (await exports.default.fetch(ORIGIN + '/avatarunityasset/' + source)).text()).toBe(
|
||||
'{"Version":3}'
|
||||
)
|
||||
await env.DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS unity_asset(unity_asset_id TEXT NOT NULL,target INTEGER NOT NULL,version INTEGER NOT NULL,kind TEXT NOT NULL,filename TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(unity_asset_id,target,version,kind))'
|
||||
).run()
|
||||
const name = 'quest/studio-avatar/test/item/lease.material.assetbundle'
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO unity_asset(unity_asset_id,target,version,kind,filename,hash) VALUES (?,2,6,?,?,?)'
|
||||
)
|
||||
.bind('avatar-fixture', 'material', name, 'hash')
|
||||
.run()
|
||||
await env.CDN_ASSETS.put('avatar/' + name, 'UnityFS\0avatar')
|
||||
expect(
|
||||
(await exports.default.fetch(ORIGIN + '/unityasset/' + name, { method: 'HEAD' })).status
|
||||
).toBe(200)
|
||||
const response = await exports.default.fetch(ORIGIN + '/unityasset/' + name, {
|
||||
headers: { Range: 'bytes=0-7' },
|
||||
})
|
||||
expect(response.status).toBe(206)
|
||||
expect(await response.text()).toBe('UnityFS\0')
|
||||
})
|
||||
|
||||
describe('cdn endpoints', () => {
|
||||
test('GET / reports service status', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/`)
|
||||
@@ -357,6 +383,18 @@ describe('cdn endpoints', () => {
|
||||
expect(head.status).toBe(200)
|
||||
expect(head.headers.get('content-length')).toBe('4')
|
||||
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
|
||||
// Current room/consumable builds live in the game's room namespace.
|
||||
await env.CDN_ASSETS.put('room/' + filename, new Uint8Array([5, 6, 7, 8, 9]))
|
||||
const current = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
|
||||
headers: { Range: 'bytes=1-2' },
|
||||
})
|
||||
expect(current.status).toBe(206)
|
||||
expect(new Uint8Array(await current.arrayBuffer())).toEqual(new Uint8Array([6, 7]))
|
||||
const currentHead = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
|
||||
method: 'HEAD',
|
||||
})
|
||||
expect(currentHead.status).toBe(200)
|
||||
expect(currentHead.headers.get('content-length')).toBe('5')
|
||||
|
||||
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
|
||||
expect(
|
||||
|
||||
@@ -106,6 +106,8 @@ import {
|
||||
ConsumeConsumableRequest,
|
||||
ConsumeEnvelope,
|
||||
ConsumeGiftRequest,
|
||||
ConsumeRoomConsumableRequest,
|
||||
ConsumeRoomConsumableResponse,
|
||||
CreatePurchaseOfferRequest,
|
||||
CreateRoomCurrencyRequest,
|
||||
CreateRoomKeyRequest,
|
||||
@@ -177,6 +179,7 @@ import {
|
||||
import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
|
||||
import {
|
||||
awardRoomConsumable,
|
||||
consumeRoomConsumable,
|
||||
getRoomConsumable,
|
||||
getRoomConsumables,
|
||||
getRoomInventory,
|
||||
@@ -4220,6 +4223,65 @@ const app = new Hono<App>({ strict: false })
|
||||
return c.json(await getRoomInventory(c.env.DB, roomId, id))
|
||||
}
|
||||
)
|
||||
// Use up one of a room consumable the CALLER holds — drinking the Health Potion. Auth-gated
|
||||
// (401); a refusal is a 200 in the same shape, as the purchase's is.
|
||||
.put(
|
||||
'/api/roomconsumables/v1/roomConsumable/:id/consume',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Consume a room consumable',
|
||||
description: [
|
||||
'Takes ONE of the consumable named in the path from what the caller holds. The body',
|
||||
'is only `{ CurrentConcurrencyCode, NewConcurrencyCode }`, at the top level; there is',
|
||||
'no quantity. `NewConcurrencyCode` is stored and served back as the item’s',
|
||||
'`ConcurrencyCode`; `CurrentConcurrencyCode` is accepted and NOT checked.',
|
||||
'',
|
||||
'Answers `{ Status, InventoryItem }` — the purchase’s `OperationResult` — with the',
|
||||
'`RoomConsumableInventoryItem` the caller now holds: `Count` the RESULTING total, 0',
|
||||
'when they used their last (after which `…/room/{roomId}/me` omits it).',
|
||||
'',
|
||||
'A consumable that does not exist, or one the caller holds none of, is `Status` 1',
|
||||
'with a null `InventoryItem`, and nothing is written. The refusal form is an',
|
||||
'ASSUMPTION — only success has been observed.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The consumable to use — a `room_consumable` id',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
requestBody: jsonBody(ConsumeRoomConsumableRequest, 'The concurrency pair'),
|
||||
responses: {
|
||||
200: json(ConsumeRoomConsumableResponse, 'What the caller now holds, or a refusal'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.json<{ NewConcurrencyCode?: unknown }>().catch(() => null)
|
||||
const newCode =
|
||||
body !== null && typeof body === 'object' && typeof body.NewConcurrencyCode === 'string'
|
||||
? body.NewConcurrencyCode
|
||||
: null
|
||||
// Every key is always sent: an omitted `Status` reads as its enum's 0, success.
|
||||
const refused = () => c.json({ Status: ROOM_KEY_STATUS_FAILED, InventoryItem: null })
|
||||
|
||||
const consumable = await getRoomConsumable(c.env.DB, c.req.param('id'))
|
||||
if (!consumable) return refused()
|
||||
const held = await consumeRoomConsumable(c.env.DB, id, consumable.RoomConsumableId, newCode)
|
||||
if (!held) return refused()
|
||||
return c.json({
|
||||
Status: ROOM_KEY_STATUS_OK,
|
||||
InventoryItem: toInventoryItem(id, held, consumable),
|
||||
})
|
||||
}
|
||||
)
|
||||
// The custom currencies a room has minted. Public, like the room itself: a room's
|
||||
// currencies are shown to every player who walks into it, so the list is not a secret
|
||||
// the way its ban list is. A room with none — or an unknown room — is an empty list.
|
||||
|
||||
@@ -1159,6 +1159,27 @@ export const PurchaseRoomConsumableResponse = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` — just the concurrency pair, at the
|
||||
* TOP level (not nested under `ConcurrencyCodes` as the purchase sends it). Uses up one.
|
||||
*/
|
||||
export const ConsumeRoomConsumableRequest = z.object({
|
||||
CurrentConcurrencyCode: z.string().nullable().optional().describe('Accepted and NOT checked'),
|
||||
NewConcurrencyCode: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Stored and echoed back as the item’s `ConcurrencyCode`'),
|
||||
})
|
||||
|
||||
/** The consume's answer: the purchase's `OperationResult`, alone. */
|
||||
export const ConsumeRoomConsumableResponse = z.object({
|
||||
Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'),
|
||||
InventoryItem: RoomConsumableInventoryItemDto.nullable().describe(
|
||||
'What the caller holds afterwards (`Count` the resulting total, 0 included); null on a refusal'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* The envelope the consumable write answers in — the same
|
||||
* `{ Value, Success, Error, error_id }` the room-currency writes use.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* - `POST /api/roomconsumables/v1/roomConsumable/awardBulk` gives one to a player
|
||||
* - `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens` and `…/purchase/currency`
|
||||
* sell one, in tokens or the listing's room currency
|
||||
* - `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` uses up one the caller holds
|
||||
*
|
||||
* `PurchaseCurrencyId` is a `room_currency`, so a room prices its goods in its own money. It
|
||||
* is nullable — `Guid?` in the client's model — for a listing that names no currency, and
|
||||
@@ -344,6 +345,42 @@ export async function awardRoomConsumable(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use up ONE of a consumable a player holds, returning what they hold afterwards — or null
|
||||
* when they hold none, and nothing was written. `concurrencyCode` is the request's
|
||||
* `NewConcurrencyCode`, stored to be served back; null leaves the stored one.
|
||||
*
|
||||
* A single conditional UPDATE: the `quantity >= 1` guard is what refuses, so two consumes
|
||||
* racing over the last one cannot both succeed. Here an empty `RETURNING` is exactly the
|
||||
* answer wanted — "you had none" — unlike the award, where it would be ambiguous.
|
||||
*/
|
||||
export async function consumeRoomConsumable(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
consumableId: string,
|
||||
concurrencyCode: string | null = null
|
||||
): Promise<RoomInventoryHolding | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`UPDATE room_inventory SET
|
||||
quantity = quantity - 1,
|
||||
concurrency_code = COALESCE(?3, concurrency_code),
|
||||
modified_at = ?4
|
||||
WHERE player_id = ?1 AND consumable_id = ?2 AND quantity >= 1
|
||||
RETURNING quantity, concurrency_code, modified_at`
|
||||
)
|
||||
.bind(playerId, consumableId, concurrencyCode, new Date().toISOString())
|
||||
.first<{ quantity: number; concurrency_code: string | null; modified_at: string | null }>()
|
||||
|
||||
return row
|
||||
? {
|
||||
quantity: row.quantity,
|
||||
concurrencyCode: row.concurrency_code,
|
||||
modifiedAt: row.modified_at,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a player owns of one room's consumables, in the shop's order. A listing they own
|
||||
* none of is absent — including one they held and spent down to zero.
|
||||
|
||||
@@ -1773,6 +1773,77 @@ describe('econ endpoints', () => {
|
||||
await env.DB.prepare('DELETE FROM room_balance WHERE currency_id = ?1').bind(coin).run()
|
||||
})
|
||||
|
||||
test('PUT roomConsumable/:id/consume uses up one the caller holds', async () => {
|
||||
const potion = (
|
||||
await envOf(await save({ ...body, Name: 'Drinkable potion' }, await bearer('1')))
|
||||
).Value!
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO room_inventory (player_id, consumable_id, quantity) VALUES (7801, ?1, 2)'
|
||||
)
|
||||
.bind(potion.RoomConsumableId)
|
||||
.run()
|
||||
|
||||
const consume = async (sub: string | null, consumableId: string, code: string) =>
|
||||
exports.default.fetch(
|
||||
`${ORIGIN}/api/roomconsumables/v1/roomConsumable/${consumableId}/consume`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(sub === null ? {} : await bearer(sub)),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
CurrentConcurrencyCode: '872adb91-b163-4f2c-aa8f-4f5d828078cb',
|
||||
NewConcurrencyCode: code,
|
||||
}),
|
||||
}
|
||||
)
|
||||
const held = async () =>
|
||||
(
|
||||
await env.DB.prepare(
|
||||
'SELECT quantity FROM room_inventory WHERE player_id = 7801 AND consumable_id = ?1'
|
||||
)
|
||||
.bind(potion.RoomConsumableId)
|
||||
.first<{ quantity: number }>()
|
||||
)?.quantity
|
||||
|
||||
expect((await consume(null, potion.RoomConsumableId, 'a')).status).toBe(401)
|
||||
|
||||
const res = await consume('7801', potion.RoomConsumableId, 'code-1')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
Status: 0,
|
||||
InventoryItem: {
|
||||
RoomConsumableId: potion.RoomConsumableId,
|
||||
AccountId: 7801,
|
||||
Count: 1,
|
||||
ConcurrencyCode: 'code-1',
|
||||
ModifiedAt: expect.any(String),
|
||||
Consumable: { ...potion, ImageName: '', IsGiftable: false, Visibility: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
// The last one answers Count 0; after that there is nothing to use.
|
||||
const last = (await (await consume('7801', potion.RoomConsumableId, 'code-2')).json()) as {
|
||||
Status: number
|
||||
InventoryItem: { Count: number; ConcurrencyCode: string } | null
|
||||
}
|
||||
expect(last).toMatchObject({
|
||||
Status: 0,
|
||||
InventoryItem: { Count: 0, ConcurrencyCode: 'code-2' },
|
||||
})
|
||||
const refusal = { Status: 1, InventoryItem: null }
|
||||
expect(await (await consume('7801', potion.RoomConsumableId, 'code-3')).json()).toEqual(
|
||||
refusal
|
||||
)
|
||||
expect(await held()).toBe(0)
|
||||
// Someone who never had one, and an unknown consumable, are refused too.
|
||||
expect(await (await consume('7802', potion.RoomConsumableId, 'x')).json()).toEqual(refusal)
|
||||
expect(await (await consume('7801', 'nope', 'x')).json()).toEqual(refusal)
|
||||
|
||||
await env.DB.prepare('DELETE FROM room_inventory').run()
|
||||
})
|
||||
|
||||
test('GET roomConsumable/room/:id is public and empty for an unknown room', async () => {
|
||||
expect(await shopOf(99999)).toEqual([])
|
||||
const res = await exports.default.fetch(
|
||||
@@ -8047,6 +8118,7 @@ describe('econ endpoints', () => {
|
||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||
'PUT /api/equipment/v1/update',
|
||||
'PUT /api/roomconsumables/v1/roomConsumable',
|
||||
'PUT /api/roomconsumables/v1/roomConsumable/{id}/consume',
|
||||
'PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{via}',
|
||||
'PUT /api/roomkeys/v1/updateAll',
|
||||
])
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||
import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
|
||||
import {
|
||||
banFromReport,
|
||||
@@ -289,6 +290,9 @@ beforeAll(async () => {
|
||||
// Platform identity links (owned by the auth worker) — a ban also reaches the
|
||||
// accounts sharing a proven identity with the banned one.
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Custom avatar items (owned by the api worker) — every room read joins the published
|
||||
// room badges from this table.
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,13 @@ Player-settings worker served on the `playersettings` subdomain.
|
||||
> Emptying the map with DELETE puts the player back to a first read: the next
|
||||
> `GET` re-seeds the defaults.
|
||||
|
||||
> Keys under `Growth.YIR.` (the client's Year-In-Review telemetry — distance
|
||||
> drawn, time with friends, …) and `MakerPenSessionCount` are **dropped** on
|
||||
> `PUT`, not stored. The client posts them with a new value every time, so they
|
||||
> were over half of all writes and nothing reads them. Real maker pen preferences
|
||||
> (`MakerPen_SnappingMode`, …) are kept. See `IGNORED_SETTING_KEYS` /
|
||||
> `IGNORED_SETTING_PREFIXES` in `default-settings.ts`.
|
||||
|
||||
## KV namespace
|
||||
|
||||
```sh
|
||||
|
||||
@@ -8,3 +8,21 @@ export const DEFAULT_SETTINGS: Array<{ Key: string; Value: string }> = [
|
||||
{ Key: 'TUTORIAL_COMPLETE_MASK', Value: '11' },
|
||||
{ Key: 'FIRST_TIME_IN_FLAGS', Value: '0' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Settings the server DROPS on write, by exact key and by prefix. The client posts its
|
||||
* Year-In-Review telemetry (`Growth.YIR.UGC.TubeDistance`, `Growth.YIR.DistanceTraveled`, …)
|
||||
* and the `MakerPenSessionCount` counter through this endpoint as the player plays, and the
|
||||
* values change every time, so they can never be deduplicated — measured at ~500 CCU they
|
||||
* were over half of all settings writes. Nothing server-side reads them and the client works
|
||||
* without them (a new player has none), so a PUT naming one is answered 200 and nothing is
|
||||
* stored. Real maker pen preferences (`MakerPen_SnappingMode` and the other `MakerPen_*`
|
||||
* keys) are kept: the client reads those back.
|
||||
*/
|
||||
export const IGNORED_SETTING_KEYS: ReadonlySet<string> = new Set(['MakerPenSessionCount'])
|
||||
export const IGNORED_SETTING_PREFIXES: readonly string[] = ['Growth.YIR.']
|
||||
|
||||
/** Whether a posted setting is one the server drops rather than stores. */
|
||||
export function isIgnoredSetting(key: string): boolean {
|
||||
return IGNORED_SETTING_KEYS.has(key) || IGNORED_SETTING_PREFIXES.some((p) => key.startsWith(p))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { mergePlayerSettings } from '@repo/domain'
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import { DEFAULT_SETTINGS } from './default-settings'
|
||||
import { DEFAULT_SETTINGS, isIgnoredSetting } from './default-settings'
|
||||
import {
|
||||
AUTHED,
|
||||
formOrJson,
|
||||
@@ -196,7 +196,9 @@ const app = new Hono<App>()
|
||||
'player’s other settings alone. A JSON body is also accepted, as one object or an',
|
||||
'array, in either `key`/`value` or `Key`/`Value` casing; entries with an empty key are',
|
||||
'dropped. An unparseable or empty body is a no-op 200, not a 400, and so is a PUT whose',
|
||||
'values are already stored — nothing is written to KV. Empty body on success.',
|
||||
'values are already stored — nothing is written to KV. Keys under `Growth.YIR.` (the',
|
||||
'client’s Year-In-Review telemetry) and `MakerPenSessionCount`, which change on every',
|
||||
'post and nothing reads, are dropped rather than stored. Empty body on success.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
|
||||
@@ -209,7 +211,7 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const incoming = await parseSettings(c)
|
||||
const incoming = (await parseSettings(c)).filter((s) => !isIgnoredSetting(s.key))
|
||||
if (incoming.length === 0) return c.body(null, 200)
|
||||
|
||||
const patch: Record<string, string> = {}
|
||||
|
||||
@@ -164,6 +164,42 @@ describe('playersettings endpoints', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('PUT /playersettings drops Growth.YIR and MakerPenSessionCount without writing', async () => {
|
||||
const stored = '{ "PlayerSessionCount": "5" }'
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put('player:11', stored)
|
||||
|
||||
const form = await SELF.fetch(
|
||||
`${ORIGIN}/playersettings`,
|
||||
putForm({ key: 'Growth.YIR.UGC.TubeDistance', value: '1234.5' }, await bearer('11'))
|
||||
)
|
||||
expect(form.status).toBe(200)
|
||||
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'text')).toBe(stored)
|
||||
|
||||
// A JSON batch keeps the real settings (a maker pen PREFERENCE included) and drops
|
||||
// only the telemetry and the session counter.
|
||||
const json = await SELF.fetch(`${ORIGIN}/playersettings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json', ...(await bearer('11')) },
|
||||
body: JSON.stringify([
|
||||
{ key: 'Growth.YIR.DistanceTraveled', value: '99' },
|
||||
{ key: 'MakerPenSessionCount', value: '3' },
|
||||
{ key: 'MakerPen_SnappingMode', value: '2' },
|
||||
]),
|
||||
})
|
||||
expect(json.status).toBe(200)
|
||||
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'json')).toEqual({
|
||||
PlayerSessionCount: '5',
|
||||
MakerPen_SnappingMode: '2',
|
||||
})
|
||||
|
||||
// A player with nothing stored stays unseeded: no key is created for telemetry alone.
|
||||
await SELF.fetch(
|
||||
`${ORIGIN}/playersettings`,
|
||||
putForm({ key: 'Growth.YIR.TimeSpentWithFriends', value: '7' }, await bearer('12'))
|
||||
)
|
||||
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:12', 'text')).toBeNull()
|
||||
})
|
||||
|
||||
it('PUT /playersettings 200s with no parseable settings', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9')))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- The searchable rooms by lowercased name, for the website's header search
|
||||
-- (`GET /rooms/search/name`, searchRoomsByName).
|
||||
--
|
||||
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- PARTIAL over exactly SEARCHABLE_WHERE, so a prefix seek visits only rooms it can serve
|
||||
-- and the LIMIT stops it: D1 bills the rows returned, not the room count. The existing
|
||||
-- `idx_rooms_name_lower` holds every dorm and private room, and each one the seek passed
|
||||
-- would be read to test the filters. Built from the VIRTUAL columns, so no backfill.
|
||||
CREATE INDEX IF NOT EXISTS idx_room_search_name ON room (name_lower)
|
||||
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1;
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
roomRoles,
|
||||
saveSubRoomData,
|
||||
searchRooms,
|
||||
searchRoomsByName,
|
||||
setPlayerData,
|
||||
setRoomDescription,
|
||||
setRoomImage,
|
||||
@@ -83,6 +84,7 @@ import {
|
||||
transferRoomOwnership,
|
||||
unbanPlayerFromRoom,
|
||||
updateRoomFields,
|
||||
withConfirmedRoles,
|
||||
writeAuditLog,
|
||||
} from '@repo/domain'
|
||||
import {
|
||||
@@ -665,16 +667,14 @@ function parseMaxPlayerCalculationMode(value: unknown): number | undefined {
|
||||
return Object.values(MAX_PLAYER_CALCULATION_MODES).includes(ordinal) ? ordinal : undefined
|
||||
}
|
||||
|
||||
async function pushRoomUpdate(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
room: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
async function pushRoomUpdate(c: Context<App>, playerId: number, room: Room): Promise<void> {
|
||||
try {
|
||||
// The pushed room is the client's room, like any served one: pending invites are not
|
||||
// part of its `Roles` (see `serveRoom`).
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
NotificationType.SubscriptionUpdateRoom,
|
||||
room
|
||||
withConfirmedRoles(room)
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push RoomUpdate notification', {
|
||||
@@ -702,7 +702,7 @@ async function pushRoomUpdate(
|
||||
async function pushRoomUpdateToRoom(
|
||||
c: Context<App>,
|
||||
roomId: number,
|
||||
room: Record<string, unknown>,
|
||||
room: Room,
|
||||
alsoNotify: number[] = []
|
||||
): Promise<void> {
|
||||
const playerIds = new Set(await getPlayerIdsInRoom(c.env.DB, roomId))
|
||||
@@ -962,9 +962,15 @@ function pickBakedBundle(
|
||||
target: number | null
|
||||
): { filename: string; hash: string | null } | null {
|
||||
if (!Array.isArray(save.UnitySubAssets)) return null
|
||||
const rootId = typeof save.UnityAssetId === 'string' ? save.UnityAssetId.toLowerCase() : null
|
||||
const rows = save.UnitySubAssets.filter(
|
||||
(item): item is { Target?: unknown; Filename?: unknown; Hash?: unknown } =>
|
||||
typeof item === 'object' && item !== null
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
(rootId === null ||
|
||||
('UnityAssetId' in item &&
|
||||
typeof item.UnityAssetId === 'string' &&
|
||||
item.UnityAssetId.toLowerCase() === rootId))
|
||||
)
|
||||
const chosen =
|
||||
target === null
|
||||
@@ -1108,16 +1114,44 @@ function toSaveDetail(save: Record<string, unknown>, target: number | null) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Client envelope for room mutations: `{ success, error, value }` (lowercase). */
|
||||
function roomEnvelope(c: Context<App>, value: unknown, error = '') {
|
||||
return c.json({ success: error === '', error, value })
|
||||
/**
|
||||
* A room as the client is shown it. The stored room is served whole EXCEPT for its pending
|
||||
* role invites: a `Roles` entry with `Role` 0 and an `InvitedRole` is an offer the player
|
||||
* has not answered, and served as-is it put the invited player on the room's member list
|
||||
* (and, on the 2025 client, in its co-owner list) before they had accepted anything. The
|
||||
* offer reaches them as a `RoomCoOwnerInvited` message; the entry stays in the STORED room
|
||||
* for the accept to promote, which is why this is applied where the room leaves the worker
|
||||
* and never to a room about to be written back.
|
||||
*
|
||||
* Every room this worker answers — alone, in a list, in the mutation envelope or pushed as
|
||||
* a `RoomUpdate` — goes through this (or {@link serveRooms}).
|
||||
*/
|
||||
const serveRoom = withConfirmedRoles
|
||||
|
||||
/** {@link serveRoom} over a list. */
|
||||
const serveRooms = (rooms: Room[]): Room[] => rooms.map(serveRoom)
|
||||
|
||||
/** {@link serveRoom} over a `{ Results, TotalResults }` page. */
|
||||
const serveRoomPage = <T extends { Results: Room[] }>(page: T): T => ({
|
||||
...page,
|
||||
Results: serveRooms(page.Results),
|
||||
})
|
||||
|
||||
/**
|
||||
* Client envelope for room mutations: `{ success, error, value }` (lowercase). `value` is
|
||||
* the updated room (served through {@link serveRoom}) or null on a refusal.
|
||||
*/
|
||||
function roomEnvelope(c: Context<App>, value: Room | null, error = '') {
|
||||
return c.json({ success: error === '', error, value: value && serveRoom(value) })
|
||||
}
|
||||
|
||||
/**
|
||||
* The same envelope for the ban write, whose `value` is the BAN rather than the room —
|
||||
* a ban isn't part of the room the client renders, so there is no updated room to send.
|
||||
*/
|
||||
const banEnvelope = roomEnvelope
|
||||
function banEnvelope(c: Context<App>, value: unknown, error = '') {
|
||||
return c.json({ success: error === '', error, value })
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope both leaderboard routes answer: `{ Success, Error, error_id }`, carrying
|
||||
@@ -1156,7 +1190,7 @@ const experienceEnvelope = leaderboardEnvelope
|
||||
async function ownedRooms(c: Context<App>) {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
return c.json(await getRoomsByCreator(c.env.DB, accountId))
|
||||
return c.json(serveRooms(await getRoomsByCreator(c.env.DB, accountId)))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1169,12 +1203,15 @@ async function ownedRoomsExcludingDorm(c: Context<App>) {
|
||||
const rooms = (await getContributedRooms(c.env.DB, accountId)).filter((room) =>
|
||||
canManageRoom(room, accountId)
|
||||
)
|
||||
return c.json(rooms.filter((r) => r.IsDorm !== true))
|
||||
return c.json(serveRooms(rooms.filter((r) => r.IsDorm !== true)))
|
||||
}
|
||||
|
||||
/** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */
|
||||
const DEFAULT_SUGGESTION_COUNT = 10
|
||||
|
||||
/** Cap on `/rooms/search/name`'s `take`: a type-ahead list, so the seek stays a few rows. */
|
||||
const NAME_SEARCH_MAX = 10
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -1325,10 +1362,10 @@ const app = new Hono<App>()
|
||||
if (idParam) {
|
||||
const id = firstId(idParam)
|
||||
const room = id === undefined ? null : await getRoomById(c.env.DB, id)
|
||||
return c.json(room ?? (c.req.query('include') === '525581' ? null : {}))
|
||||
return c.json(room ? serveRoom(room) : c.req.query('include') === '525581' ? null : {})
|
||||
}
|
||||
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
||||
return c.json(room ?? (c.req.query('include') === '525581' ? null : {}))
|
||||
return c.json(room ? serveRoom(room) : c.req.query('include') === '525581' ? null : {})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1360,7 +1397,7 @@ const app = new Hono<App>()
|
||||
return c.json({ error: `You can only have ${maxRooms} rooms.` }, 403)
|
||||
const room = await createStudioRoom(c.env.DB, name, accountId, version)
|
||||
if (!room) return c.json({ error: 'A room with that name already exists.' }, 409)
|
||||
return c.json(room)
|
||||
return c.json(serveRoom(room))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1398,7 +1435,39 @@ const app = new Hono<App>()
|
||||
const query = c.req.query('query') ?? ''
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30
|
||||
return c.json(await searchRooms(c.env.DB, query, skip, take))
|
||||
return c.json(serveRoomPage(await searchRooms(c.env.DB, query, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
// The website's header search: searchable rooms whose name STARTS with `query`, A–Z,
|
||||
// as a bare array. Not the game's search (`/rooms/search` is a substring match that
|
||||
// reads every searchable room); this is an index seek that reads only the rows it
|
||||
// returns, which is what lets the site ask on every pause in typing. Un-hydrated rooms.
|
||||
.get(
|
||||
'/rooms/search/name',
|
||||
describeRoute({
|
||||
tags: ['Discovery'],
|
||||
summary: 'Prefix-search rooms by name',
|
||||
description: [
|
||||
'Searchable rooms (public, non-dorm, not `ExcludeFromSearch`) whose name begins',
|
||||
'with `query`, case-insensitive, alphabetically — a bare array. An index seek, so',
|
||||
'it reads only the rooms it returns. Rooms are served without subrooms, tags or',
|
||||
'badges. The game’s substring search is `/rooms/search`.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
stringQuery('query', 'Name prefix'),
|
||||
intQuery('take', `How many rooms to return (default and max ${NAME_SEARCH_MAX})`),
|
||||
],
|
||||
responses: { 200: json(RoomDto.array(), 'The matching rooms, A–Z') },
|
||||
}),
|
||||
async (c) => {
|
||||
const take = Number.parseInt(c.req.query('take') ?? '', 10)
|
||||
const rooms = await searchRoomsByName(
|
||||
c.env.DB,
|
||||
c.req.query('query') ?? '',
|
||||
Number.isNaN(take) ? NAME_SEARCH_MAX : Math.min(take, NAME_SEARCH_MAX)
|
||||
)
|
||||
return c.json(serveRooms(rooms))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1483,7 +1552,7 @@ const app = new Hono<App>()
|
||||
const tag = c.req.query('tag') ?? ''
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getHotRooms(c.env.DB, tag, skip, take))
|
||||
return c.json(serveRoomPage(await getHotRooms(c.env.DB, tag, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1535,7 +1604,7 @@ const app = new Hono<App>()
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getTrendingRooms(c.env.DB, skip, take))
|
||||
return c.json(serveRoomPage(await getTrendingRooms(c.env.DB, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1557,7 +1626,7 @@ const app = new Hono<App>()
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getBaseRooms(c.env.DB, skip, take))
|
||||
return c.json(serveRooms(await getBaseRooms(c.env.DB, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1585,7 +1654,7 @@ const app = new Hono<App>()
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getRecommendedRooms(c.env.DB, skip, take))
|
||||
return c.json(serveRooms(await getRecommendedRooms(c.env.DB, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1680,13 +1749,15 @@ const app = new Hono<App>()
|
||||
if (ids.length > MAX_BULK_ROOM_IDS) return tooManyIds(c)
|
||||
const rooms = await getRoomsByIds(c.env.DB, ids)
|
||||
return c.json(
|
||||
excludePrivateRooms(c.req.query('excludePrivateRooms'))
|
||||
? rooms.filter((r) => r.Accessibility === 1)
|
||||
: rooms
|
||||
serveRooms(
|
||||
excludePrivateRooms(c.req.query('excludePrivateRooms'))
|
||||
? rooms.filter((r) => r.Accessibility === 1)
|
||||
: rooms
|
||||
)
|
||||
)
|
||||
}
|
||||
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
||||
return c.json(room ? [room] : [])
|
||||
return c.json(room ? [serveRoom(room)] : [])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1738,7 +1809,7 @@ const app = new Hono<App>()
|
||||
const rooms = await getRoomsByIds(c.env.DB, ids)
|
||||
|
||||
const excludePrivate = excludePrivateRooms(field('excludePrivateRooms')[0])
|
||||
return c.json(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms)
|
||||
return c.json(serveRooms(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1829,7 +1900,7 @@ const app = new Hono<App>()
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
return c.json(await getContributedRooms(c.env.DB, accountId))
|
||||
return c.json(serveRooms(await getContributedRooms(c.env.DB, accountId)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1886,7 +1957,11 @@ const app = new Hono<App>()
|
||||
responses: { 200: json(RoomDto.array(), 'That account’s public rooms') },
|
||||
}),
|
||||
async (c) =>
|
||||
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
|
||||
c.json(
|
||||
serveRooms(
|
||||
await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// A player's showcased rooms — the hand-picked rail the client draws on a profile,
|
||||
@@ -1934,7 +2009,7 @@ const app = new Hono<App>()
|
||||
if (accountId === null) return unauthorized(c)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getFavoritedRooms(c.env.DB, accountId, skip, take))
|
||||
return c.json(serveRooms(await getFavoritedRooms(c.env.DB, accountId, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1958,7 +2033,7 @@ const app = new Hono<App>()
|
||||
if (accountId === null) return unauthorized(c)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getVisitedRooms(c.env.DB, accountId, skip, take))
|
||||
return c.json(serveRooms(await getVisitedRooms(c.env.DB, accountId, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2004,9 +2079,11 @@ const app = new Hono<App>()
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(
|
||||
own
|
||||
? await getVisitedRooms(c.env.DB, playerId, skip, take)
|
||||
: await getPublicVisitedRooms(c.env.DB, playerId, skip, take)
|
||||
serveRooms(
|
||||
own
|
||||
? await getVisitedRooms(c.env.DB, playerId, skip, take)
|
||||
: await getPublicVisitedRooms(c.env.DB, playerId, skip, take)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -2617,9 +2694,14 @@ const app = new Hono<App>()
|
||||
summary: 'A room’s roles',
|
||||
description: [
|
||||
'Everyone with a role in the room — its creator plus whoever has been granted Host,',
|
||||
'Moderator or CoOwner — as whole `RoomRole` records. This is the same array the',
|
||||
'room DTO serves under `Roles`, on its own endpoint, so it is public exactly like the',
|
||||
'room is: a role list says who runs a room, which the room page already shows.',
|
||||
'Moderator or CoOwner — as whole `RoomRole` records, PLUS every pending invite. It is',
|
||||
'public exactly like the room is: a role list says who runs a room, which the room',
|
||||
'page already shows.',
|
||||
'',
|
||||
'Unlike the room DTO’s `Roles`, this list INCLUDES unanswered co-owner invites (`Role`',
|
||||
'0, the offer on `InvitedRole`): the client reads it to show the players with an',
|
||||
'invite pending. The room DTO leaves those entries out, because there an invited',
|
||||
'player read as a member before they had accepted anything.',
|
||||
'',
|
||||
'A bare array, NOT the `{ success, error, value }` envelope the role WRITE answers. An',
|
||||
'unknown room is an empty list rather than an error — it reads the same as a room',
|
||||
@@ -4305,7 +4387,7 @@ const app = new Hono<App>()
|
||||
return c.json({
|
||||
success: true,
|
||||
error: null,
|
||||
value: { room: result.room, subRoomDataSave: toSaveResponse(result.save) },
|
||||
value: { room: serveRoom(result.room), subRoomDataSave: toSaveResponse(result.save) },
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -4856,7 +4938,9 @@ const app = new Hono<App>()
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(
|
||||
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
|
||||
serveRoomPage(
|
||||
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -5124,7 +5208,9 @@ const app = new Hono<App>()
|
||||
tags: ['Rooms'],
|
||||
summary: 'A room by id',
|
||||
description: [
|
||||
'The room as stored, with its `SubRooms` re-attached. Unlike `GET /rooms?id=`, an',
|
||||
'The room as stored, with its `SubRooms` re-attached and pending role invites left',
|
||||
'out of `Roles` (an unanswered invite is `Role` 0 with the offer on `InvitedRole`; the',
|
||||
'invited player is not a member until they accept). Unlike `GET /rooms?id=`, an',
|
||||
'unknown room here is a 404, not `{}`. A save that has a Rec Room Studio build',
|
||||
'lists its main bundles on `CurrentSave.UnitySubAssets` (`Target` 0 Windows, 2',
|
||||
'Android/Quest, `Filename` downloaded from the CDN at `/unityasset/{filename}`).',
|
||||
@@ -5147,7 +5233,7 @@ const app = new Hono<App>()
|
||||
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
|
||||
if (!room) return c.notFound()
|
||||
filterRoomUnityAssets(room, unityAssetTargetQuery(c))
|
||||
return c.json(room)
|
||||
return c.json(serveRoom(room))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
answerRoomRoleInvite,
|
||||
AUDIT_LOG_SCHEMA_DDL,
|
||||
countRoomsUsingInvention,
|
||||
createRoomInstance,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
UNITY_ASSET_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
||||
import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db'
|
||||
import { NotificationType } from '../../../../notify/src/notification-types'
|
||||
@@ -134,6 +136,7 @@ beforeAll(async () => {
|
||||
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Inventions (owned by the api worker) — a room save writes each one's room count.
|
||||
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves.
|
||||
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
@@ -538,6 +541,20 @@ describe('rooms endpoints', () => {
|
||||
Roles: [{ AccountId: 822, Role: 30 }],
|
||||
})
|
||||
await seed({ RoomId: 30405, Name: 'ContribNoRoles', CreatorAccountId: 821, SubRooms: [] })
|
||||
// ...and one where 820 has only been INVITED to co-own — `Role` 0 with the offer on
|
||||
// `InvitedRole`. They have no job in the room until they accept, so it stays out of
|
||||
// both of their lists, and the pending entry is not served as part of the room.
|
||||
await seed({
|
||||
RoomId: 30408,
|
||||
Name: 'ContribInvited',
|
||||
CreatorAccountId: 821,
|
||||
Accessibility: 1,
|
||||
SubRooms: [],
|
||||
Roles: [
|
||||
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
{ AccountId: 820, Role: 0, LastChangedByAccountId: 821, InvitedRole: 30 },
|
||||
],
|
||||
})
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
|
||||
headers: await bearer('820'),
|
||||
@@ -552,6 +569,46 @@ describe('rooms endpoints', () => {
|
||||
expect(rooms.some((r) => r.RoomId === 30407)).toBe(false)
|
||||
expect(rooms[0]).toMatchObject({ Name: expect.any(String), Accessibility: expect.any(Number) })
|
||||
|
||||
// The unanswered invite lists the room for NOBODY: not under "contributed" (above),
|
||||
// not under "owned".
|
||||
const owned = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('820') })
|
||||
const ownedIds = ((await owned.json()) as Array<{ RoomId: number }>).map((r) => r.RoomId)
|
||||
expect(ownedIds).toContain(30401)
|
||||
expect(ownedIds).not.toContain(30408)
|
||||
|
||||
// Nor is the invited player part of the room as served: the room DTO carries only HELD
|
||||
// roles. `GET /rooms/:id/roles` DOES list the invite — the client shows pending invites
|
||||
// from it — and the stored room keeps the entry for the accept.
|
||||
type Served = { Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }> }
|
||||
const servedRoles = async () =>
|
||||
((await (await SELF.fetch(`${ORIGIN}/rooms/30408`)).json()) as Served).Roles
|
||||
expect(await servedRoles()).toEqual([
|
||||
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
])
|
||||
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30408/roles`)).json()).toEqual([
|
||||
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
{ AccountId: 820, Role: 0, LastChangedByAccountId: 821, InvitedRole: 30 },
|
||||
])
|
||||
expect(
|
||||
await env.DB.prepare(
|
||||
'SELECT role, invited_role FROM room_role WHERE room_id = 30408 AND account_id = 820'
|
||||
).first()
|
||||
).toEqual({ role: 0, invited_role: 30 })
|
||||
|
||||
// Accepting is what puts them in: the room appears in both lists and the entry is served.
|
||||
const invitedRoom = (await getRoomById(env.DB, 30408))!
|
||||
expect(await answerRoomRoleInvite(env.DB, 30408, 820, 30, invitedRoom)).not.toBeNull()
|
||||
expect(await contributed('820')).toContain(30408)
|
||||
const ownedAfter = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
|
||||
headers: await bearer('820'),
|
||||
})
|
||||
expect(((await ownedAfter.json()) as Array<{ RoomId: number }>).map((r) => r.RoomId)).toContain(
|
||||
30408
|
||||
)
|
||||
expect(await servedRoles()).toContainEqual(
|
||||
expect.objectContaining({ AccountId: 820, Role: 30, InvitedRole: 0 })
|
||||
)
|
||||
|
||||
// A player who contributes to nothing gets an empty array, not a 404.
|
||||
const none = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
|
||||
headers: await bearer('829'),
|
||||
@@ -577,8 +634,8 @@ describe('rooms endpoints', () => {
|
||||
|
||||
// The DB is shared across this file, and these are the only player-made public rooms
|
||||
// in it — leaving them behind changes what the `new`/`community` room feeds serve.
|
||||
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run()
|
||||
await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30407').run()
|
||||
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30408').run()
|
||||
await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30408').run()
|
||||
})
|
||||
|
||||
it('GET /rooms/:roomId/experience serves the room’s stored XP settings, no auth', async () => {
|
||||
@@ -624,6 +681,46 @@ describe('rooms endpoints', () => {
|
||||
expect(body.Results.some((r) => r.Name === 'RecCenter')).toBe(true)
|
||||
})
|
||||
|
||||
it('GET /rooms/search/name prefix-matches searchable rooms A–Z through the partial index', async () => {
|
||||
const search = async (query: string, extra = '') =>
|
||||
(await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/search/name?query=${encodeURIComponent(query)}${extra}`)
|
||||
).json()) as Array<{ Name: string; IsDorm?: boolean; Stats: { VisitCount: number } }>
|
||||
|
||||
// A bare array of whole rooms, case-insensitive, every name starting with the query, A–Z.
|
||||
const rec = await search('REC', '&take=10')
|
||||
expect(rec.some((r) => r.Name === 'RecCenter')).toBe(true)
|
||||
for (const r of rec) expect(r.Name.toLowerCase().startsWith('rec')).toBe(true)
|
||||
const names = rec.map((r) => r.Name.toLowerCase())
|
||||
expect(names).toEqual([...names].sort())
|
||||
expect(typeof rec[0]?.Stats.VisitCount).toBe('number')
|
||||
|
||||
// A prefix, not a substring: "center" is inside RecCenter but doesn't start it.
|
||||
expect((await search('center')).some((r) => r.Name === 'RecCenter')).toBe(false)
|
||||
// `take` caps it, and is itself capped at 10; empty and wildcard queries find nothing.
|
||||
expect((await search('r', '&take=2')).length).toBeLessThanOrEqual(2)
|
||||
expect(await search('', '&take=500')).toEqual([])
|
||||
expect(await search('%')).toEqual([])
|
||||
expect((await search('r', '&take=500')).length).toBeLessThanOrEqual(10)
|
||||
// Dorms (named `@user's Dorm`) are never served.
|
||||
expect((await search('@')).every((r) => r.IsDorm !== true)).toBe(true)
|
||||
|
||||
// The point of the endpoint: a SEEK on the partial index, so D1 reads only the rows it
|
||||
// returns. Fall back to a scan (or the unfiltered name index) and every call is billed
|
||||
// for every room again.
|
||||
const plan = await env.DB.prepare(
|
||||
`EXPLAIN QUERY PLAN SELECT data FROM room
|
||||
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1
|
||||
AND name_lower >= ?1 AND name_lower < ?2 AND name_lower LIKE ?3 ESCAPE '\\'
|
||||
ORDER BY name_lower LIMIT 5`
|
||||
)
|
||||
.bind('r', 'r\u{10FFFF}', 'r%')
|
||||
.all<{ detail: string }>()
|
||||
const detail = plan.results.map((r) => r.detail).join('\n')
|
||||
expect(detail).toContain('SEARCH room USING INDEX idx_room_search_name')
|
||||
expect(detail).not.toContain('TEMP B-TREE')
|
||||
})
|
||||
|
||||
it('GET /rooms/autocomplete_search suggests names and tags as plain strings', async () => {
|
||||
const suggest = async (query: string, extra = '') =>
|
||||
(await (
|
||||
@@ -1673,6 +1770,18 @@ describe('rooms endpoints', () => {
|
||||
type RoomEnv = { success: boolean; error: string; value: Record<string, unknown> | null }
|
||||
const envOf = async (res: Response) => (await res.json()) as RoomEnv
|
||||
|
||||
// A role entry as STORED, read off the `room_role` index — pending invites included.
|
||||
// The served room carries only held roles; `GET /rooms/:id/roles` lists invites too.
|
||||
type StoredRole = { Role: number; InvitedRole: number; LastChangedByAccountId: number | null }
|
||||
const storedRoleOf = async (roomId: number, accountId: number) =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT role AS Role, invited_role AS InvitedRole,
|
||||
last_changed_by_account_id AS LastChangedByAccountId
|
||||
FROM room_role WHERE room_id = ?1 AND account_id = ?2`
|
||||
)
|
||||
.bind(roomId, accountId)
|
||||
.first<StoredRole>()) ?? undefined
|
||||
|
||||
// A subroom as the client sees it. There is no GET for a single subroom — the client
|
||||
// reads them off the room — so tests do the same.
|
||||
const subRoomOf = async (
|
||||
@@ -2089,7 +2198,9 @@ describe('rooms endpoints', () => {
|
||||
success: false,
|
||||
error: 'You must provide a valid role!',
|
||||
})
|
||||
expect(await roleOf(885)).toMatchObject({ InvitedRole: 30 })
|
||||
expect(await storedRoleOf(2, 885)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
// The role list still shows the invite as pending.
|
||||
expect(await roleOf(885)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
})
|
||||
|
||||
it('a revoke pushes a RoomUpdate to the room and to the player who lost the role', async () => {
|
||||
@@ -2148,9 +2259,13 @@ describe('rooms endpoints', () => {
|
||||
)
|
||||
|
||||
// RecCenter (room 2) is owned by account 1. The owner invites 850 to co-ownership,
|
||||
// leaving a pending offer and no role yet.
|
||||
// leaving a pending offer and no role yet. The role list shows the pending invite, but
|
||||
// the room DTO does not list them: they are not a member until they accept.
|
||||
expect((await putForm('/rooms/2/roles/850/invite', { role: '30' }, '1')).status).toBe(200)
|
||||
expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Roles: Role[] }
|
||||
expect(room2.Roles.some((r) => r.AccountId === 850)).toBe(false)
|
||||
|
||||
// No token → 401 (auth gate).
|
||||
expect((await putForm('/rooms/2/roles/850', { role: '30' })).status).toBe(401)
|
||||
@@ -2163,7 +2278,7 @@ describe('rooms endpoints', () => {
|
||||
await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, caller))
|
||||
).toMatchObject({ success: false, error: 'Co-ownership must be invited, not granted!' })
|
||||
}
|
||||
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
// Unknown room → failure envelope.
|
||||
expect(
|
||||
await envOf(await putForm('/rooms/99999/roles/850', { role: '30' }, '850'))
|
||||
@@ -2180,7 +2295,7 @@ describe('rooms endpoints', () => {
|
||||
expect(await envOf(await putForm('/rooms/2/roles/850', { role: '255' }, '850'))).toMatchObject({
|
||||
success: false,
|
||||
})
|
||||
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
|
||||
// A player with no entry in the room at all is the same rejection — probing tells
|
||||
// them nothing.
|
||||
@@ -2227,11 +2342,13 @@ describe('rooms endpoints', () => {
|
||||
// Invited, then declined: the whole entry goes, leaving `Roles` as it was before the
|
||||
// offer rather than an entry with nothing pending on it.
|
||||
expect((await putForm('/rooms/2/roles/851/invite', { role: '30' }, '1')).status).toBe(200)
|
||||
expect(await storedRoleOf(2, 851)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
expect(await roleOf(851)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
|
||||
const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851')
|
||||
expect(declined.status).toBe(200)
|
||||
expect(await envOf(declined)).toMatchObject({ success: true, error: '' })
|
||||
expect(await storedRoleOf(2, 851)).toBeUndefined()
|
||||
expect(await roleOf(851)).toBeUndefined()
|
||||
|
||||
// And the decline can't be replayed either — there is no entry left to answer.
|
||||
@@ -2246,6 +2363,7 @@ describe('rooms endpoints', () => {
|
||||
expect(await roleOf(852)).toMatchObject({ Role: 10, InvitedRole: 0 })
|
||||
expect((await putForm('/rooms/2/roles/852/invite', { role: '30' }, '1')).status).toBe(200)
|
||||
expect((await putForm('/rooms/2/roles/852', { role: '0' }, '852')).status).toBe(200)
|
||||
expect(await storedRoleOf(2, 852)).toBeUndefined()
|
||||
expect(await roleOf(852)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -2493,12 +2611,23 @@ describe('rooms endpoints', () => {
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// A new entry, holding no role yet — only the pending offer, stamped with the inviter.
|
||||
// The role list shows it as a pending invite (the client lists pending invites from
|
||||
// it); the room DTO's `Roles` does not, until they accept.
|
||||
expect(await storedRoleOf(2, 187)).toEqual({
|
||||
Role: 0,
|
||||
InvitedRole: 30,
|
||||
LastChangedByAccountId: 1,
|
||||
})
|
||||
expect(await rolesOf(2)).toContainEqual({
|
||||
AccountId: 187,
|
||||
Role: 0,
|
||||
InvitedRole: 30,
|
||||
LastChangedByAccountId: 1,
|
||||
})
|
||||
const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
||||
Roles: Array<{ AccountId: number }>
|
||||
}
|
||||
expect(room2.Roles.some((r) => r.AccountId === 187)).toBe(false)
|
||||
|
||||
// One MessageReceived to the INVITED player carrying a type 62 RoomCoOwnerInvited —
|
||||
// the entry alone is silent, this is what raises the invite on their client.
|
||||
@@ -6162,6 +6291,7 @@ describe('rooms endpoints', () => {
|
||||
'GET /rooms/ownedby/{accountId}',
|
||||
'GET /rooms/recommendations',
|
||||
'GET /rooms/search',
|
||||
'GET /rooms/search/name',
|
||||
'GET /rooms/visitedby/me',
|
||||
'GET /rooms/visitedby/{playerId}',
|
||||
'GET /rooms/{roomId}',
|
||||
@@ -6329,6 +6459,111 @@ describe('studio room bundles', () => {
|
||||
)
|
||||
}
|
||||
|
||||
it('serves partitioned prefab and shared-material exports as save dependencies', async () => {
|
||||
await reset()
|
||||
await storeFiles()
|
||||
const prefab = '00111111-2222-4333-8444-555555555555'
|
||||
const shared = '00222222-2222-4333-8444-555555555555'
|
||||
await env.DB.prepare(
|
||||
"UPDATE subroom_save SET data=json_set(data,'$.ServerData',json(?1)) WHERE sub_room_id=99011"
|
||||
)
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
Exports: [
|
||||
{ e: 'prefab', u: prefab.toUpperCase() },
|
||||
{ e: 'shared', u: shared },
|
||||
{ e: 'duplicate', u: prefab },
|
||||
],
|
||||
})
|
||||
)
|
||||
.run()
|
||||
for (const id of [prefab, shared]) {
|
||||
for (const target of [0, 2]) {
|
||||
for (const kind of ['main', 'stripped'] as const) {
|
||||
await putUnityAsset(
|
||||
env.DB,
|
||||
{
|
||||
UnityAssetId: id,
|
||||
Target: target,
|
||||
Version: 5,
|
||||
Filename: publicStudioBundleFilename(
|
||||
9901,
|
||||
id,
|
||||
target === 0 ? 'windows' : 'android',
|
||||
kind
|
||||
),
|
||||
Hash: winHash,
|
||||
},
|
||||
kind
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const target of [0, 2]) {
|
||||
const response = await SELF.fetch(`${ORIGIN}/rooms/9901?unityAssetTarget=${target}`)
|
||||
const room = (await response.json()) as {
|
||||
SubRooms: Array<{
|
||||
CurrentSave: {
|
||||
SubRoomDataSaveId: number
|
||||
UnitySubAssets: Array<{ UnityAssetId: string; Target: number }>
|
||||
}
|
||||
}>
|
||||
}
|
||||
const current = room.SubRooms[0]!.CurrentSave
|
||||
expect(current.UnitySubAssets.map((asset) => asset.UnityAssetId).sort()).toEqual(
|
||||
[ASSET, prefab, shared].sort()
|
||||
)
|
||||
expect(current.UnitySubAssets.every((asset) => asset.Target === target)).toBe(true)
|
||||
const detailResponse = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${current.SubRoomDataSaveId}?unityAssetTarget=${target}`,
|
||||
{ headers: await bearer('1') }
|
||||
)
|
||||
expect(await detailResponse.json()).toMatchObject({
|
||||
UnityAsset: target === 0 ? winName : androidName,
|
||||
UnitySubAssets: [prefab, shared].map((id) => ({
|
||||
UnityAssetId: id,
|
||||
UnityAsset: publicStudioBundleFilename(
|
||||
9901,
|
||||
id,
|
||||
target === 0 ? 'windows' : 'android',
|
||||
'main'
|
||||
),
|
||||
UnityAssetHash: winHash,
|
||||
})),
|
||||
})
|
||||
}
|
||||
// In-game saves reuse the compiled Studio asset. They must keep the export
|
||||
// manifest when staging, saving again, and publishing the new scene data.
|
||||
for (const autoPublish of [false, false, true]) {
|
||||
const response = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/data`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('1')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
UnityAssetId: ASSET.toUpperCase(),
|
||||
SubRoomData: { Filename: 'in-game-save' },
|
||||
AutoPublish: autoPublish,
|
||||
}),
|
||||
})
|
||||
const result = (await response.json()) as {
|
||||
success: boolean
|
||||
value: { subRoomDataSave: { subRoomDataSaveId: number; unityAsset: string } }
|
||||
}
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.value.subRoomDataSave.unityAsset).toBe(winName)
|
||||
const detail = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${result.value.subRoomDataSave.subRoomDataSaveId}?unityAssetTarget=0`,
|
||||
{ headers: await bearer('1') }
|
||||
)
|
||||
expect(await detail.json()).toMatchObject({
|
||||
UnityAsset: winName,
|
||||
UnitySubAssets: [prefab, shared].map((UnityAssetId) => ({ UnityAssetId })),
|
||||
})
|
||||
}
|
||||
await env.DB.prepare('DELETE FROM unity_asset WHERE unity_asset_id IN (?1,?2)')
|
||||
.bind(prefab, shared)
|
||||
.run()
|
||||
})
|
||||
|
||||
it('loads baked bundles onto the room and serves the unity asset', async () => {
|
||||
await reset()
|
||||
|
||||
@@ -7185,3 +7420,63 @@ describe('Studio source revision permissions', () => {
|
||||
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' })
|
||||
})
|
||||
})
|
||||
|
||||
it('omits hydrated Badges when persisting a room settings edit', async () => {
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 987670,
|
||||
Name: 'BadgeSerialization',
|
||||
CreatorAccountId: 987670,
|
||||
SubRooms: [],
|
||||
})
|
||||
await env.DB.prepare(
|
||||
"UPDATE room SET data=json_set(data,'$.Badges',json(?)) WHERE room_id=987670"
|
||||
)
|
||||
.bind(JSON.stringify([{ Name: 'derived badge' }]))
|
||||
.run()
|
||||
const response = await SELF.fetch(`${ORIGIN}/rooms/987670/warning`, {
|
||||
method: 'PUT',
|
||||
headers: await bearer('987670'),
|
||||
body: new URLSearchParams({ warningMask: '1' }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const stored = await env.DB.prepare('SELECT data FROM room WHERE room_id=987670').first<{
|
||||
data: string
|
||||
}>()
|
||||
expect(JSON.parse(stored!.data)).not.toHaveProperty('Badges')
|
||||
})
|
||||
|
||||
// The badge lookup runs on every room page, served by a partial index over the badge rows
|
||||
// (api migrations/0035, mirrored in SCHEMA_DDL above). Only a PUBLISHED badge with a built save
|
||||
// hydrates; a draft stays on its creator's Studio shelf.
|
||||
it("GET /rooms/:id hydrates the room's published badges", async () => {
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 987671,
|
||||
Name: 'BadgeHydration',
|
||||
CreatorAccountId: 987671,
|
||||
SubRooms: [],
|
||||
})
|
||||
const badge = (id: string, roomId: number, accessibility: number, saves: unknown[]) =>
|
||||
JSON.stringify({
|
||||
CustomAvatarItemId: id,
|
||||
CreatorAccountId: 987671,
|
||||
Name: id,
|
||||
OutfitType: 1000,
|
||||
Accessibility: accessibility,
|
||||
CurrentSaves: saves,
|
||||
CustomBadgeMetadata: { RoomId: roomId },
|
||||
})
|
||||
const insert = env.DB.prepare('INSERT INTO custom_avatar_item (data) VALUES (?)')
|
||||
await env.DB.batch([
|
||||
insert.bind(badge('badge-published', 987671, 1, [{ BodyType: 0 }])),
|
||||
insert.bind(badge('badge-draft', 987671, 0, [{ BodyType: 0 }])),
|
||||
insert.bind(badge('badge-unbuilt', 987671, 1, [])),
|
||||
insert.bind(badge('badge-other-room', 987670, 1, [{ BodyType: 0 }])),
|
||||
])
|
||||
// Twice: the second read takes the path that remembers the table exists.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/987671`)
|
||||
expect(res.status).toBe(200)
|
||||
const room = (await res.json()) as { Badges: Array<{ CustomAvatarItemId: string }> }
|
||||
expect(room.Badges.map((b) => b.CustomAvatarItemId)).toEqual(['badge-published'])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -56,6 +56,8 @@ const UPLOAD_SUBFOLDER: Record<number, string> = {
|
||||
8: 'room',
|
||||
9: 'roomunityasset',
|
||||
10: 'unityasset',
|
||||
13: 'avatar',
|
||||
14: 'avatarunityasset',
|
||||
}
|
||||
|
||||
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
|
||||
@@ -380,6 +382,7 @@ const app = new Hono<App>()
|
||||
contentType = image.contentType
|
||||
}
|
||||
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
|
||||
customMetadata: { ownerId: String(id) },
|
||||
httpMetadata: { contentType },
|
||||
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
|
||||
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
|
||||
|
||||
@@ -21,6 +21,27 @@ beforeAll(async () => {
|
||||
// storage worker's validation accepts it.
|
||||
const TEST_SECRET = 'test-signing-key'
|
||||
|
||||
it('stores avatar source and baked uploads with immutable account ownership metadata', async () => {
|
||||
for (const [type, prefix] of [
|
||||
[13, 'avatar'],
|
||||
[14, 'avatarunityasset'],
|
||||
] as const) {
|
||||
const form = new FormData()
|
||||
form.set('fileType', String(type))
|
||||
form.set('file', new File(['avatar source'], 'source.bin'))
|
||||
const r = await SELF.fetch(ORIGIN + '/upload', {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'User-Agent': 'RRStudio' },
|
||||
body: form,
|
||||
})
|
||||
expect(r.status).toBe(200)
|
||||
const body = await r.json<{ Filename: string; Hash: string }>()
|
||||
expect(body.Hash).toBeTruthy()
|
||||
const object = await env.CDN_ASSETS.head(prefix + '/' + body.Filename)
|
||||
expect(object?.customMetadata?.ownerId).toBe('42')
|
||||
}
|
||||
})
|
||||
|
||||
function b64url(input: ArrayBuffer | string): string {
|
||||
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
||||
let binary = ''
|
||||
|
||||
@@ -83,12 +83,16 @@ build ID with a null completion time. Repeated requests for an in-progress save
|
||||
reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status;
|
||||
`PUT /cloud-builds/cancel` cancels a queued or running job.
|
||||
|
||||
Apply migration `0003_studio_cloud_build_job.sql`. Configure a private
|
||||
`CLOUD_BUILD_KEY` and run the outbound worker from `recflare/cloud-building` with
|
||||
the matching credential. Without a key, creation returns an explicit unavailable
|
||||
response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and Android), and
|
||||
`CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169. Match installed editor modules to
|
||||
the enabled targets.
|
||||
Apply migration `0003_studio_cloud_build_job.sql`. Create the private
|
||||
`CLOUD_BUILD_KEY` in the shared Secrets Store (the one `JWT_SECRET` lives in — see
|
||||
`.env.example`) and run the outbound worker from `recflare/cloud-building` with the
|
||||
same value as its `STUDIO_WORKER_KEY`. The binding must exist for the worker to
|
||||
deploy; a value of `off` disables cloud building, and creation then returns an
|
||||
explicit unavailable response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and
|
||||
Android), and `CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169 — the cloud-building
|
||||
worker claims only jobs for its own configured template version (171 by default),
|
||||
so set this to match or jobs queue and are never claimed. Match installed editor
|
||||
modules to the enabled targets.
|
||||
|
||||
The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish`
|
||||
routes lease jobs to that worker. Only validated UnityFS artifacts for the
|
||||
@@ -96,5 +100,20 @@ allocated asset IDs and requested platforms are accepted. SDK bundle version 5
|
||||
is retained. Completion links the submitted save; auto-publish does not replace
|
||||
a newer draft. Failed, cancelled, or expired jobs never become successful builds.
|
||||
Claims include a signed, short-lived SDK validation token with no account subject
|
||||
or roles. Deploy the paired API support: only bulk component-text validation
|
||||
accepts this credential. It cannot authenticate as an account or publish a room.
|
||||
or roles. Bulk component-text validation and job-scoped grant reads accept this
|
||||
credential. It cannot authenticate as an account or publish a room.
|
||||
|
||||
## Avatar Studio and consumable validation
|
||||
|
||||
Avatar authoring uses per-account Studio grants, source revisions, and the managed
|
||||
cloud queue. Apply `migrations/0004_avatar_studio.sql` to the shared database before
|
||||
enabling these routes. The API owns creator saves and explicit publication; the
|
||||
Studio service queues prefab builds and returns per-platform main/stripped bundles
|
||||
at version 5 and material bundles at version 6. Image badges publish directly.
|
||||
Changed body/configuration inputs receive distinct output IDs, and automatic
|
||||
publication cannot replace a newer revision. Deploy the paired cloud-building
|
||||
worker for prefab support; restricted validation credentials grant no account role.
|
||||
|
||||
The SDK's access override reads the target account's entitlement. Build-validation
|
||||
credentials can inspect only the current owners of their active job's room; peers
|
||||
can inspect grants only while they currently manage the same room.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id);
|
||||
CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL);
|
||||
@@ -1,11 +1,18 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import {
|
||||
AVATAR_STUDIO_SLOTS,
|
||||
avatarItemAccessBit,
|
||||
avatarStudioFlags,
|
||||
avatarStudioItem,
|
||||
avatarStudioSave,
|
||||
canManageRoom,
|
||||
canManageRoomById,
|
||||
getRoomById,
|
||||
getSubRoom,
|
||||
getSubRoomSaveById,
|
||||
publicStudioBundleFilename,
|
||||
publishAvatarSave,
|
||||
studioBundleR2Key,
|
||||
} from '@repo/domain'
|
||||
import {
|
||||
@@ -16,13 +23,28 @@ import {
|
||||
|
||||
import { parseMultipart } from './multipart'
|
||||
|
||||
import type { App } from './context'
|
||||
import type { Context } from 'hono'
|
||||
import type { App, Env } from './context'
|
||||
|
||||
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
|
||||
cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
|
||||
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
|
||||
)`
|
||||
interface Job {
|
||||
kind?: 'avatar'
|
||||
avatarItemId?: string
|
||||
avatar?: {
|
||||
itemId: string
|
||||
outfitType: number
|
||||
bodyType: number
|
||||
configuration: string
|
||||
name: string
|
||||
createdAt: string
|
||||
price: number
|
||||
creatorAccountId: number
|
||||
canCreateRoomieEquipment: boolean
|
||||
permittedItemTypes: number[]
|
||||
}
|
||||
buildId: string
|
||||
roomId: number
|
||||
subRoomId: number
|
||||
@@ -34,7 +56,8 @@ interface Job {
|
||||
targets: number[]
|
||||
manifest: {
|
||||
Version: number
|
||||
MainSceneProjectRelativePath: string
|
||||
MainSceneProjectRelativePath?: string
|
||||
MainPrefabProjectRelativePath?: string
|
||||
ExportPartitionVersion: number
|
||||
Files: Array<{ n: string; p: string; g?: string }>
|
||||
Exports?: Array<{ e: string; d?: string[] }>
|
||||
@@ -51,6 +74,7 @@ const ok = (value: unknown) => ({ success: true, error: null, value })
|
||||
const fail = (error: string) => ({ success: false, error, value: null })
|
||||
const ID = /^[1-9]\d*$/
|
||||
const guid = () => crypto.randomUUID()
|
||||
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
|
||||
interface Artifact {
|
||||
field: string
|
||||
unityAssetId: string
|
||||
@@ -84,7 +108,6 @@ interface CloudBuildRecord {
|
||||
unity_asset_id: string
|
||||
created_by_account_id: number
|
||||
}
|
||||
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
|
||||
function wire(row: CloudBuildRecord) {
|
||||
return {
|
||||
cloudBuildId: row.cloud_build_id,
|
||||
@@ -99,7 +122,21 @@ function wire(row: CloudBuildRecord) {
|
||||
: null,
|
||||
}
|
||||
}
|
||||
async function privateAuth(request: Request, key: string | undefined) {
|
||||
/**
|
||||
* The pull worker's credential, resolved per request from the Secrets Store. Returns ''
|
||||
* when cloud building is off: the secret holds the literal `off` (the binding has to
|
||||
* resolve for the worker to deploy, so that is how an operator opts out) or can't be read.
|
||||
* Every gate on "is cloud building configured" goes through here.
|
||||
*/
|
||||
export async function cloudBuildKey(env: Env): Promise<string> {
|
||||
try {
|
||||
const key = (await env.CLOUD_BUILD_KEY.get()) ?? ''
|
||||
return key === 'off' ? '' : key
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
async function privateAuth(request: Request, key: string) {
|
||||
if (!key) return false
|
||||
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
|
||||
const got = await crypto.subtle.digest(
|
||||
@@ -116,7 +153,48 @@ async function pendingBuild(db: D1Database, roomId: number, subRoomId: number, s
|
||||
.bind(roomId, subRoomId, saveId)
|
||||
.first<CloudBuildRecord>()
|
||||
}
|
||||
async function pendingAvatarBuild(db: D1Database, itemId: string, saveId: number) {
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) JOIN studio_cloud_build_job j USING(cloud_build_id) WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing') LIMIT 1"
|
||||
)
|
||||
.bind(itemId, saveId)
|
||||
.first<CloudBuildRecord>()
|
||||
}
|
||||
const app = new Hono<App>()
|
||||
.get('/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}', async (c) => {
|
||||
const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (id === null) return c.body(null, 401)
|
||||
const item = await avatarStudioItem(c.env.DB, c.req.param('itemId'))
|
||||
if (!item || item.CreatorAccountId !== id) return c.body(null, 403)
|
||||
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
|
||||
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
|
||||
const { results } = await c.env.DB.prepare(
|
||||
'SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) WHERE a.item_id=? ORDER BY b.started_at DESC'
|
||||
)
|
||||
.bind(item.CustomAvatarItemId)
|
||||
.all<CloudBuildRecord>()
|
||||
return c.json({
|
||||
results: results.slice(skip, skip + take).map(wire),
|
||||
totalResults: results.length,
|
||||
})
|
||||
})
|
||||
.post(
|
||||
'/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}/save/:saveId{[1-9][0-9]*}',
|
||||
(c) => enqueueAvatar(c, c.req.param('itemId'), Number(c.req.param('saveId')))
|
||||
)
|
||||
.post('/cloud-builds/custom-avatar-item/create-for-new-build-version', async (c) => {
|
||||
const form = await c.req.parseBody()
|
||||
const itemId = String(form.customAvatarItemId ?? ''),
|
||||
assetId = String(form.unityAssetId ?? '')
|
||||
const row = await c.env.DB.prepare(
|
||||
'SELECT id FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
|
||||
)
|
||||
.bind(itemId, assetId)
|
||||
.first<{ id: number }>()
|
||||
if (!row) return c.notFound()
|
||||
return enqueueAvatar(c, itemId, row.id, form)
|
||||
})
|
||||
.post('/cloud-builds/create', async (c) => {
|
||||
const secret = await c.env.JWT_SECRET.get()
|
||||
const accountId = await validateAndGetAccountId(c.req.raw, secret)
|
||||
@@ -124,7 +202,7 @@ const app = new Hono<App>()
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
if (!roles?.includes('betastudio'))
|
||||
return c.json(fail('Studio upload authorization is required.'), 403)
|
||||
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503)
|
||||
if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
|
||||
const form = await c.req.parseBody()
|
||||
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
|
||||
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id))))
|
||||
@@ -272,11 +350,16 @@ const app = new Hono<App>()
|
||||
const form = await c.req.parseBody()
|
||||
const id = textField(form.cloudBuildId)
|
||||
const row = await c.env.DB.prepare(
|
||||
'SELECT room_id FROM studio_cloud_build WHERE cloud_build_id = ?'
|
||||
'SELECT room_id,created_by_account_id FROM studio_cloud_build WHERE cloud_build_id = ?'
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ room_id: number }>()
|
||||
if (!row || !(await canManageRoomById(c.env.DB, row.room_id, accountId)))
|
||||
.first<{ room_id: number; created_by_account_id: number }>()
|
||||
if (
|
||||
!row ||
|
||||
!(row.room_id === 0
|
||||
? row.created_by_account_id === accountId
|
||||
: await canManageRoomById(c.env.DB, row.room_id, accountId))
|
||||
)
|
||||
return c.json(fail('No accessible build.'), 404)
|
||||
await c.env.DB.batch([
|
||||
c.env.DB.prepare(
|
||||
@@ -289,7 +372,7 @@ const app = new Hono<App>()
|
||||
return c.json(ok(null))
|
||||
})
|
||||
.use('/internal/cloud-builds/*', async (c, next) => {
|
||||
if (!(await privateAuth(c.req.raw, c.env.CLOUD_BUILD_KEY))) return c.body(null, 401)
|
||||
if (!(await privateAuth(c.req.raw, await cloudBuildKey(c.env)))) return c.body(null, 401)
|
||||
return next()
|
||||
})
|
||||
.post('/internal/cloud-builds/claim', async (c) => {
|
||||
@@ -325,17 +408,13 @@ const app = new Hono<App>()
|
||||
})
|
||||
.post('/internal/cloud-builds/:id/heartbeat', async (c) => {
|
||||
const { leaseToken } = await c.req.json<{ leaseToken: string }>()
|
||||
const leaseUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString()
|
||||
const result = await c.env.DB.prepare(
|
||||
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
|
||||
)
|
||||
.bind(
|
||||
new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
||||
c.req.param('id'),
|
||||
leaseToken,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.bind(leaseUntil, c.req.param('id'), leaseToken, new Date().toISOString())
|
||||
.run()
|
||||
return c.json({ active: result.meta.changes > 0 })
|
||||
return c.json(result.meta.changes > 0 ? { active: true, leaseUntil } : { active: false })
|
||||
})
|
||||
.post('/internal/cloud-builds/:id/finish', async (c) => {
|
||||
const form = await parseMultipart(c.req.raw)
|
||||
@@ -359,6 +438,26 @@ const app = new Hono<App>()
|
||||
])
|
||||
return c.json(ok(null))
|
||||
}
|
||||
if (job.kind === 'avatar') {
|
||||
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
|
||||
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
|
||||
if (
|
||||
!item ||
|
||||
!save ||
|
||||
item.CreatorAccountId !== job.accountId ||
|
||||
!((await avatarStudioFlags(c.env.DB, job.accountId)) & avatarItemAccessBit(item.OutfitType))
|
||||
) {
|
||||
await c.env.DB.batch([
|
||||
c.env.DB.prepare(
|
||||
"UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='building'"
|
||||
).bind(id, lease.lease_token),
|
||||
c.env.DB.prepare(
|
||||
"UPDATE studio_cloud_build SET completed_at=?,error='Avatar source or authorization was removed.' WHERE cloud_build_id=? AND completed_at=''"
|
||||
).bind(new Date().toISOString(), id),
|
||||
])
|
||||
return c.json(fail('Avatar source or authorization was removed.'), 409)
|
||||
}
|
||||
}
|
||||
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
|
||||
let entries: unknown
|
||||
try {
|
||||
@@ -368,7 +467,10 @@ const app = new Hono<App>()
|
||||
}
|
||||
if (!Array.isArray(entries) || !entries.every(isArtifact))
|
||||
return c.json(fail('Invalid build artifact metadata.'), 400)
|
||||
if (!entries.length || entries.length > assets.size * job.targets.length * 2)
|
||||
if (
|
||||
!entries.length ||
|
||||
entries.length > assets.size * job.targets.length * (job.kind === 'avatar' ? 3 : 2)
|
||||
)
|
||||
return c.json(fail('Missing build artifacts.'), 400)
|
||||
const seen = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
@@ -377,8 +479,10 @@ const app = new Hono<App>()
|
||||
if (
|
||||
!assets.has(entry.unityAssetId) ||
|
||||
!job.targets.includes(entry.target) ||
|
||||
!['main', 'stripped'].includes(entry.kind) ||
|
||||
entry.version !== 5 ||
|
||||
!(job.kind === 'avatar' ? ['main', 'stripped', 'material'] : ['main', 'stripped']).includes(
|
||||
entry.kind
|
||||
) ||
|
||||
entry.version !== (job.kind === 'avatar' && entry.kind === 'material' ? 6 : 5) ||
|
||||
!(file instanceof File) ||
|
||||
file.size === 0 ||
|
||||
seen.has(key)
|
||||
@@ -390,6 +494,11 @@ const app = new Hono<App>()
|
||||
for (const target of job.targets)
|
||||
if (!seen.has(`${asset}:${target}:main`))
|
||||
return c.json(fail('A required asset bundle is missing.'), 400)
|
||||
if (
|
||||
job.kind === 'avatar' &&
|
||||
job.targets.some((target) => !seen.has(`${job.unityAssetId}:${target}:material`))
|
||||
)
|
||||
return c.json(fail('An avatar material bundle is missing.'), 400)
|
||||
const writes: D1PreparedStatement[] = []
|
||||
for (const entry of entries) {
|
||||
const file = form[entry.field] as File
|
||||
@@ -416,8 +525,11 @@ const app = new Hono<App>()
|
||||
entry.target === 2 ? 'android' : 'windows',
|
||||
entry.kind
|
||||
)
|
||||
const filename = baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
|
||||
const key = studioBundleR2Key(filename)
|
||||
const filename =
|
||||
job.kind === 'avatar'
|
||||
? `${entry.target === 2 ? 'quest/' : ''}studio-avatar/${job.avatarItemId}/${entry.unityAssetId}/${lease.lease_token}.${entry.kind}.assetbundle`
|
||||
: baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
|
||||
const key = job.kind === 'avatar' ? `avatar/${filename}` : studioBundleR2Key(filename)
|
||||
await c.env.CDN_ASSETS.put(key, bytes)
|
||||
keys.push(key)
|
||||
writes.push(
|
||||
@@ -439,48 +551,52 @@ const app = new Hono<App>()
|
||||
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
|
||||
keys.push(serverDataKey)
|
||||
// The exact submitted save is linked; a newer draft must never be replaced.
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
||||
).bind(
|
||||
job.unityAssetId,
|
||||
JSON.stringify({ Exports: job.exports }),
|
||||
job.saveId,
|
||||
job.subRoomId,
|
||||
id,
|
||||
lease.lease_token
|
||||
)
|
||||
)
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
|
||||
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
|
||||
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
||||
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
|
||||
)
|
||||
if (job.autoPublish) {
|
||||
// Test and update the staging slot in the same statement. A draft saved
|
||||
// during compilation must not be replaced by this build's older save.
|
||||
if (job.kind !== 'avatar')
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
`UPDATE subroom SET current_save_id=?,
|
||||
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
|
||||
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
|
||||
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
|
||||
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
||||
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
||||
).bind(
|
||||
job.saveId,
|
||||
job.saveId,
|
||||
job.roomId,
|
||||
job.subRoomId,
|
||||
job.saveId,
|
||||
job.unityAssetId,
|
||||
JSON.stringify({ Exports: job.exports }),
|
||||
job.saveId,
|
||||
job.subRoomId,
|
||||
id,
|
||||
lease.lease_token
|
||||
)
|
||||
)
|
||||
if (job.kind !== 'avatar') {
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
|
||||
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
|
||||
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
||||
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
|
||||
)
|
||||
if (job.autoPublish) {
|
||||
// Test and update the staging slot in the same statement. A draft saved
|
||||
// during compilation must not be replaced by this build's older save.
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
`UPDATE subroom SET current_save_id=?,
|
||||
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
|
||||
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
|
||||
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
|
||||
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
||||
).bind(
|
||||
job.saveId,
|
||||
job.saveId,
|
||||
job.roomId,
|
||||
job.subRoomId,
|
||||
job.saveId,
|
||||
job.saveId,
|
||||
job.subRoomId,
|
||||
id,
|
||||
lease.lease_token
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
"UPDATE studio_cloud_build SET completed_at=?,error=NULL WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
||||
@@ -491,20 +607,42 @@ const app = new Hono<App>()
|
||||
"UPDATE studio_cloud_build_job SET status='completed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
|
||||
).bind(id, lease.lease_token)
|
||||
)
|
||||
// Recheck the lease and target inside the publication transaction. R2
|
||||
// uploads can outlast a lease, and a room can be deleted while compiling.
|
||||
// Recheck authority, lease and target inside the final publication transaction.
|
||||
const publicationTime = new Date().toISOString()
|
||||
const roomRow =
|
||||
job.kind !== 'avatar'
|
||||
? await c.env.DB.prepare('SELECT data FROM room WHERE room_id=?')
|
||||
.bind(job.roomId)
|
||||
.first<{ data: string }>()
|
||||
: null
|
||||
const authorizedRoom =
|
||||
roomRow && canManageRoom(JSON.parse(roomRow.data), job.accountId) ? roomRow.data : null
|
||||
const targetExists =
|
||||
job.kind === 'avatar'
|
||||
? `SELECT 1 FROM avatar_studio_save s JOIN custom_avatar_item i ON i.custom_avatar_item_id=s.item_id
|
||||
JOIN avatar_studio_access a ON a.account_id=json_extract(i.data,'$.CreatorAccountId')
|
||||
WHERE s.id=? AND s.item_id=? AND s.unity_asset_id=? AND a.account_id=? AND (a.flags & ?)<>0`
|
||||
: `SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
|
||||
JOIN room ON room.room_id=r.room_id
|
||||
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=? AND room.data=?`
|
||||
const targetBindings =
|
||||
job.kind === 'avatar'
|
||||
? [
|
||||
job.saveId,
|
||||
job.avatarItemId!,
|
||||
job.unityAssetId,
|
||||
job.accountId,
|
||||
avatarItemAccessBit(job.avatar!.outfitType),
|
||||
]
|
||||
: [job.saveId, job.subRoomId, job.roomId, authorizedRoom]
|
||||
const results = await c.env.DB.batch([
|
||||
c.env.DB.prepare(
|
||||
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
|
||||
WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
|
||||
AND (lease_until<=? OR NOT EXISTS (
|
||||
SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
|
||||
JOIN room ON room.room_id=r.room_id
|
||||
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=?))`
|
||||
).bind(id, lease.lease_token, publicationTime, job.saveId, job.subRoomId, job.roomId),
|
||||
AND (lease_until<=? OR NOT EXISTS (${targetExists}))`
|
||||
).bind(id, lease.lease_token, publicationTime, ...targetBindings),
|
||||
c.env.DB.prepare(
|
||||
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target save removed.'
|
||||
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target authorization/save removed.'
|
||||
WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
|
||||
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
|
||||
).bind(publicationTime, id, id),
|
||||
@@ -512,7 +650,7 @@ const app = new Hono<App>()
|
||||
])
|
||||
if (!results[results.length - 1].meta.changes) {
|
||||
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
||||
return c.json(fail('Build lease expired or target save removed.'), 409)
|
||||
return c.json(fail('Build lease expired or target authorization/save removed.'), 409)
|
||||
}
|
||||
} catch (error) {
|
||||
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
||||
@@ -523,6 +661,156 @@ const app = new Hono<App>()
|
||||
.run()
|
||||
throw error
|
||||
}
|
||||
if (job.kind === 'avatar') {
|
||||
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
|
||||
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
|
||||
if (
|
||||
item &&
|
||||
save &&
|
||||
(job.autoPublish || (item.Accessibility === 0 && save.update_current === 1))
|
||||
) {
|
||||
await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id, true)
|
||||
}
|
||||
return c.json(ok(null))
|
||||
}
|
||||
|
||||
return c.json(ok(null))
|
||||
})
|
||||
export default app
|
||||
|
||||
async function enqueueAvatar(
|
||||
c: Context<App>,
|
||||
itemId: string,
|
||||
saveId: number,
|
||||
supplied?: Record<string, unknown>
|
||||
) {
|
||||
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
const item = await avatarStudioItem(c.env.DB, itemId),
|
||||
save = await avatarStudioSave(c.env.DB, itemId, saveId)
|
||||
if (!item || !save) return c.notFound()
|
||||
if (item.OutfitType === 1000)
|
||||
return c.json(fail('Badge images publish directly and do not require a Unity build.'), 400)
|
||||
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (
|
||||
item.CreatorAccountId !== accountId ||
|
||||
!roles?.includes('betastudio') ||
|
||||
!((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(item.OutfitType))
|
||||
)
|
||||
return c.body(null, 403)
|
||||
if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
|
||||
const pending = await pendingAvatarBuild(c.env.DB, itemId, saveId)
|
||||
if (pending) return c.json(ok(wire(pending)))
|
||||
const object = await c.env.CDN_ASSETS.get('avatarunityasset/' + save.source_filename)
|
||||
if (!object || object.size > 8 * 1024 * 1024)
|
||||
return c.json(fail('Missing or oversized avatar source manifest.'), 400)
|
||||
let manifest: Job['manifest']
|
||||
try {
|
||||
manifest = await object.json<Job['manifest']>()
|
||||
} catch {
|
||||
return c.json(fail('Invalid source manifest JSON.'), 400)
|
||||
}
|
||||
const main = manifest?.MainPrefabProjectRelativePath
|
||||
if (
|
||||
!manifest ||
|
||||
manifest.Version !== 3 ||
|
||||
typeof main !== 'string' ||
|
||||
!main.startsWith('Assets/') ||
|
||||
!main.endsWith('.prefab') ||
|
||||
!Array.isArray(manifest.Files) ||
|
||||
!manifest.Files.length ||
|
||||
manifest.Files.length > 100000 ||
|
||||
!manifest.Files.some((f) => f?.p === main) ||
|
||||
manifest.Files.some(
|
||||
(f) =>
|
||||
!f ||
|
||||
typeof f.p !== 'string' ||
|
||||
!/^(Assets|Packages)\//.test(f.p) ||
|
||||
f.p.includes('\\') ||
|
||||
f.p.includes(':') ||
|
||||
f.p
|
||||
.split('/')
|
||||
.some(
|
||||
(p) => p === '..' || p === '.' || p === '' || p.toLowerCase().endsWith('.bundle')
|
||||
) ||
|
||||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
|
||||
!/^[a-z0-9]{40}$/.test(f.n)
|
||||
) ||
|
||||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
|
||||
)
|
||||
return c.json(fail('Invalid avatar source file manifest.'), 400)
|
||||
const form = supplied ?? (await c.req.parseBody())
|
||||
if (form.pipelineVersion !== undefined && String(form.pipelineVersion) !== '2')
|
||||
return c.json(fail('Unsupported avatar build pipeline.'), 400)
|
||||
const targets = (c.env.CLOUD_BUILD_TARGETS ?? '0,2').split(',').map(Number)
|
||||
if (!targets.length || targets.some((t) => t !== 0 && t !== 2))
|
||||
return c.json(fail('Invalid build targets.'), 503)
|
||||
const access = await avatarStudioFlags(c.env.DB, accountId)
|
||||
const permittedItemTypes = AVATAR_STUDIO_SLOTS.filter(
|
||||
(slot) =>
|
||||
(access & avatarItemAccessBit(slot)) !== 0 &&
|
||||
(![104, 203].includes(slot) || roles.includes('developer'))
|
||||
)
|
||||
const job: Job = {
|
||||
kind: 'avatar',
|
||||
avatarItemId: itemId,
|
||||
avatar: {
|
||||
itemId,
|
||||
outfitType: item.OutfitType,
|
||||
bodyType: save.body_type,
|
||||
configuration: save.configuration,
|
||||
name: String(item.Name ?? ''),
|
||||
createdAt: save.created_at,
|
||||
price: Number(item.Price ?? 0),
|
||||
creatorAccountId: accountId,
|
||||
canCreateRoomieEquipment: roles.includes('developer'),
|
||||
permittedItemTypes,
|
||||
},
|
||||
buildId: numericId(),
|
||||
roomId: 0,
|
||||
subRoomId: 0,
|
||||
saveId,
|
||||
accountId,
|
||||
unityAssetId: save.unity_asset_id,
|
||||
autoPublish: textField(form.autoPublish).toLowerCase() === 'true',
|
||||
templateVersion: Number(c.env.CLOUD_BUILD_TEMPLATE_VERSION ?? 169),
|
||||
targets,
|
||||
manifest,
|
||||
room: null,
|
||||
exports: [],
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
// As for rooms, reserve the build and its queue/link rows in one transaction.
|
||||
const created = await c.env.DB.batch([
|
||||
c.env.DB.prepare(
|
||||
`INSERT INTO studio_cloud_build(cloud_build_id,room_id,sub_room_id,sub_room_data_save_id,unity_asset_id,created_by_account_id,started_at,completed_at,error)
|
||||
SELECT ?,0,0,?,?,?,?,'',NULL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM studio_avatar_build a JOIN studio_cloud_build_job j USING(cloud_build_id)
|
||||
WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing'))`
|
||||
).bind(job.buildId, saveId, job.unityAssetId, accountId, now, itemId, saveId),
|
||||
c.env.DB.prepare(
|
||||
'INSERT INTO studio_avatar_build(cloud_build_id,item_id,save_id) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
|
||||
).bind(job.buildId, itemId, saveId, job.buildId),
|
||||
c.env.DB.prepare(
|
||||
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,created_at) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
|
||||
).bind(job.buildId, JSON.stringify(job), now, job.buildId),
|
||||
])
|
||||
if (!created[0].meta.changes) {
|
||||
const existing = await pendingAvatarBuild(c.env.DB, itemId, saveId)
|
||||
return existing
|
||||
? c.json(ok(wire(existing)))
|
||||
: c.json(fail('Build state changed; retry creation.'), 409)
|
||||
}
|
||||
return c.json(
|
||||
ok(
|
||||
wire({
|
||||
cloud_build_id: job.buildId,
|
||||
started_at: now,
|
||||
completed_at: null,
|
||||
unity_asset_id: job.unityAssetId,
|
||||
created_by_account_id: accountId,
|
||||
error: null,
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,12 +17,23 @@ export type Env = SharedHonoEnv & {
|
||||
// Shared `recflare-cdn` bucket. Local Studio builds land under
|
||||
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
|
||||
CDN_ASSETS: R2Bucket
|
||||
/** Private pull-worker credential; leave unset to disable cloud build dispatch. */
|
||||
CLOUD_BUILD_KEY?: string
|
||||
/**
|
||||
* Private pull-worker credential, out of the shared Secrets Store (same store as
|
||||
* JWT_SECRET). Read it through `cloudBuildKey()` in cloud-builds.ts, never `.get()`
|
||||
* directly: the value `off` (or an unreadable secret) means cloud building is disabled.
|
||||
*/
|
||||
CLOUD_BUILD_KEY: SecretsStoreSecret
|
||||
CLOUD_BUILD_TARGETS?: string
|
||||
CLOUD_BUILD_TEMPLATE_VERSION?: string
|
||||
/** R2 key of an operator-published template catalog; unset disables downloads. */
|
||||
STUDIO_DISTRIBUTION_MANIFEST?: string
|
||||
/**
|
||||
* Discord role ids whose holders have Studio upload access without the account's
|
||||
* `hasStudio` flag (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`; the same var `auth`
|
||||
* stamps `betastudio` from). The access lookups here rebuild the role from the account
|
||||
* rather than a token, so they read it through the same `hasStudioAccess`.
|
||||
*/
|
||||
STUDIO_ROLLOUT?: string
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -109,22 +109,29 @@ export async function getUserBuildCount(
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000)
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS failed
|
||||
`SELECT SUM(CASE WHEN room_id!=0 THEN 1 ELSE 0 END) AS total,
|
||||
SUM(CASE WHEN room_id!=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS failed,
|
||||
SUM(CASE WHEN room_id=0 THEN 1 ELSE 0 END) AS avatar_total,
|
||||
SUM(CASE WHEN room_id=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS avatar_failed
|
||||
FROM studio_cloud_build
|
||||
WHERE created_by_account_id = ?1
|
||||
AND started_at >= ?2 AND started_at < ?3`
|
||||
)
|
||||
.bind(accountId, start.toISOString(), end.toISOString())
|
||||
.first<{ total: number | string; failed: number | string | null }>()
|
||||
.first<{
|
||||
total: number | string
|
||||
failed: number | string | null
|
||||
avatar_total: number | null
|
||||
avatar_failed: number | null
|
||||
}>()
|
||||
return {
|
||||
accountId,
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
subRoomTotalBuildCount: Number(row?.total ?? 0),
|
||||
subRoomFailedBuildCount: Number(row?.failed ?? 0),
|
||||
avatarItemTotalBuildCount: 0,
|
||||
avatarItemFailedBuildCount: 0,
|
||||
avatarItemTotalBuildCount: Number(row?.avatar_total ?? 0),
|
||||
avatarItemFailedBuildCount: Number(row?.avatar_failed ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
canManageRoomById,
|
||||
getAccount,
|
||||
getPlayerIdsInRoomSubRoom,
|
||||
getRoomCoOwnerIds,
|
||||
getSubRoom,
|
||||
} from '@repo/domain'
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
import { studioBuildValidationId, validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
import { hasStudioAccess } from '../../auth/src/platform-db'
|
||||
import cloudBuilds from './cloud-builds'
|
||||
import distribution from './distribution'
|
||||
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
|
||||
@@ -30,6 +32,13 @@ function positiveId(raw: string | undefined): number | null {
|
||||
return Number.isSafeInteger(id) ? id : null
|
||||
}
|
||||
|
||||
/** SQLite datetime() is UTC, but its space-separated output is not a .NET JSON DateTime. */
|
||||
function accessTimestamp(value: string | undefined, fallback: string): string {
|
||||
const normalized = value?.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/, '$1T$2Z')
|
||||
const time = normalized ? Date.parse(normalized) : NaN
|
||||
return Number.isFinite(time) ? new Date(time).toISOString() : fallback
|
||||
}
|
||||
|
||||
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
|
||||
const UPLOAD_FIRST =
|
||||
'Upload the room before building locally. The latest save is where this build is recorded.'
|
||||
@@ -168,6 +177,75 @@ const app = new Hono<App>()
|
||||
.get('/', async (c) => {
|
||||
return c.text('hello, world!')
|
||||
})
|
||||
.get('/access/override/:accountId{[1-9][0-9]*}', async (c) => {
|
||||
const secret = await c.env.JWT_SECRET.get()
|
||||
const caller = await validateAndGetAccountId(c.req.raw, secret)
|
||||
const id = Number(c.req.param('accountId'))
|
||||
if (!Number.isSafeInteger(id)) return c.body(null, 400)
|
||||
let roles = await validateAndGetRoles(c.req.raw, secret)
|
||||
if (caller === null) {
|
||||
const buildId = await studioBuildValidationId(c.req.raw, secret)
|
||||
if (!buildId) return c.body(null, 401)
|
||||
const job = await c.env.DB.prepare(
|
||||
"SELECT payload FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='building' AND lease_until>?"
|
||||
)
|
||||
.bind(buildId, new Date().toISOString())
|
||||
.first<{ payload: string }>()
|
||||
if (!job) return c.body(null, 403)
|
||||
const payload = JSON.parse(job.payload) as {
|
||||
roomId: number
|
||||
room?: { Roles?: Array<{ AccountId: number; Role: number }> }
|
||||
}
|
||||
if (
|
||||
!payload.roomId ||
|
||||
!payload.room?.Roles?.some((r) => r.AccountId === id && [30, 255].includes(r.Role))
|
||||
)
|
||||
return c.body(null, 403)
|
||||
if (!(await getRoomCoOwnerIds(c.env.DB, payload.roomId))?.includes(id))
|
||||
return c.body(null, 403)
|
||||
const account = await getAccount(c.env.DB, id)
|
||||
roles = (await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT)) ? ['betastudio'] : []
|
||||
}
|
||||
if (
|
||||
caller !== null &&
|
||||
caller !== id &&
|
||||
!roles?.some((r) => r === 'developer' || r === 'moderator')
|
||||
) {
|
||||
// The SDK validates a registry against every owner of its room. Allow
|
||||
// peers to inspect grants only when both currently manage the same room.
|
||||
const shared = await c.env.DB.prepare(
|
||||
`SELECT 1 FROM room r WHERE
|
||||
(r.creator_account_id=?1 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?1 AND json_extract(role.value,'$.Role') IN (30,255))) AND
|
||||
(r.creator_account_id=?2 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?2 AND json_extract(role.value,'$.Role') IN (30,255))) LIMIT 1`
|
||||
)
|
||||
.bind(caller, id)
|
||||
.first()
|
||||
if (!shared) return c.body(null, 403)
|
||||
}
|
||||
// Inspection authorization above belongs to the caller; this grant belongs to the target.
|
||||
if (caller !== null && caller !== id)
|
||||
roles = (await hasStudioAccess(
|
||||
c.env.DB,
|
||||
await getAccount(c.env.DB, id),
|
||||
c.env.STUDIO_ROLLOUT
|
||||
))
|
||||
? ['betastudio']
|
||||
: []
|
||||
const grant = await c.env.DB.prepare(
|
||||
'SELECT flags,created_at,modified_at FROM avatar_studio_access WHERE account_id=?'
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ flags: number; created_at: string; modified_at: string }>()
|
||||
const flags = roles?.includes('betastudio') ? (grant?.flags ?? 0) : 0
|
||||
const now = new Date().toISOString()
|
||||
return c.json({
|
||||
AccountId: id,
|
||||
StudioAccess: roles?.includes('betastudio') ? 1 : 2,
|
||||
AvatarStudioAccess: flags,
|
||||
CreatedAt: accessTimestamp(grant?.created_at, now),
|
||||
ModifiedAt: accessTimestamp(grant?.modified_at, now),
|
||||
})
|
||||
})
|
||||
// There is no circuit recommendation corpus on a fresh deployment. An empty
|
||||
// list is the SDK's supported no-recommendations state, not a failed lookup.
|
||||
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AVATAR_STUDIO_SCHEMA,
|
||||
avatarSaveDto,
|
||||
avatarStudioSave,
|
||||
publishAvatarSave,
|
||||
ROOM_SCHEMA_DDL,
|
||||
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
|
||||
UNITY_ASSET_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
import { generateToken } from '@repo/jwt'
|
||||
|
||||
import { SCHEMA_DDL, toQuestCustomAvatarItem } from '../../../../api/src/custom-avatar-items-db'
|
||||
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
const origin = 'https://example.com',
|
||||
itemId = '11111111-1111-1111-1111-111111111111',
|
||||
assetId = '22222222-2222-2222-2222-222222222222'
|
||||
const worker = { Authorization: 'Bearer private-avatar-worker-key' }
|
||||
async function auth(id = 42, roles = ['betastudio']) {
|
||||
return {
|
||||
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
|
||||
}
|
||||
}
|
||||
const manifest = {
|
||||
Version: 3,
|
||||
MainPrefabProjectRelativePath: 'Assets/Hat.prefab',
|
||||
Files: [{ n: 'a'.repeat(40), p: 'Assets/Hat.prefab' }],
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||
await adminSecretsStore(env.CLOUD_BUILD_KEY).create('private-avatar-worker-key')
|
||||
env.CLOUD_BUILD_TARGETS = '0,2'
|
||||
for (const sql of [
|
||||
...SCHEMA_DDL,
|
||||
...ROOM_SCHEMA_DDL,
|
||||
...AVATAR_STUDIO_SCHEMA,
|
||||
...STUDIO_CLOUD_BUILD_SCHEMA_DDL,
|
||||
...UNITY_ASSET_SCHEMA_DDL,
|
||||
CLOUD_JOB_SCHEMA,
|
||||
])
|
||||
await env.DB.prepare(sql).run()
|
||||
})
|
||||
beforeEach(async () => {
|
||||
for (const table of [
|
||||
'studio_cloud_build_job',
|
||||
'studio_cloud_build',
|
||||
'studio_avatar_build',
|
||||
'unity_asset',
|
||||
'avatar_studio_save',
|
||||
'avatar_studio_access',
|
||||
'custom_avatar_item',
|
||||
])
|
||||
await env.DB.prepare('DELETE FROM ' + table).run()
|
||||
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
|
||||
await env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
CustomAvatarItemId: itemId,
|
||||
CreatorAccountId: 42,
|
||||
OutfitType: 0,
|
||||
Accessibility: 0,
|
||||
Name: 'Hat',
|
||||
CurrentSaves: [],
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,update_current,created_at) VALUES (1,?,1,?,'source','hash','thumb','{}',1,'2026-10-18')"
|
||||
)
|
||||
.bind(itemId, assetId)
|
||||
.run()
|
||||
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
|
||||
})
|
||||
async function create(id = 42) {
|
||||
return SELF.fetch(origin + '/cloud-builds/custom-avatar-item/' + itemId + '/save/1', {
|
||||
method: 'POST',
|
||||
headers: await auth(id),
|
||||
body: new URLSearchParams({ pipelineVersion: '2', autoPublish: 'true' }),
|
||||
})
|
||||
}
|
||||
async function claim() {
|
||||
const r = await SELF.fetch(origin + '/internal/cloud-builds/claim', {
|
||||
method: 'POST',
|
||||
headers: worker,
|
||||
})
|
||||
return r.json<{ job: { buildId: string; kind: string; manifest: unknown }; leaseToken: string }>()
|
||||
}
|
||||
function artifacts(lease: { leaseToken: string }, materialVersion = 6) {
|
||||
const form = new FormData()
|
||||
form.set('leaseToken', lease.leaseToken)
|
||||
const entries = []
|
||||
for (const target of [0, 2])
|
||||
for (const kind of ['main', 'material', 'stripped']) {
|
||||
const field = `bundle${target}${kind}`
|
||||
form.set(
|
||||
field,
|
||||
new File(['UnityFS\0avatar fixture ' + target + kind], field + '.assetbundle')
|
||||
)
|
||||
entries.push({
|
||||
field,
|
||||
unityAssetId: assetId,
|
||||
target,
|
||||
kind,
|
||||
version: kind === 'material' ? materialVersion : 5,
|
||||
})
|
||||
}
|
||||
form.set('artifacts', JSON.stringify(entries))
|
||||
return form
|
||||
}
|
||||
it('authorizes per-account access and rejects builds from other owners', async () => {
|
||||
expect((await SELF.fetch(origin + '/access/override/42')).status).toBe(401)
|
||||
expect(
|
||||
(await SELF.fetch(origin + '/access/override/42', { headers: await auth(43) })).status
|
||||
).toBe(403)
|
||||
expect(
|
||||
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
|
||||
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 1 })
|
||||
expect(
|
||||
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth(42, []) })).json()
|
||||
).toMatchObject({ AvatarStudioAccess: 0 })
|
||||
expect((await create(43)).status).toBe(403)
|
||||
})
|
||||
it('serializes SQLite and ISO access timestamps as SDK-compatible UTC DateTimes', async () => {
|
||||
await env.DB.prepare(
|
||||
'UPDATE avatar_studio_access SET flags=15,created_at=?,modified_at=? WHERE account_id=42'
|
||||
)
|
||||
.bind('2026-10-19T00:45:00.372Z', '2026-10-19 16:44:56')
|
||||
.run()
|
||||
expect(
|
||||
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
|
||||
).toMatchObject({
|
||||
AvatarStudioAccess: 15,
|
||||
CreatedAt: '2026-10-19T00:45:00.372Z',
|
||||
ModifiedAt: '2026-10-19T16:44:56.000Z',
|
||||
})
|
||||
})
|
||||
it('builds and publishes prefab revisions with real SDK main/material version distinctions', async () => {
|
||||
expect((await create()).status).toBe(200)
|
||||
const lease = await claim()
|
||||
expect(lease.job.kind).toBe('avatar')
|
||||
expect(lease.job.manifest).toMatchObject(manifest)
|
||||
const finish = () => origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish'
|
||||
expect(
|
||||
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease, 5) }))
|
||||
.status
|
||||
).toBe(400)
|
||||
expect(
|
||||
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
|
||||
).toBe(200)
|
||||
const row = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
|
||||
const item = JSON.parse(row!.data)
|
||||
expect(item.CurrentSaves).toHaveLength(1)
|
||||
expect(item.CurrentSaves[0]).toMatchObject({
|
||||
CustomAvatarItemSaveId: 1,
|
||||
BodyType: 1,
|
||||
OutfitType: 0,
|
||||
})
|
||||
expect(item.CurrentSaves[0].UnityAsset).toMatch(/^studio-avatar\//)
|
||||
expect(item.CurrentSaves[0].UnityAsset2).toContain('.material.assetbundle')
|
||||
expect(item.CurrentSaves[0]).not.toHaveProperty('SourceUnityAssetFilename')
|
||||
const saved = await avatarStudioSave(env.DB, itemId, 1),
|
||||
dto = await avatarSaveDto(env.DB, saved!)
|
||||
expect(dto.BakedAssets).toHaveLength(4)
|
||||
expect(dto.BakedAssets.map((a) => a.Version).sort()).toEqual([5, 5, 6, 6])
|
||||
const quest = toQuestCustomAvatarItem(item).CurrentSaves[0]
|
||||
expect(quest.UnityAssetHash).toBe(
|
||||
dto.BakedAssets.find((a) => a.Target === 2 && a.Version === 5)?.Hash
|
||||
)
|
||||
expect(quest.UnityAssetHash).not.toBe(item.CurrentSaves[0].UnityAssetHash)
|
||||
expect((await env.CDN_ASSETS.get('avatar/' + dto.UnityAsset))?.size).toBeGreaterThan(8)
|
||||
expect(
|
||||
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
|
||||
).toBe(409)
|
||||
const count = await SELF.fetch(origin + '/cloud-builds/get-build-count-for-user', {
|
||||
headers: await auth(),
|
||||
})
|
||||
expect(await count.json()).toMatchObject({
|
||||
value: { subRoomTotalBuildCount: 0, avatarItemTotalBuildCount: 1 },
|
||||
})
|
||||
})
|
||||
it('rejects source code paths and cannot cancel another account’s build', async () => {
|
||||
await env.CDN_ASSETS.put(
|
||||
'avatarunityasset/source',
|
||||
JSON.stringify({ ...manifest, Files: [null] })
|
||||
)
|
||||
expect((await create()).status).toBe(400)
|
||||
await env.CDN_ASSETS.put(
|
||||
'avatarunityasset/source',
|
||||
JSON.stringify({
|
||||
...manifest,
|
||||
Files: [...manifest.Files, { n: 'a'.repeat(40), p: 'Assets/Code.cs' }],
|
||||
})
|
||||
)
|
||||
expect((await create()).status).toBe(400)
|
||||
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
|
||||
await create()
|
||||
const lease = await claim()
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||
method: 'PUT',
|
||||
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||
method: 'PUT',
|
||||
headers: await auth(43),
|
||||
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||
})
|
||||
).status
|
||||
).toBe(404)
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||
method: 'PUT',
|
||||
headers: await auth(),
|
||||
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
|
||||
method: 'POST',
|
||||
headers: worker,
|
||||
body: artifacts(lease),
|
||||
})
|
||||
).status
|
||||
).toBe(409)
|
||||
})
|
||||
it('does not publish a deleted item or an older revision after a newer save', async () => {
|
||||
await create()
|
||||
const lease = await claim()
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1'
|
||||
).run()
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
|
||||
method: 'POST',
|
||||
headers: worker,
|
||||
body: artifacts(lease),
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
const item = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
|
||||
expect(JSON.parse(item!.data).CurrentSaves).toEqual([])
|
||||
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
|
||||
await env.DB.prepare('DELETE FROM studio_cloud_build').run()
|
||||
await env.DB.prepare('DELETE FROM studio_avatar_build').run()
|
||||
await create()
|
||||
const deleted = await claim()
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/internal/cloud-builds/' + deleted.job.buildId + '/finish', {
|
||||
method: 'POST',
|
||||
headers: worker,
|
||||
body: artifacts(deleted),
|
||||
})
|
||||
).status
|
||||
).toBe(409)
|
||||
})
|
||||
|
||||
it('deduplicates concurrent avatar requests and allows a new build after failure', async () => {
|
||||
const responses = await Promise.all([create(), create(), create()])
|
||||
const ids = await Promise.all(
|
||||
responses.map(async (response) => {
|
||||
expect(response.status).toBe(200)
|
||||
return (await response.json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
|
||||
})
|
||||
)
|
||||
expect(new Set(ids).size).toBe(1)
|
||||
for (const table of ['studio_cloud_build', 'studio_avatar_build', 'studio_cloud_build_job'])
|
||||
expect(await env.DB.prepare('SELECT COUNT(*) AS n FROM ' + table).first()).toEqual({ n: 1 })
|
||||
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='failed'").run()
|
||||
expect(
|
||||
(await (await create()).json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
|
||||
).not.toBe(ids[0])
|
||||
})
|
||||
it.each([false, true])(
|
||||
'checks automatic avatar revision ordering in the write, including CAS retry=%s, preserving explicit restore',
|
||||
async (retry) => {
|
||||
await env.DB.prepare("INSERT INTO unity_asset VALUES (?,0,5,'main','old.bundle','hash')")
|
||||
.bind(assetId)
|
||||
.run()
|
||||
let intercepted = false
|
||||
const DB = new Proxy(env.DB, {
|
||||
get(target, key) {
|
||||
if (key === 'prepare')
|
||||
return (sql: string) => {
|
||||
const wrap = (statement: D1PreparedStatement): D1PreparedStatement =>
|
||||
new Proxy(statement, {
|
||||
get(stmt, method) {
|
||||
if (method === 'bind') return (...values: unknown[]) => wrap(stmt.bind(...values))
|
||||
if (method === 'run' && sql.startsWith('UPDATE custom_avatar_item'))
|
||||
return async () => {
|
||||
if (!intercepted) {
|
||||
intercepted = true
|
||||
await target
|
||||
.prepare(
|
||||
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT 2,item_id,body_type,'new-identity',source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1"
|
||||
)
|
||||
.run()
|
||||
if (retry)
|
||||
await target
|
||||
.prepare(
|
||||
"UPDATE custom_avatar_item SET data=json_set(data,'$.Name','Concurrent metadata')"
|
||||
)
|
||||
.run()
|
||||
}
|
||||
return stmt.run()
|
||||
}
|
||||
const value = Reflect.get(stmt, method)
|
||||
return typeof value === 'function' ? value.bind(stmt) : value
|
||||
},
|
||||
})
|
||||
return wrap(target.prepare(sql))
|
||||
}
|
||||
const value = Reflect.get(target, key)
|
||||
return typeof value === 'function' ? value.bind(target) : value
|
||||
},
|
||||
})
|
||||
expect(await publishAvatarSave(DB, itemId, 1, true)).toBe(false)
|
||||
expect(intercepted).toBe(true)
|
||||
let item = JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
|
||||
)
|
||||
expect(item.CurrentSaves).toEqual([])
|
||||
expect(await publishAvatarSave(env.DB, itemId, 1)).toBe(true)
|
||||
item = JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
|
||||
)
|
||||
expect(item.CurrentSaves[0].CustomAvatarItemSaveId).toBe(1)
|
||||
if (retry) expect(item.Name).toBe('Concurrent metadata')
|
||||
}
|
||||
)
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UNITY_ASSET_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||
import cloudBuilds, { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
@@ -49,7 +49,7 @@ async function auth(id = 42, studio = true) {
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||
env.CLOUD_BUILD_KEY = key
|
||||
await adminSecretsStore(env.CLOUD_BUILD_KEY).create(key)
|
||||
env.CLOUD_BUILD_TARGETS = '0'
|
||||
for (const sql of [
|
||||
...ROOM_SCHEMA_DDL,
|
||||
@@ -468,3 +468,138 @@ it('does not complete a build whose submitted save was deleted during compilatio
|
||||
.run()
|
||||
}
|
||||
})
|
||||
|
||||
function builtArtifacts(lease: Awaited<ReturnType<typeof claim>>) {
|
||||
const form = new FormData()
|
||||
form.set('leaseToken', lease.leaseToken)
|
||||
form.set('bundle', new File(['UnityFS\0regression'], 'bundle.assetbundle'))
|
||||
form.set(
|
||||
'artifacts',
|
||||
JSON.stringify([
|
||||
{
|
||||
field: 'bundle',
|
||||
unityAssetId: lease.job.unityAssetId,
|
||||
target: 0,
|
||||
kind: 'main',
|
||||
version: 5,
|
||||
},
|
||||
])
|
||||
)
|
||||
return form
|
||||
}
|
||||
function finishBuild(
|
||||
lease: Awaited<ReturnType<typeof claim>>,
|
||||
form = builtArtifacts(lease),
|
||||
bindings = env
|
||||
) {
|
||||
return cloudBuilds.request(
|
||||
origin + `/internal/cloud-builds/${lease.job.buildId}/finish`,
|
||||
{ method: 'POST', headers: worker, body: form },
|
||||
bindings
|
||||
)
|
||||
}
|
||||
it.each(['before finish', 'before transaction'])(
|
||||
'rejects removed room co-owner authority %s without linking or publishing',
|
||||
async (timing) => {
|
||||
const prior = await env.DB.prepare('SELECT data FROM room WHERE room_id=3000').first<{
|
||||
data: string
|
||||
}>()
|
||||
const originalSave = await env.DB.prepare(
|
||||
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||
).first<{ data: string }>()
|
||||
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
|
||||
.bind(JSON.stringify([{ AccountId: 43, Role: 30 }]))
|
||||
.run()
|
||||
await create(43, true, { autoPublish: 'true' })
|
||||
const lease = await claim()
|
||||
const revoke = () =>
|
||||
env.DB.prepare(
|
||||
"UPDATE room SET data=json_set(data,'$.Roles',json('[]')) WHERE room_id=3000"
|
||||
).run()
|
||||
if (timing === 'before finish') await revoke()
|
||||
const DB = new Proxy(env.DB, {
|
||||
get(target, key) {
|
||||
if (key === 'batch')
|
||||
return async (statements: D1PreparedStatement[]) => {
|
||||
await revoke()
|
||||
return target.batch(statements)
|
||||
}
|
||||
const value = Reflect.get(target, key)
|
||||
return typeof value === 'function' ? value.bind(target) : value
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
|
||||
expect(
|
||||
await env.DB.prepare(
|
||||
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||
).first()
|
||||
).toEqual(originalSave)
|
||||
expect(
|
||||
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
|
||||
.bind(lease.job.unityAssetId)
|
||||
.first()
|
||||
).toBeNull()
|
||||
expect(
|
||||
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
|
||||
.bind(lease.job.buildId)
|
||||
.first()
|
||||
).toEqual({ status: 'failed' })
|
||||
expect(
|
||||
await env.CDN_ASSETS.get(
|
||||
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
(await env.CDN_ASSETS.list({ prefix: `room/studio/3000/${lease.job.unityAssetId}` }))
|
||||
.objects
|
||||
).toEqual([])
|
||||
} finally {
|
||||
await env.DB.prepare('UPDATE room SET data=? WHERE room_id=3000').bind(prior!.data).run()
|
||||
}
|
||||
}
|
||||
)
|
||||
it.each(['expired lease', 'deleted save'])(
|
||||
'cleans uploaded artifacts when final publication finds %s',
|
||||
async (condition) => {
|
||||
await create()
|
||||
const lease = await claim()
|
||||
const save = await env.DB.prepare(
|
||||
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||
).first<{ data: string }>()
|
||||
const DB = new Proxy(env.DB, {
|
||||
get(target, key) {
|
||||
if (key === 'batch')
|
||||
return async (statements: D1PreparedStatement[]) => {
|
||||
if (condition === 'expired lease')
|
||||
await target
|
||||
.prepare("UPDATE studio_cloud_build_job SET lease_until='2000-01-01'")
|
||||
.run()
|
||||
else
|
||||
await target
|
||||
.prepare('DELETE FROM subroom_save WHERE sub_room_data_save_id=3002')
|
||||
.run()
|
||||
return target.batch(statements)
|
||||
}
|
||||
const value = Reflect.get(target, key)
|
||||
return typeof value === 'function' ? value.bind(target) : value
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
|
||||
expect(
|
||||
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
|
||||
.bind(lease.job.unityAssetId)
|
||||
.first()
|
||||
).toBeNull()
|
||||
expect(
|
||||
await env.CDN_ASSETS.get(
|
||||
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
|
||||
)
|
||||
).toBeNull()
|
||||
} finally {
|
||||
if (condition === 'deleted save')
|
||||
await env.DB.prepare('INSERT INTO subroom_save VALUES (3002,3001,?)').bind(save!.data).run()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||
|
||||
import { SCHEMA_DDL as ACCOUNT_SCHEMA, AVATAR_STUDIO_SCHEMA, ROOM_SCHEMA_DDL } from '@repo/domain'
|
||||
import { generateStudioBuildValidationToken, generateToken } from '@repo/jwt'
|
||||
|
||||
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
const base = 'https://example.com/access/override/'
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||
for (const sql of [
|
||||
...ACCOUNT_SCHEMA,
|
||||
...ROOM_SCHEMA_DDL,
|
||||
...AVATAR_STUDIO_SCHEMA,
|
||||
CLOUD_JOB_SCHEMA,
|
||||
])
|
||||
await env.DB.prepare(sql).run()
|
||||
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
|
||||
.bind(JSON.stringify({ accountId: 42, username: 'Fixture', hasStudio: true }))
|
||||
.run()
|
||||
await env.DB.prepare('INSERT INTO room(data) VALUES (?)')
|
||||
.bind(
|
||||
JSON.stringify({ RoomId: 3000, CreatorAccountId: 42, Roles: [{ AccountId: 42, Role: 255 }] })
|
||||
)
|
||||
.run()
|
||||
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,8,'now','now')").run()
|
||||
})
|
||||
beforeEach(async () => {
|
||||
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,status,lease_until,created_at) VALUES (?,?,?,?,?)'
|
||||
)
|
||||
.bind(
|
||||
'123',
|
||||
JSON.stringify({ roomId: 3000, room: { Roles: [{ AccountId: 42, Role: 255 }] } }),
|
||||
'building',
|
||||
new Date(Date.now() + 60000).toISOString(),
|
||||
'now'
|
||||
)
|
||||
.run()
|
||||
})
|
||||
async function request(id: number, build = '123') {
|
||||
return SELF.fetch(base + id, {
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + (await generateStudioBuildValidationToken(build, 'test-key')),
|
||||
},
|
||||
})
|
||||
}
|
||||
it('allows read-only consumable validation only for an active job’s room owners', async () => {
|
||||
expect(await (await request(42)).json()).toMatchObject({
|
||||
AccountId: 42,
|
||||
StudioAccess: 1,
|
||||
AvatarStudioAccess: 8,
|
||||
})
|
||||
expect((await request(43)).status).toBe(403)
|
||||
expect((await request(42, '124')).status).toBe(403)
|
||||
expect((await SELF.fetch(base + '42')).status).toBe(401)
|
||||
})
|
||||
it('rejects cancelled and expired jobs', async () => {
|
||||
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='cancelled'").run()
|
||||
expect((await request(42)).status).toBe(403)
|
||||
await env.DB.prepare(
|
||||
"UPDATE studio_cloud_build_job SET status='building',lease_until='2000-01-01'"
|
||||
).run()
|
||||
expect((await request(42)).status).toBe(403)
|
||||
})
|
||||
it('lets co-owners validate each other’s grants without exposing unrelated accounts', async () => {
|
||||
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
|
||||
.bind(
|
||||
JSON.stringify([
|
||||
{ AccountId: 42, Role: 255 },
|
||||
{ AccountId: 43, Role: 30 },
|
||||
])
|
||||
)
|
||||
.run()
|
||||
const headers = {
|
||||
Authorization:
|
||||
'Bearer ' + (await generateToken('43', 'fixture', 0, 'test-key', ['betastudio'])),
|
||||
}
|
||||
expect(await (await SELF.fetch(base + '42', { headers })).json()).toMatchObject({
|
||||
AvatarStudioAccess: 8,
|
||||
})
|
||||
expect((await SELF.fetch(base + '44', { headers })).status).toBe(403)
|
||||
})
|
||||
|
||||
it('uses target entitlement for staff inspections in both directions', async () => {
|
||||
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
|
||||
.bind(JSON.stringify({ accountId: 44, username: 'Disabled', hasStudio: false }))
|
||||
.run()
|
||||
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (44,15,'now','now')").run()
|
||||
const staff = async (roles: string[]) => ({
|
||||
Authorization: 'Bearer ' + (await generateToken('99', 'staff', 0, 'test-key', roles)),
|
||||
})
|
||||
expect(
|
||||
await (
|
||||
await SELF.fetch(base + '44', { headers: await staff(['developer', 'betastudio']) })
|
||||
).json()
|
||||
).toMatchObject({ AccountId: 44, StudioAccess: 2, AvatarStudioAccess: 0 })
|
||||
expect(
|
||||
await (await SELF.fetch(base + '42', { headers: await staff(['moderator']) })).json()
|
||||
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 8 })
|
||||
})
|
||||
@@ -37,6 +37,20 @@
|
||||
"binding": "JWT_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "JWT_SECRET"
|
||||
},
|
||||
// The credential the cloud-building pull worker (recflare/cloud-building, its
|
||||
// STUDIO_WORKER_KEY) presents as a bearer token on /internal/cloud-builds/*. A real
|
||||
// secret, so it lives in the same store rather than riding along as a `--var` from
|
||||
// .env, which would put it in plaintext on every worker. The binding must resolve
|
||||
// for this worker to deploy: create it with the value `off` to run without cloud
|
||||
// builds (creation then answers 503 and the private routes refuse everyone).
|
||||
//
|
||||
// wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
|
||||
// --scopes workers --remote
|
||||
{
|
||||
"binding": "CLOUD_BUILD_KEY",
|
||||
"store_id": "local",
|
||||
"secret_name": "CLOUD_BUILD_KEY"
|
||||
}
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
|
||||
@@ -154,6 +154,23 @@ box. The grant runs after Plus is granted and the link written, and a failure in
|
||||
logged rather than failing the claim. Unset, the var awards nothing and the claim is as
|
||||
before.
|
||||
|
||||
**Unlinking.** The same panel offers **Unlink Discord** whenever any Discord is on the
|
||||
account — a claim's link or the bare one "Sign in with Discord" leaves — and
|
||||
`POST /api/benefits/unlink` removes every `PlatformType.Discord` link the account holds.
|
||||
It is refused (403) until the account has an **email address**: an account made from
|
||||
Discord has none until the player sets one, the link may be its only way in, and an address
|
||||
is what the account can be recovered through (a password is not required — a passwordless
|
||||
account sets one in game). The row decides, not the page; `/api/benefits/status` serves the
|
||||
same flag (`hasEmail`, beside `discordLinked`) so the panel can say so first. Afterwards that
|
||||
Discord opens no account — signing in with it makes a new one — and the once-only guard lets
|
||||
it claim elsewhere. **The grants go with the link**: `hasPlus` and `hasStudio` are both
|
||||
cleared, whoever set them (an operator's grant too — nothing on the row says which wrote
|
||||
it), and the panel warns before the confirm; both reach the game on the next token. What the
|
||||
account does keep is that it claimed: removing a link with roles recorded stamps
|
||||
`hasClaimedDiscord` on the account, and the claim's "first link" check consults it, or
|
||||
link → unlink → link would pay the one-time supporter gift on every cycle. The route is not gated on the Discord
|
||||
configuration — the link is in the table whether or not the claim is still open.
|
||||
|
||||
The link also records the member's **roles** (`platform_account.role`, a JSON array of
|
||||
role ids). The claim writes them from the member record it read; on their own they'd be
|
||||
a snapshot from claim time, because the claim revokes the player's token the moment it's
|
||||
@@ -220,11 +237,40 @@ won't pick up newly created secrets until it restarts.
|
||||
which one is missing. `wrangler tail www` shows it.
|
||||
|
||||
In the [Discord developer portal](https://discord.com/developers/applications),
|
||||
add `https://<your domain>/claim` to the app's **Redirects**. It has to match byte
|
||||
for byte: `www` derives the redirect URI from the incoming request's own origin
|
||||
(never from the request body, which would turn the client secret into a redemption
|
||||
oracle for someone else's app), so add `http://localhost:5173/claim` too if you
|
||||
want the flow to work under `pnpm turbo dev`.
|
||||
add `https://<your domain>/claim` **and** `https://<your domain>/login/discord` to the
|
||||
app's **Redirects**. Each has to match byte for byte: `www` derives the redirect URI
|
||||
from the incoming request's own origin (never from the request body, which would turn
|
||||
the client secret into a redemption oracle for someone else's app), so add the
|
||||
`http://localhost:5173/…` pair too if you want the flows to work under `pnpm turbo dev`.
|
||||
|
||||
### Sign in with Discord
|
||||
|
||||
The same app also serves the login page's **Sign in with Discord** button, on the same
|
||||
switch (`/api/config` serves `discordLoginUrl` beside `discordAuthorizeUrl`, both null
|
||||
while the claim is closed). It asks Discord for the `identify` scope only and returns to
|
||||
`/login/discord`, where the SPA posts the code to `POST /api/login/discord`. `www`
|
||||
exchanges it, reads `GET /users/@me` for the Discord user id, revokes the token, and
|
||||
mints an **identity assertion** — a one-minute HS256 token signed with the shared
|
||||
`JWT_SECRET` naming platform `101` and that id — which it posts to `auth`'s
|
||||
`identity_assertion` grant over the service binding. `auth` resolves the id through
|
||||
`platform_account`:
|
||||
|
||||
- **One linked account** (a player who claimed benefits on it) — signed in; the ordinary
|
||||
token body comes back and the SPA stores it like a password sign-in's.
|
||||
- **No linked account** — `auth` **creates** one and links the Discord to it, under the
|
||||
ban-evasion check and the per-IP / per-identity signup caps (but not `PASSWORD_SIGNUP`:
|
||||
that switch exists because a password account has no identity but its address). The
|
||||
body carries `created: true`, and the SPA stops to say so: the account has **no
|
||||
password**, and setting one on the account page is what lets it sign in to the game —
|
||||
on a sideloaded Quest without a Meta account, which is the case this is for. Its first
|
||||
benefits claim still pays the welcome gift: the claim keys "first" on roles being
|
||||
recorded on a Discord link, not on the link existing.
|
||||
- **Several linked accounts** — refused; the claim never produces this.
|
||||
|
||||
`www` never chooses the account id: the assertion names an identity, and which account
|
||||
that is (or that there is none) is `auth`'s decision from the same table `cached_login`
|
||||
reads. The Discord snowflake stays off the new account's blob and token, as it does
|
||||
after a claim.
|
||||
|
||||
### Discord role refresh (cron)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RecFlare — play like it's 2025</title>
|
||||
<title>RecFlare</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="RecFlare is a free, open source fan rebuild of the RecNet servers. Download for PC, join the Discord, and play."
|
||||
|
||||
@@ -40,6 +40,14 @@ const AUTH_MESSAGES: Record<string, string> = {
|
||||
'That platform cannot be verified — only Steam and Meta are supported.',
|
||||
'no linked account for this platform identity':
|
||||
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
||||
// The website's Discord sign-in found the Discord user on several accounts — a state the
|
||||
// benefits claim never produces, so this is for an operator to untangle, not the player.
|
||||
'platform identity is linked to more than one account':
|
||||
'That Discord account is linked to more than one RecFlare account. Sign in with your username and password instead.',
|
||||
// www minted the assertion a moment ago, so this is a key mismatch between www and auth
|
||||
// (or a clock a minute out), never something the player did.
|
||||
'invalid or expired identity assertion':
|
||||
'Sign-in is unavailable right now. This is a problem on our end — please try again later.',
|
||||
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
||||
// An account that shares a device or network with a BANNED one. Phrased for BOTH the
|
||||
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
|
||||
|
||||
@@ -16,6 +16,7 @@ import { downgradeRoom, needsDowngrade } from '../room-converter'
|
||||
// The session token, the worker hostnames and the `call` every request goes through —
|
||||
// see api.ts for why they're a module of their own rather than defined here.
|
||||
import {
|
||||
accountByUsername,
|
||||
accountIdForUsername,
|
||||
call,
|
||||
hasToken,
|
||||
@@ -30,11 +31,12 @@ import {
|
||||
import { DeviceLinkPage } from './DeviceLink'
|
||||
import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item'
|
||||
import { ModerationPage, PlayerBanStatus } from './Moderation'
|
||||
import { PumpleBossFight } from './PumpleBossFight'
|
||||
import { StatsPage } from './Stats'
|
||||
import { StudioAccessPage } from './StudioAccess'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Hosts } from './api'
|
||||
import type { Hosts, PublicAccount } from './api'
|
||||
|
||||
/**
|
||||
* Site config from `www`. `signupEnabled` is false when the operator has no Turnstile
|
||||
@@ -56,6 +58,12 @@ interface SiteConfig {
|
||||
* benefits are off. The `state` nonce is appended here — see `startDiscordAuth`.
|
||||
*/
|
||||
discordAuthorizeUrl: string | null
|
||||
/**
|
||||
* The consent URL for "Sign in with Discord" — same app, `identify` scope only, and its own
|
||||
* return leg (`/login/discord`). On the same switch as the claim, so null whenever
|
||||
* `benefitsEnabled` is false. See `DiscordLoginPage`.
|
||||
*/
|
||||
discordLoginUrl: string | null
|
||||
}
|
||||
|
||||
/** The private self DTO from `accounts` (`GET /account/me`). */
|
||||
@@ -66,6 +74,8 @@ interface SelfAccount {
|
||||
email: string | null
|
||||
/** A key on the `img` worker — `DefaultProfileImage.jpg` until the player sets one. */
|
||||
profileImage: string
|
||||
/** The profile banner's key on `img` — `""` until the player sets one. */
|
||||
bannerImage: string
|
||||
/**
|
||||
* Username changes left on the account — each change spends one, and an account
|
||||
* starts with one. Absent on an older self DTO, which reads as "unknown": the form
|
||||
@@ -356,6 +366,41 @@ async function setProfileImage(imageName: string): Promise<void> {
|
||||
if (res.success !== true) throw new Error('The accounts worker refused the image.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a banner picture through the same `api` route as the avatar, but typed
|
||||
* `SavedImageType.None` (0): the game has no banner type (its banner is one of the
|
||||
* player's camera photos), and anything but ShareCamera stays out of the photo feed and
|
||||
* the player's photo list, while ProfileThumbnail would also overwrite the avatar. Filed
|
||||
* under `none/` in the bucket. Returns the key `img` serves it by.
|
||||
*/
|
||||
async function uploadBannerImage(file: File): Promise<string> {
|
||||
const form = new FormData()
|
||||
form.set('imgMeta', JSON.stringify({ savedImageType: 0 }))
|
||||
form.set('image', file)
|
||||
const { ImageName } = await call<{ ImageName?: string }>(
|
||||
`${where().api}/api/images/v4/uploadsaved`,
|
||||
{ method: 'POST', multipart: form, authed: true }
|
||||
)
|
||||
if (!ImageName) throw new Error('The api worker accepted the picture but returned no name.')
|
||||
return ImageName
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the account's banner at an uploaded picture — `PUT /account/me/bannerimage` on
|
||||
* `accounts`, the call the game makes when a player picks a banner. Same `{ success }`
|
||||
* envelope as the avatar's PUT, and it pushes the same `AccountUpdate` frame, so a
|
||||
* running client redraws the banner too.
|
||||
*/
|
||||
async function setBannerImage(imageName: string): Promise<void> {
|
||||
const res = await call<{ success?: boolean }>(`${where().accounts}/account/me/bannerimage`, {
|
||||
method: 'PUT',
|
||||
authed: true,
|
||||
form: { imageName },
|
||||
refusal: 'The accounts worker refused the banner.',
|
||||
})
|
||||
if (res.success !== true) throw new Error('The accounts worker refused the banner.')
|
||||
}
|
||||
|
||||
/**
|
||||
* The blob's SHA-256, base64 — the encoding this API's hash fields use (an invention's
|
||||
* `BlobHash` comes back the same way). `rooms` only echoes it back on the save, but a
|
||||
@@ -552,6 +597,16 @@ interface BenefitsStatus {
|
||||
hasPlus: boolean
|
||||
/** Whether a Discord identity is already tied to it. Which one is deliberately not served. */
|
||||
linked: boolean
|
||||
/**
|
||||
* Whether ANY Discord is on the account — a claim's link or the bare one "Sign in with
|
||||
* Discord" leaves. What the unlink button keys on; `linked` is only the claimed case.
|
||||
*/
|
||||
discordLinked: boolean
|
||||
/**
|
||||
* The unlink's one precondition: the account must have an email address, or taking the
|
||||
* Discord off could leave nothing to recover it by. Served so the panel can say so first.
|
||||
*/
|
||||
hasEmail: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -582,6 +637,14 @@ interface ClaimResult {
|
||||
const claimBenefits = (code: string): Promise<ClaimResult> =>
|
||||
call<ClaimResult>('/api/benefits/claim', { json: { code }, authed: true })
|
||||
|
||||
/**
|
||||
* Take the Discord off the account. `www` refuses (403, with a sentence) unless the account
|
||||
* has a password and an email — the panel checks the same two flags first so the button
|
||||
* only shows when it can work. Answers the status as it now stands.
|
||||
*/
|
||||
const unlinkDiscord = (): Promise<BenefitsStatus> =>
|
||||
call<BenefitsStatus>('/api/benefits/unlink', { json: {}, authed: true })
|
||||
|
||||
/**
|
||||
* The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage.
|
||||
*
|
||||
@@ -592,21 +655,52 @@ const claimBenefits = (code: string): Promise<ClaimResult> =>
|
||||
*/
|
||||
const OAUTH_STATE_KEY = 'rf_discord_state'
|
||||
|
||||
/**
|
||||
* Where to land after a Discord SIGN-IN — the `?next=` the login page was opened with.
|
||||
* Stashed beside the nonce because the round trip through Discord loses the query string,
|
||||
* and read back by `DiscordLoginPage` exactly once.
|
||||
*/
|
||||
const OAUTH_NEXT_KEY = 'rf_discord_next'
|
||||
|
||||
/**
|
||||
* Send the browser to Discord's consent screen.
|
||||
*
|
||||
* A real navigation, not a client-side route — Discord is another origin. The `state` is
|
||||
* minted here and stashed for the return leg; `www` built everything else about the URL
|
||||
* (see `/api/config`), so this only ever appends the one parameter it owns.
|
||||
* (see `/api/config`), so this only ever appends the one parameter it owns. `next` is the
|
||||
* sign-in's post-login destination; the claim passes none.
|
||||
*/
|
||||
function startDiscordAuth(authorizeUrl: string) {
|
||||
function startDiscordAuth(authorizeUrl: string, next?: string) {
|
||||
const state = crypto.randomUUID()
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, state)
|
||||
if (next) sessionStorage.setItem(OAUTH_NEXT_KEY, next)
|
||||
else sessionStorage.removeItem(OAUTH_NEXT_KEY)
|
||||
const url = new URL(authorizeUrl)
|
||||
url.searchParams.set('state', state)
|
||||
window.location.assign(url.toString())
|
||||
}
|
||||
|
||||
/** What `/api/login/discord` answers: auth's token body, plus who signed in and whether it made them. */
|
||||
interface DiscordSignIn {
|
||||
access_token?: string
|
||||
/** True when no account was linked to this Discord and `auth` created one just now. */
|
||||
created?: boolean
|
||||
/** The Discord username, for the confirmation line only. */
|
||||
discordUsername?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem the sign-in's authorization code. Goes through `www`, like signup and the claim,
|
||||
* because the exchange needs the Discord client secret; `www` then has `auth` issue the
|
||||
* session and relays the token body, which is stored exactly as a password sign-in's is.
|
||||
*/
|
||||
async function signInWithDiscord(code: string): Promise<DiscordSignIn> {
|
||||
const data = await call<DiscordSignIn>('/api/login/discord', { json: { code } })
|
||||
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login'))
|
||||
setToken(data.access_token)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
|
||||
* — so a session without the role is rejected there (403) even though the UI shows no
|
||||
@@ -739,6 +833,7 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
|
||||
// and tokens last a day and are never refreshed. Without this line the claim looks like
|
||||
// it silently did nothing, which is the single most likely support question here.
|
||||
const [relogin, setRelogin] = useState(false)
|
||||
const [unlinking, setUnlinking] = useState(false)
|
||||
// StrictMode runs effects twice in dev, and a Discord code is single-use: the second
|
||||
// run would redeem a spent code and report a failure over a claim that just worked.
|
||||
const redeemed = useRef(false)
|
||||
@@ -779,7 +874,14 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
|
||||
setPending(true)
|
||||
claimBenefits(code)
|
||||
.then((result) => {
|
||||
setStatus({ hasPlus: result.hasPlus, linked: true })
|
||||
setStatus((prev) => ({
|
||||
hasPlus: result.hasPlus,
|
||||
linked: true,
|
||||
discordLinked: true,
|
||||
// The claim says nothing about this; keep what the status read said, and assume
|
||||
// none until it answers.
|
||||
hasEmail: prev?.hasEmail ?? false,
|
||||
}))
|
||||
const who = result.discordUsername ? ` as ${result.discordUsername}` : ''
|
||||
// Linked either way; whether Plus came with it depends on the roles. Without a
|
||||
// qualifying role the roles are still saved for the game, and that is said
|
||||
@@ -881,6 +983,60 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
|
||||
Verify with Discord
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status?.discordLinked && !pending && (
|
||||
// Taking the Discord off again. Offered for ANY link — a claim's, or the bare one a
|
||||
// Discord sign-in made — but only once the account can be recovered without it: an
|
||||
// account made from Discord has no email until the player sets one, and `www`
|
||||
// refuses the unlink until there is one. The panel says so rather than showing a
|
||||
// button that would fail.
|
||||
<div className="unlink">
|
||||
<h3>Unlink Discord</h3>
|
||||
{status.hasEmail ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
Removes your Discord from this account. Rec Room Plus and Rec Room Studio access
|
||||
granted to this account are removed with it, your roles stop being read, and signing
|
||||
in with that Discord afterwards will make a new account rather than open this one.
|
||||
You can link again at any time.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="linkish danger"
|
||||
disabled={unlinking}
|
||||
onClick={() => {
|
||||
if (
|
||||
!confirm(
|
||||
'Unlink your Discord from this account? Rec Room Plus and Studio access on this account will be removed.'
|
||||
)
|
||||
)
|
||||
return
|
||||
setUnlinking(true)
|
||||
setError('')
|
||||
setDone('')
|
||||
unlinkDiscord()
|
||||
.then((next) => {
|
||||
setStatus(next)
|
||||
setRelogin(false)
|
||||
setDone('Your Discord has been unlinked from this account.')
|
||||
})
|
||||
.catch((err: unknown) =>
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
)
|
||||
.finally(() => setUnlinking(false))
|
||||
}}
|
||||
>
|
||||
{unlinking ? 'Unlinking…' : 'Unlink Discord'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="hint">
|
||||
Before you can unlink, add an email address to this account (the{' '}
|
||||
<strong>Email</strong> tab), so the account can be recovered without Discord.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -952,16 +1108,6 @@ function roomIdFromPath(path: string): number | null {
|
||||
return match ? Number.parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
/** Public account shape, from `GET /account/:id` or `/account/search`. */
|
||||
interface PublicAccount {
|
||||
accountId: number
|
||||
username: string
|
||||
displayName: string
|
||||
profileImage: string
|
||||
bannerImage: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's photo, from the `ImagesPlayer` projection. Public unless the list was the
|
||||
* viewer's own (see fetchPlayerPhotos), which is the one way a private one gets here —
|
||||
@@ -983,24 +1129,23 @@ interface PublicRoom {
|
||||
Stats: { VisitCount: number; CheerCount: number; FavoriteCount: number }
|
||||
}
|
||||
|
||||
/** Prefix-search accounts by username — backs the header search bar. */
|
||||
async function searchPlayers(query: string): Promise<PublicAccount[]> {
|
||||
/** Prefix-search accounts by username — backs the header search. */
|
||||
async function searchPlayers(query: string, take: number): Promise<PublicAccount[]> {
|
||||
if (query.trim() === '') return []
|
||||
return call<PublicAccount[]>(
|
||||
`${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}`
|
||||
`${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}&take=${take}`
|
||||
)
|
||||
}
|
||||
|
||||
/** The profile page's account: an exact lookup, not the search bar's prefix query. */
|
||||
const fetchPublicAccount = (username: string): Promise<PublicAccount | null> =>
|
||||
searchPlayers(username).then(
|
||||
(matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null
|
||||
)
|
||||
accountByUsername(username)
|
||||
|
||||
/**
|
||||
* The photos a player has taken. Sent WITH the session token when there is one: `api`
|
||||
* reads it and, only when the list is the caller's own, includes the photos they keep
|
||||
* private — so a signed-in player sees all of theirs, on `/account` and on their own
|
||||
* `/u/<name>`, while everyone else's profile is the public list either way. Nothing
|
||||
* `/user/<name>`, while everyone else's profile is the public list either way. Nothing
|
||||
* here decides who the viewer is; the worker does, from the token.
|
||||
*/
|
||||
const fetchPlayerPhotos = (accountId: number): Promise<PublicPhoto[]> =>
|
||||
@@ -1073,91 +1218,199 @@ async function setRoomExcluded(roomId: number, excluded: boolean): Promise<Owned
|
||||
return result.value
|
||||
}
|
||||
|
||||
/** The `/u/<username>` path, or null for any other path. */
|
||||
/** The `/user/<username>` path (or the old `/u/<username>`), or null for any other path. */
|
||||
function usernameFromPath(path: string): string | null {
|
||||
const match = /^\/u\/([^/]+)$/.exec(path)
|
||||
const match = /^\/(?:user|u)\/([^/]+)$/.exec(path)
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
// The nav's player search. `/u/:name` profiles stay reachable by URL either way; this only
|
||||
// decides whether the search bar that leads to them is shown.
|
||||
const SHOW_PLAYER_SEARCH: boolean = true
|
||||
// The nav's search. `/user/:name` and `/rooms/:id` stay reachable by URL either way; this
|
||||
// only decides whether the search button that leads to them is shown.
|
||||
const SHOW_SEARCH: boolean = true
|
||||
|
||||
/** How many of each kind the search panel lists. */
|
||||
const SEARCH_TAKE = 5
|
||||
|
||||
/**
|
||||
* The header search bar — a rec.net-style bubble dropdown of matching players as you
|
||||
* type. Debounced so it doesn't fire a search per keystroke; closes on selecting a
|
||||
* result, on Escape, or on clicking outside it.
|
||||
* Rooms whose name starts with `query`. The prefix endpoint, not the game's `/rooms/search`:
|
||||
* that one's substring match reads every public room per call, which a search fired on
|
||||
* every pause in typing can't afford; this one is an index seek billed per row returned.
|
||||
*/
|
||||
function PlayerSearch({ navigate }: { navigate: Navigate }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<PublicAccount[]>([])
|
||||
const searchRooms = (query: string, take: number): Promise<PublicRoom[]> =>
|
||||
query.trim() === ''
|
||||
? Promise.resolve([])
|
||||
: call<PublicRoom[]>(
|
||||
`${where().rooms}/rooms/search/name?query=${encodeURIComponent(query.trim())}&take=${take}`
|
||||
)
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
|
||||
<circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" strokeWidth="2.2" />
|
||||
<path
|
||||
d="M16 16l4.5 4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The header search: a magnifier button that opens a panel searching players and rooms
|
||||
* together as you type. A button rather than an always-on bar so it fits a phone's nav.
|
||||
* Debounced so it doesn't fire per keystroke; closes on picking a result, on Escape, or
|
||||
* on a tap outside it. Enter takes the first player, else the first room.
|
||||
*/
|
||||
function SiteSearch({ navigate }: { navigate: Navigate }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [players, setPlayers] = useState<PublicAccount[]>([])
|
||||
const [rooms, setRooms] = useState<PublicRoom[]>([])
|
||||
// The query the results on screen answer, so "nothing found" waits for the answer.
|
||||
const [answered, setAnswered] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
// Answers by normalized query, for the panel's lifetime on the page: backspacing to a
|
||||
// query already asked redraws it without another request (and another D1 read).
|
||||
const cache = useRef(new Map<string, [PublicAccount[], PublicRoom[]]>())
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim() === '') {
|
||||
setResults([])
|
||||
const key = query.trim().toLowerCase()
|
||||
const show = ([p, r]: [PublicAccount[], PublicRoom[]]) => {
|
||||
setPlayers(p.slice(0, SEARCH_TAKE))
|
||||
setRooms(r.slice(0, SEARCH_TAKE))
|
||||
setAnswered(query)
|
||||
}
|
||||
if (key === '') {
|
||||
setPlayers([])
|
||||
setRooms([])
|
||||
setAnswered('')
|
||||
return
|
||||
}
|
||||
const known = cache.current.get(key)
|
||||
if (known) {
|
||||
show(known)
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
const timeout = setTimeout(() => {
|
||||
void searchPlayers(query)
|
||||
.then((r) => setResults(r.slice(0, 6)))
|
||||
.catch(() => setResults([]))
|
||||
void Promise.all([
|
||||
searchPlayers(key, SEARCH_TAKE).catch(() => null),
|
||||
searchRooms(key, SEARCH_TAKE).catch(() => null),
|
||||
]).then(([p, r]) => {
|
||||
const found: [PublicAccount[], PublicRoom[]] = [p ?? [], r ?? []]
|
||||
// A failed half isn't cached, so the next time this is typed it asks again.
|
||||
if (p !== null && r !== null) cache.current.set(key, found)
|
||||
if (!stale) show(found)
|
||||
})
|
||||
}, 250)
|
||||
return () => clearTimeout(timeout)
|
||||
return () => {
|
||||
stale = true
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (!open) return
|
||||
inputRef.current?.focus()
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [])
|
||||
document.addEventListener('pointerdown', onPointerDown)
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown)
|
||||
}, [open])
|
||||
|
||||
const go = (username: string) => {
|
||||
const go = (to: string) => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
navigate(`/u/${encodeURIComponent(username)}`)
|
||||
navigate(to)
|
||||
}
|
||||
const playerPath = (r: PublicAccount) => `/user/${encodeURIComponent(r.username)}`
|
||||
const roomPath = (r: PublicRoom) => `/rooms/${r.RoomId}`
|
||||
const nothing = query.trim() !== '' && answered === query && players.length + rooms.length === 0
|
||||
|
||||
return (
|
||||
<div className="player-search" ref={containerRef}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search players…"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
if (e.key === 'Enter' && results[0]) go(results[0].username)
|
||||
}}
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<div className="player-search-bubble">
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.accountId}
|
||||
className="player-search-result"
|
||||
onClick={() => go(r.username)}
|
||||
>
|
||||
<img
|
||||
className="player-search-avatar"
|
||||
src={`${where().img}/${r.profileImage}?width=64`}
|
||||
alt=""
|
||||
/>
|
||||
<span>
|
||||
<span className="player-search-name">{r.displayName || r.username}</span>
|
||||
<span className="player-search-handle">@{r.username}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="site-search" ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`site-search-toggle${open ? ' active' : ''}`}
|
||||
aria-label="Search players and rooms"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="site-search-panel">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
placeholder="Search players and rooms…"
|
||||
aria-label="Search players and rooms"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
if (e.key === 'Enter') {
|
||||
if (players[0]) go(playerPath(players[0]))
|
||||
else if (rooms[0]) go(roomPath(rooms[0]))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{players.length > 0 && (
|
||||
<div className="site-search-group">
|
||||
<p className="site-search-heading">Players</p>
|
||||
{players.map((r) => (
|
||||
<button
|
||||
key={r.accountId}
|
||||
type="button"
|
||||
className="site-search-result"
|
||||
onClick={() => go(playerPath(r))}
|
||||
>
|
||||
<img
|
||||
className="site-search-avatar"
|
||||
src={`${where().img}/${r.profileImage}?width=64`}
|
||||
alt=""
|
||||
/>
|
||||
<span>
|
||||
<span className="site-search-name">{r.displayName || r.username}</span>
|
||||
<span className="site-search-sub">@{r.username}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rooms.length > 0 && (
|
||||
<div className="site-search-group">
|
||||
<p className="site-search-heading">Rooms</p>
|
||||
{rooms.map((r) => (
|
||||
<button
|
||||
key={r.RoomId}
|
||||
type="button"
|
||||
className="site-search-result"
|
||||
onClick={() => go(roomPath(r))}
|
||||
>
|
||||
<img
|
||||
className="site-search-thumb"
|
||||
src={`${where().img}/${r.ImageName}?width=128`}
|
||||
alt=""
|
||||
/>
|
||||
<span>
|
||||
<span className="site-search-name">^{r.Name}</span>
|
||||
<span className="site-search-sub">
|
||||
{r.Stats.VisitCount.toLocaleString()} visits
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{nothing && <p className="site-search-empty">No players or rooms match.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1284,7 +1537,7 @@ function ImagePage({ imageId, config }: { imageId: number; config: SiteConfig |
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's public profile page (`/u/<username>`) — banner, avatar, bio-adjacent
|
||||
* A player's public profile page (`/user/<username>`) — banner, avatar, bio-adjacent
|
||||
* info, their public rooms, and their public photos. Read-only: this is the
|
||||
* "rec.net-style profile" other players browse to, not the owner's own dashboard
|
||||
* (that stays on `/account`).
|
||||
@@ -1306,7 +1559,7 @@ function PlayerPage({
|
||||
useEffect(() => {
|
||||
// Waits for the config, not the session: this page is public, but every fetch on it
|
||||
// goes through `where()`, which has no hostnames until `/api/config` lands. On a cold
|
||||
// load of `/u/<name>` the effect would otherwise fire first and the page would be
|
||||
// load of `/user/<name>` the effect would otherwise fire first and the page would be
|
||||
// stuck on "Still starting up".
|
||||
if (config === undefined) return
|
||||
setAccount(undefined)
|
||||
@@ -1777,11 +2030,18 @@ function halloweenActive(): boolean {
|
||||
}
|
||||
|
||||
/** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */
|
||||
const PUMPKIN_SRC =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAZCAYAAADaILXQAAAFD0lEQVR42pWVX2xcxRXGf2fu3Lu7jh0bO3FsnMQ2idP8gZoIGkootZsIGrUlVEVCTVW1qVREUPtSiTckYgQPqEIg1FZU6gtP8EJVAaZpIqE4UtrQOCSIkj+YVgRsp3E2mzisnb27OzOnD+uuE2yJZJ7u6J755jvf+eYc4SbX7qHBbNyjpe5VS5k4Nw3VZj4evcrFiWn+9fZxuTbW3Cw43WcxBMBCNYtYRYwiujDU3ijmwK++rWv7G7BSpLdrGf8Zv8TY+54kCuQ/v4IsckZuBPg7Twzq2ntq1IwXjvzlCz4cPv6VZxcN2LttTfXh9phKLPxtMmVnW7BqAiPnU2aipTzUFnjzonMiFhFn9747vihO9P+PoW29Dz7Yt3LD/sdl3dRl/9NmLRu1GJPzpqfFk2SV6ZIhGwlrVoBP1MQO0xQrQ/1N76/qXL7u4KeXxxbV3Jjwuveu1USGH98XOH44EIopu+7PECJBXGBmVpFKhr71JfqAD0cTVMGY5G1MukAJC/DM9l597IEmVsRTAJz4u2XrG+cxQGnPHuyOP6L797AxOYh1KdGmnYAhHPsrrjrLlq05dNTCtm71Jd82dGTiUh3cKDV7ac2ZAUP60moEB6ffQvfvQeucQt3Bd+3eAZlb+OCV1+n/Zpl3hpsAP8/86QdW6VM/tEjlCmWf8sizGcaiHB9thYQZgg+sfngYMPxuR46VpkS/exMRRQWE5bWL1fD9LsuhcV/Yu707febdz3I29gapBMg0467maG5pI3JlitaSAZaEIk1tHbXqNyxBowTT4IjK0zW72Qoyl7GmJdTPu8SC4dg/Krw6GrhcbeUDrYIY7v+th4uTnHxuaT3NR157j0CVvoN3cvo303PyBPRLjz3MSWiDwMvvrQACEpRg5v6oQVs3sfHFgEpg7NzxufRhbPIE0ZNw+NG1xDbHvnxA/+mR+kVz4Af9p/G31vjq5NkGyppjszpOYFFVQBERfmkNfvVmHIqo4e5cBBiy4RIVyhiRmhnUAG6+cY2M4FzSMNyQc9XWlgoiwi0mkAhkgO4AagUTZVkeWZZloSnyeHzNTdfIMeWgHMmBGbtkX12o5w+ceWj5ra7Y1j4LQK8qwZfRNGVbDgKWINCei+kVT1+Yt6XB4VGEwNEZoZKaXS8cOPWjBV1RCKAWpMLPlrYg1at4CWzf/DkXTt7K16qeFhNxVCI6br/Avrwl+Dw/6Grl5zMrWNbXQFquwpGJhf1cTJYnt16ou8BVpxm8q7bvvCPPW50T/L6rQMcd+TmPQ5rt4RezLdz7aCNrtwi5ZlMYeGKgdF0v2NR/W8FFcatUS/ykI8POzhasVf787/N8rzNi31TKRyu7KSyJicXw30/ykFiCEZbd1k6aFvn6fRmSOEsZ5ZPDbl4Wn2QxCmQa0VCiWrlMKjE+00g5MlQyEaXY4GyEw+EaDZFP8FJG1WGTLGhMpeJAPS5yNeYb7t543ZA6fexUPaP139ioEULcnqGtu5mKKOu3eFDDn84eihiiXt7BXw/o4HdXYnIlxicK12uuaHotMMCZ0VMSVClPpRTOzlL4rMjYUaGnq5nHewb8Y68O1ImpKucu5BmfrNXkpgb0lUK+oz3X1IRa1MiCoSwnTay2dEmI+fio1jRXleFIwYupLgZqggwDJMXkKhtwEEhLji+uKEhdFUZGRtztu3YeMBI1Znzxxgb0tWtw92BWGyitu8dzZlQxCof+cHhRnP8B5aopRAfi2ZcAAAAASUVORK5CYII='
|
||||
const PUMPKIN_SCALE = 3
|
||||
const PUMPKIN_W = 23 * PUMPKIN_SCALE
|
||||
const PUMPKIN_H = 25 * PUMPKIN_SCALE
|
||||
const PUMPKIN_FRAMES = [
|
||||
new URL('./pumple-frames/frame-00.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-01.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-02.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-03.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-04.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-05.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-06.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-07.png', import.meta.url).href,
|
||||
]
|
||||
const PUMPKIN_W = 150
|
||||
const PUMPKIN_H = 150
|
||||
const PUMPKIN_LINES = ['boo!', 'hehe', 'hey!', 'spooky!', 'happy halloween!', 'ow!', '🎃']
|
||||
|
||||
const PUMPKIN_CSS = `
|
||||
@@ -1793,16 +2053,7 @@ const PUMPKIN_CSS = `
|
||||
image-rendering: pixelated;
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
.rf-pumpkin.is-walking img {
|
||||
animation: rf-pumpkin-step 0.24s steps(2, end) infinite;
|
||||
}
|
||||
@keyframes rf-pumpkin-step {
|
||||
0%, 100% { transform: translateY(0) scaleX(1); }
|
||||
50% { transform: translateY(-1px) scaleX(0.96); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rf-pumpkin.is-walking img { animation: none; }
|
||||
}
|
||||
/* The animated GIF provides its own walking frames. */
|
||||
.rf-pumpkin-bubble { position: absolute; left: 50%; transform: translateX(-50%); margin: 6px 0; padding: 3px 8px;
|
||||
border-radius: 10px; background: #fff3dc; color: #2a1608; font-size: 12px; font-weight: 600;
|
||||
white-space: nowrap; opacity: 0; transition: opacity 0.15s; }
|
||||
@@ -1816,13 +2067,15 @@ const PUMPKIN_CSS = `
|
||||
function LilPumpkin() {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const flipRef = useRef<HTMLDivElement>(null)
|
||||
const spriteRef = useRef<HTMLImageElement>(null)
|
||||
const bubbleRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
const flip = flipRef.current
|
||||
const sprite = spriteRef.current
|
||||
const bubble = bubbleRef.current
|
||||
if (!root || !flip || !bubble) return
|
||||
if (!root || !flip || !sprite || !bubble) return
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
root.style.display = 'none'
|
||||
return
|
||||
@@ -1841,12 +2094,73 @@ function LilPumpkin() {
|
||||
let jy = 0
|
||||
let vy = 0
|
||||
let walkTime = 0
|
||||
let walkDistance = 0
|
||||
let walkFrame = 0
|
||||
let mouseX = -1
|
||||
let mouseY = -1
|
||||
let mouseAt = -Infinity
|
||||
let speechTimer = 0
|
||||
let last = performance.now()
|
||||
let raf = 0
|
||||
const effectNodes: HTMLElement[] = []
|
||||
|
||||
const spawnBatSwarm = () => {
|
||||
const flash = document.createElement('div')
|
||||
Object.assign(flash.style, {
|
||||
position: 'fixed',
|
||||
inset: '0',
|
||||
background: 'rgba(210, 0, 28, 0.32)',
|
||||
opacity: '1',
|
||||
transition: 'opacity 480ms ease-out',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '100000',
|
||||
})
|
||||
document.body.appendChild(flash)
|
||||
effectNodes.push(flash)
|
||||
requestAnimationFrame(() => {
|
||||
flash.style.opacity = '0'
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
flash.remove()
|
||||
const i = effectNodes.indexOf(flash)
|
||||
if (i !== -1) effectNodes.splice(i, 1)
|
||||
}, 520)
|
||||
|
||||
const cx = x + PUMPKIN_W / 2
|
||||
const cy = y + jy + PUMPKIN_H / 2
|
||||
for (let i = 0; i < 22; i++) {
|
||||
const bat = document.createElement('span')
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const distance = 100 + Math.random() * 260
|
||||
const dx = Math.cos(angle) * distance
|
||||
const dy = Math.sin(angle) * distance
|
||||
Object.assign(bat.style, {
|
||||
position: 'fixed',
|
||||
left: `${cx}px`,
|
||||
top: `${cy}px`,
|
||||
fontSize: `${20 + Math.random() * 20}px`,
|
||||
lineHeight: '1',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '100001',
|
||||
opacity: '1',
|
||||
transform: 'translate(-50%, -50%) scale(0.4)',
|
||||
transition: 'transform 850ms ease-out, opacity 850ms ease-out',
|
||||
willChange: 'transform, opacity',
|
||||
})
|
||||
bat.textContent = '🦇'
|
||||
document.body.appendChild(bat)
|
||||
effectNodes.push(bat)
|
||||
requestAnimationFrame(() => {
|
||||
bat.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${Math.random() * 100 - 50}deg) scale(1.2)`
|
||||
bat.style.opacity = '0'
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
bat.remove()
|
||||
const i = effectNodes.indexOf(bat)
|
||||
if (i !== -1) effectNodes.splice(i, 1)
|
||||
}, 900)
|
||||
}
|
||||
}
|
||||
|
||||
root.style.transform = `translate(${x}px, ${y}px)`
|
||||
|
||||
@@ -1870,14 +2184,30 @@ function LilPumpkin() {
|
||||
const onLeave = () => {
|
||||
mouseAt = -Infinity
|
||||
}
|
||||
let rapidClickCount = 0
|
||||
let rapidClickStart = 0
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const top = y + jy
|
||||
const bob = 0
|
||||
const hit =
|
||||
e.clientX >= x - 6 &&
|
||||
e.clientX <= x + PUMPKIN_W + 6 &&
|
||||
e.clientY >= top - 6 &&
|
||||
e.clientY <= top + PUMPKIN_H + 6
|
||||
e.clientX >= x - 45 &&
|
||||
e.clientX <= x + PUMPKIN_W + 45 &&
|
||||
e.clientY >= top - bob - 45 &&
|
||||
e.clientY <= top - bob + PUMPKIN_H + 45
|
||||
if (!hit) return
|
||||
|
||||
const now = performance.now()
|
||||
if (now - rapidClickStart > 5000) {
|
||||
rapidClickStart = now
|
||||
rapidClickCount = 0
|
||||
}
|
||||
rapidClickCount++
|
||||
if (rapidClickCount >= 10) {
|
||||
rapidClickCount = 0
|
||||
rapidClickStart = 0
|
||||
window.dispatchEvent(new CustomEvent('rf-pumple-boss'))
|
||||
}
|
||||
spawnBatSwarm()
|
||||
if (jy === 0) vy = -430
|
||||
say(PUMPKIN_LINES[Math.floor(Math.random() * PUMPKIN_LINES.length)])
|
||||
}
|
||||
@@ -1903,11 +2233,11 @@ function LilPumpkin() {
|
||||
const dy = mouseY - (y + PUMPKIN_H / 2)
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if (Math.abs(dx) > 4) facing = dx > 0 ? 1 : -1
|
||||
if (dist > 110) {
|
||||
if (dist > 85) {
|
||||
// Walk toward the cursor but stop short, so it never ends up sitting under the pointer.
|
||||
tx = clamp(mouseX - (dx / dist) * 80 - PUMPKIN_W / 2, 0, maxX())
|
||||
ty = clamp(mouseY - (dy / dist) * 80 - PUMPKIN_H / 2, 0, maxY())
|
||||
speed = 150
|
||||
speed = 310
|
||||
} else {
|
||||
tx = x
|
||||
ty = y
|
||||
@@ -1920,7 +2250,7 @@ function LilPumpkin() {
|
||||
ty = Math.random() * maxY()
|
||||
}
|
||||
} else {
|
||||
speed = 60
|
||||
speed = 145
|
||||
}
|
||||
|
||||
const mx = tx - x
|
||||
@@ -1932,6 +2262,15 @@ function LilPumpkin() {
|
||||
x += (mx / md) * step
|
||||
y += (my / md) * step
|
||||
moving = true
|
||||
|
||||
// Advance the walk cycle according to distance actually traveled.
|
||||
walkDistance += step
|
||||
const nextFrame = Math.floor(walkDistance / 30) % PUMPKIN_FRAMES.length
|
||||
if (nextFrame !== walkFrame) {
|
||||
walkFrame = nextFrame
|
||||
sprite.src = PUMPKIN_FRAMES[walkFrame]
|
||||
}
|
||||
|
||||
if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1
|
||||
} else if (speed > 0 && !following) {
|
||||
pause = 1 + Math.random() * 2.5
|
||||
@@ -1980,7 +2319,7 @@ function LilPumpkin() {
|
||||
<div className="rf-pumpkin" ref={rootRef}>
|
||||
<div className="rf-pumpkin-bubble" ref={bubbleRef} />
|
||||
<div ref={flipRef} style={{ transformOrigin: '50% 100%' }}>
|
||||
<img src={PUMPKIN_SRC} alt="" draggable={false} />
|
||||
<img ref={spriteRef} src={PUMPKIN_FRAMES[0]} alt="" draggable={false} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -2085,6 +2424,7 @@ export function App() {
|
||||
turnstileSiteKey: null,
|
||||
benefitsEnabled: false,
|
||||
discordAuthorizeUrl: null,
|
||||
discordLoginUrl: null,
|
||||
})
|
||||
setAccount(null)
|
||||
})
|
||||
@@ -2102,7 +2442,12 @@ export function App() {
|
||||
return (
|
||||
<>
|
||||
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
|
||||
{path === '/login' || path === '/signup' ? (
|
||||
{path === '/login/discord' ? (
|
||||
// Discord's registered redirect for a SIGN-IN (the claim's is `/claim`). Matched
|
||||
// before `/login`, which it would otherwise never reach; a cold load falls through
|
||||
// to the SPA shell like every other client-side route.
|
||||
<DiscordLoginPage config={config} navigate={navigate} onAuthed={setAccount} />
|
||||
) : path === '/login' || path === '/signup' ? (
|
||||
// One page, two doors. `/signup` exists so the homepage's create-account link
|
||||
// lands on that tab instead of dropping people on sign-in to find it — and so
|
||||
// the URL is linkable. Unknown paths fall back to index.html (see the assets
|
||||
@@ -2175,6 +2520,7 @@ export function App() {
|
||||
)}
|
||||
<SiteFooter />
|
||||
<HalloweenOverlay />
|
||||
<PumpleBossFight />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2222,7 +2568,6 @@ function NavBar({
|
||||
RecFlare
|
||||
</Link>
|
||||
<nav className="nav-links">
|
||||
{SHOW_PLAYER_SEARCH && <PlayerSearch navigate={navigate} />}
|
||||
<a href={DISCORD_INVITE} target="_blank" rel="noreferrer">
|
||||
Discord
|
||||
</a>
|
||||
@@ -2267,6 +2612,7 @@ function NavBar({
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
{SHOW_SEARCH && <SiteSearch navigate={navigate} />}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
@@ -2482,7 +2828,7 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
||||
return (
|
||||
<section className="about">
|
||||
<div>
|
||||
<h2 className="about-title">A cloud architected server for the 2023/2025 game clients</h2>
|
||||
<h2 className="about-title">A cloud architected server for the 2023-2026 game clients</h2>
|
||||
<p className="about-lede">
|
||||
A free fan project, made by players who missed it. Aiming to be{' '}
|
||||
<strong>feature-complete</strong> and infinitely scalable —{' '}
|
||||
@@ -2563,6 +2909,7 @@ function LoginPage({
|
||||
}
|
||||
|
||||
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
|
||||
const discordLoginUrl = config?.discordLoginUrl ?? null
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
@@ -2609,6 +2956,25 @@ function LoginPage({
|
||||
your Steam ID — set a password on it and it signs in here too.
|
||||
</p>
|
||||
<LoginForm onAuthed={authed} />
|
||||
{/* The other door. Shown on the server's say-so like the signup tab: the URL is
|
||||
null until the Discord app is configured. `next` rides along through Discord in
|
||||
sessionStorage, since the round trip drops the query. */}
|
||||
{discordLoginUrl && (
|
||||
<div className="alt-signin">
|
||||
<p className="muted">or</p>
|
||||
<button
|
||||
type="button"
|
||||
className="cta discord"
|
||||
onClick={() => startDiscordAuth(discordLoginUrl, next)}
|
||||
>
|
||||
Sign in with Discord
|
||||
</button>
|
||||
<p className="muted">
|
||||
Signs you into the account your Discord is linked to. No account yet? This makes
|
||||
one — set a password on it afterwards and it signs in to the game too.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* The tabs above already offer this; the line under the button is where
|
||||
someone who just found out they have no account is actually looking.
|
||||
Gated on the same key, so it can't point at a door that isn't there. */}
|
||||
@@ -2627,6 +2993,109 @@ function LoginPage({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `/login/discord` — the page Discord redirects a SIGN-IN back to. Its own route rather than
|
||||
* `/claim`, because that page wants a session before it does anything and this is how one
|
||||
* gets made; it is the app's second registered `redirect_uri`, so like `/claim` it has to
|
||||
* work on a cold load.
|
||||
*
|
||||
* The return leg mirrors the claim's: check the nonce this tab minted, strip the code off
|
||||
* the URL before anything else, redeem it exactly once. What differs is the outcome. A
|
||||
* sign-in to an existing account just goes where the login page was headed. A sign-in that
|
||||
* CREATED an account (`created`) stops here first: that account has no password, and
|
||||
* without one it can't sign in to the game — on a Quest without a Meta account, which is
|
||||
* the whole point of making it this way — so the one thing to tell a brand-new player is
|
||||
* where to set one.
|
||||
*/
|
||||
function DiscordLoginPage({
|
||||
config,
|
||||
navigate,
|
||||
onAuthed,
|
||||
}: {
|
||||
config: SiteConfig | undefined
|
||||
navigate: Navigate
|
||||
onAuthed: (a: SelfAccount) => void
|
||||
}) {
|
||||
const [error, setError] = useState('')
|
||||
const [created, setCreated] = useState<SelfAccount | null>(null)
|
||||
// StrictMode runs effects twice in dev, and a Discord code is single-use — see BenefitsPanel.
|
||||
const redeemed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const code = params.get('code')
|
||||
const state = params.get('state')
|
||||
const expected = sessionStorage.getItem(OAUTH_STATE_KEY)
|
||||
const next = safeNextPath(sessionStorage.getItem(OAUTH_NEXT_KEY)) ?? '/account'
|
||||
|
||||
if (code === null) {
|
||||
// Opened directly, or Discord sent the player back with `?error=access_denied`
|
||||
// because they cancelled. Nothing to redeem; back to the door.
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
|
||||
window.history.replaceState(null, '', '/login/discord')
|
||||
if (redeemed.current) return
|
||||
redeemed.current = true
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY)
|
||||
sessionStorage.removeItem(OAUTH_NEXT_KEY)
|
||||
|
||||
if (state === null || expected === null || state !== expected) {
|
||||
setError('That Discord sign-in did not match this browser. Please start again.')
|
||||
return
|
||||
}
|
||||
|
||||
signInWithDiscord(code)
|
||||
.then(async (result) => {
|
||||
const me = await fetchMe()
|
||||
onAuthed(me)
|
||||
if (result.created) setCreated(me)
|
||||
else navigate(next)
|
||||
})
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
|
||||
}, [navigate, onAuthed])
|
||||
|
||||
if (created) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="card">
|
||||
<h2>Welcome to RecFlare</h2>
|
||||
<p>
|
||||
Your account is ready, linked to your Discord. Your username is{' '}
|
||||
<strong>@{created.username}</strong> — you can change it on your account page.
|
||||
</p>
|
||||
<p className="muted">
|
||||
It has no password yet. Set one on your account page: your username and that password
|
||||
are how you sign in to the game — including on a Quest without a Meta account.
|
||||
</p>
|
||||
<Link to="/account" navigate={navigate} className="cta">
|
||||
Go to my account
|
||||
</Link>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="card">
|
||||
<h2>Signing in with Discord</h2>
|
||||
{error ? (
|
||||
<>
|
||||
<p className="error">{error}</p>
|
||||
<Link to="/login" navigate={navigate} className="cta ghost">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">{config === undefined ? 'Loading…' : 'One moment…'}</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
/** The signed-in account page. Redirects to sign-in when there's no session. */
|
||||
function AccountPage({
|
||||
account,
|
||||
@@ -2838,7 +3307,7 @@ function PublicRoomView({
|
||||
{creator && (
|
||||
<p className="room-hero-by">
|
||||
by{' '}
|
||||
<Link to={`/u/${encodeURIComponent(creator.username)}`} navigate={navigate}>
|
||||
<Link to={`/user/${encodeURIComponent(creator.username)}`} navigate={navigate}>
|
||||
{creator.displayName || creator.username}
|
||||
</Link>
|
||||
</p>
|
||||
@@ -2889,6 +3358,7 @@ function PublicRoomView({
|
||||
<>
|
||||
{isDeveloper() && <StaffRoomTokens room={room} />}
|
||||
<StaffListing room={room} onRoomChange={onRoomChange} />
|
||||
<StaffClearInstances room={room} />
|
||||
{isDeveloper() && <StaffTakedown room={room} onTakenDown={onTakenDown} />}
|
||||
</>
|
||||
)}
|
||||
@@ -3027,6 +3497,64 @@ function StaffListing({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every instance of the room, for one whose sessions are stuck. Two steps, like the
|
||||
* dorm clear: nothing is lost for good, but anyone inside is cut off from their session.
|
||||
*/
|
||||
function StaffClearInstances({ room }: { room: OwnedRoom }) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const { pending, error, done, run } = useAction()
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Instances</h2>
|
||||
<p className="hint">
|
||||
Deletes every instance of this room, including ones with players in them. The next player to
|
||||
join gets a fresh instance.
|
||||
</p>
|
||||
<div className="mod-filter-actions">
|
||||
{confirming ? (
|
||||
<>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
const res = await call<{ deleted: number[] }>(
|
||||
`/api/staff/rooms/${room.RoomId}/clear-instances`,
|
||||
{ authed: true, method: 'POST' }
|
||||
)
|
||||
setConfirming(false)
|
||||
const n = res.deleted.length
|
||||
return n === 0
|
||||
? `^${room.Name} had no instances.`
|
||||
: `Cleared ${n} instance${n === 1 ? '' : 's'} of ^${room.Name}.`
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? 'Clearing…' : 'Confirm clear instances'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="linkish"
|
||||
disabled={pending}
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="submit" onClick={() => setConfirming(true)}>
|
||||
Clear instances
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{done && <p className="ok">{done}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const { pending, error, run } = useAction()
|
||||
@@ -3934,6 +4462,11 @@ function Dashboard({
|
||||
// settings form they opened the page to avoid.
|
||||
{ id: 'rooms', label: 'My rooms', render: () => <MyRooms navigate={navigate} /> },
|
||||
{ id: 'photos', label: 'My photos', render: () => <MyPhotos accountId={account.accountId} /> },
|
||||
{
|
||||
id: 'banner',
|
||||
label: 'Profile banner',
|
||||
render: () => <BannerUpload account={account} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
id: 'username',
|
||||
label: 'Username',
|
||||
@@ -4112,6 +4645,55 @@ function MyPhotos({ accountId }: { accountId: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile banner, as an {@link ImageUpload} in the profile banner's own shape: upload
|
||||
* the picture (`uploadBannerImage`), then set it on the account (`setBannerImage`). The
|
||||
* game only lets a player pick one of their camera photos; this takes any JPEG or PNG.
|
||||
* The held account is patched with the new key, so the preview redraws without a refetch.
|
||||
*/
|
||||
function BannerUpload({
|
||||
account,
|
||||
onChange,
|
||||
}: {
|
||||
account: SelfAccount
|
||||
onChange: (a: SelfAccount) => void
|
||||
}) {
|
||||
const { pending, error, done, run } = useAction()
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Profile banner</h2>
|
||||
<p className="muted">
|
||||
The wide picture across the top of your profile. Click it to upload a JPEG or PNG.
|
||||
</p>
|
||||
<ImageUpload
|
||||
// No banner yet: a transparent pixel, so the frame shows its gradient backdrop
|
||||
// (the profile's empty-banner look) rather than a stretched default avatar.
|
||||
src={
|
||||
account.bannerImage
|
||||
? `${where().img}/${account.bannerImage}?width=1024`
|
||||
: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
|
||||
}
|
||||
className="banner-upload"
|
||||
imgClassName="banner-preview"
|
||||
hint={account.bannerImage ? 'Change banner' : 'Upload a banner'}
|
||||
pending={pending}
|
||||
onFile={(file) =>
|
||||
void run(async () => {
|
||||
requireImage(file)
|
||||
const imageName = await uploadBannerImage(file)
|
||||
await setBannerImage(imageName)
|
||||
onChange({ ...account, bannerImage: imageName })
|
||||
return 'Banner updated.'
|
||||
})
|
||||
}
|
||||
/>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{done && <p className="ok">{done}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's
|
||||
* doing. The whole row links to the room's own page.
|
||||
|
||||
@@ -216,10 +216,10 @@ export function ItemPage({
|
||||
<p className="room-hero-by">
|
||||
by{' '}
|
||||
<a
|
||||
href={`/u/${encodeURIComponent(creator.username)}`}
|
||||
href={`/user/${encodeURIComponent(creator.username)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(`/u/${encodeURIComponent(creator.username)}`)
|
||||
navigate(`/user/${encodeURIComponent(creator.username)}`)
|
||||
}}
|
||||
>
|
||||
{creator.displayName || creator.username}
|
||||
@@ -379,10 +379,10 @@ export function InventionPage({
|
||||
<p className="room-hero-by">
|
||||
by{' '}
|
||||
<a
|
||||
href={`/u/${encodeURIComponent(creator.username)}`}
|
||||
href={`/user/${encodeURIComponent(creator.username)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(`/u/${encodeURIComponent(creator.username)}`)
|
||||
navigate(`/user/${encodeURIComponent(creator.username)}`)
|
||||
}}
|
||||
>
|
||||
{creator.displayName || creator.username}
|
||||
|
||||
@@ -369,7 +369,7 @@ function BanSummary({
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for a player's profile page (`/u/<username>`): whether a ban blocks them right
|
||||
* One line for a player's profile page (`/user/<username>`): whether a ban blocks them right
|
||||
* now — their own or a linked account's — and a link to their full history here.
|
||||
*/
|
||||
export function PlayerBanStatus({
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
After Width: | Height: | Size: 82 KiB |
@@ -14,16 +14,29 @@ import { call, isAdmin, useAction } from './api'
|
||||
|
||||
const PAGE = '/settings/recroomstudio'
|
||||
|
||||
type AccessKind = keyof typeof accessLabels
|
||||
const accessLabels = {
|
||||
studio: 'Studio uploads',
|
||||
'avatar-items': 'Avatar Studio items',
|
||||
'avatar-costumes': 'Avatar Studio costumes',
|
||||
'avatar-badges': 'Avatar Studio badges',
|
||||
'avatar-consumables': 'Avatar Studio consumables',
|
||||
screenshare: 'Screen sharing',
|
||||
}
|
||||
|
||||
interface AccessRow {
|
||||
accountId: number
|
||||
username: string | null
|
||||
displayName: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function StudioAccessPage({
|
||||
function AccessPage({
|
||||
kind,
|
||||
account,
|
||||
navigate,
|
||||
}: {
|
||||
kind: AccessKind
|
||||
/** undefined while the session is still being checked, null when signed out. */
|
||||
account: { accountId: number; username: string } | null | undefined
|
||||
navigate: (to: string) => void
|
||||
@@ -39,15 +52,17 @@ export function StudioAccessPage({
|
||||
const add = useAction()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const me = await call<{ granted: boolean }>('/api/studio-access', { authed: true })
|
||||
setGranted(me.granted === true)
|
||||
const me = await call<Record<AccessKind | 'granted', boolean>>('/api/studio-access', {
|
||||
authed: true,
|
||||
})
|
||||
setGranted((kind === 'studio' ? me.granted : me[kind]) === true)
|
||||
if (isAdmin()) {
|
||||
const page = await call<{ accounts: AccessRow[] }>('/api/staff/studio-access', {
|
||||
const page = await call<{ accounts: AccessRow[] }>(`/api/staff/studio-access?kind=${kind}`, {
|
||||
authed: true,
|
||||
})
|
||||
setRows(page.accounts ?? [])
|
||||
}
|
||||
}, [])
|
||||
}, [kind])
|
||||
|
||||
const accountId = account?.accountId
|
||||
useEffect(() => {
|
||||
@@ -80,13 +95,13 @@ export function StudioAccessPage({
|
||||
void add.run(async () => {
|
||||
const result = await call<{ username: string; alreadyGranted: boolean }>(
|
||||
'/api/staff/studio-access',
|
||||
{ method: 'POST', authed: true, json: { username: username.trim() } }
|
||||
{ method: 'POST', authed: true, json: { username: username.trim(), kind } }
|
||||
)
|
||||
setUsername('')
|
||||
await reload()
|
||||
return result.alreadyGranted
|
||||
? `@${result.username} is already on the list.`
|
||||
: `@${result.username} can upload the next time they sign in to Studio.`
|
||||
? `@${result.username} already has ${accessLabels[kind]} access.`
|
||||
: `@${result.username} now has ${accessLabels[kind]} access.`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,7 +110,14 @@ export function StudioAccessPage({
|
||||
setRemoveError('')
|
||||
void (async () => {
|
||||
try {
|
||||
await call(`/api/staff/studio-access/${row.accountId}`, { method: 'DELETE', authed: true })
|
||||
await call(
|
||||
row.enabled
|
||||
? `/api/staff/studio-access/${row.accountId}?kind=${kind}`
|
||||
: '/api/staff/studio-access',
|
||||
row.enabled
|
||||
? { method: 'DELETE', authed: true }
|
||||
: { method: 'POST', authed: true, json: { accountId: row.accountId, kind } }
|
||||
)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setRemoveError(err instanceof Error ? err.message : String(err))
|
||||
@@ -108,13 +130,16 @@ export function StudioAccessPage({
|
||||
const signInHref = `/login?next=${encodeURIComponent(PAGE)}`
|
||||
|
||||
return (
|
||||
<main className={staff ? 'shell wide' : 'shell'}>
|
||||
<h1>RecFlare Studio access</h1>
|
||||
<section>
|
||||
<h2>{accessLabels[kind]}</h2>
|
||||
<section className="card">
|
||||
<p className="muted">
|
||||
RecFlare Studio signs in with any account. Uploading is limited to accounts on this list.
|
||||
Access is picked up the next time you sign in to Studio, or when that session refreshes. A
|
||||
session you already have keeps its old access until then, and a session lasts a day.
|
||||
{kind === 'studio'
|
||||
? 'Manage permission to upload from RecFlare Studio.'
|
||||
: kind.startsWith('avatar-')
|
||||
? 'Grant this Avatar Studio feature independently. Studio upload access is also required. Roomie equipment still requires developer access.'
|
||||
: 'Screen sharing is disabled by default. Staff can enable it for individual users.'}{' '}
|
||||
Sign in again or refresh the session to pick up permission changes.
|
||||
</p>
|
||||
{account === undefined ? (
|
||||
<p className="muted">Loading…</p>
|
||||
@@ -130,7 +155,7 @@ export function StudioAccessPage({
|
||||
>
|
||||
Sign in
|
||||
</a>{' '}
|
||||
<span className="muted">to see whether this account can upload.</span>
|
||||
<span className="muted">to see the access for this account.</span>
|
||||
</p>
|
||||
) : loading ? (
|
||||
<p className="muted">Loading…</p>
|
||||
@@ -138,22 +163,23 @@ export function StudioAccessPage({
|
||||
<p className="error">{loadError}</p>
|
||||
) : granted ? (
|
||||
<p>
|
||||
<strong>{account.username}</strong> can upload in RecFlare Studio. If the editor still
|
||||
<strong>{account.username}</strong> has {accessLabels[kind]} access. If the editor still
|
||||
says otherwise, sign out of it and sign in again.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<strong>{account.username}</strong> cannot upload in RecFlare Studio yet. A moderator
|
||||
can add this account to the list.
|
||||
<strong>{account.username}</strong> does not have {accessLabels[kind]} access. A
|
||||
moderator can add this account to the list.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{staff && account && (
|
||||
<section className="card">
|
||||
<h2>Whitelist</h2>
|
||||
<h3>Whitelist</h3>
|
||||
<p className="muted">
|
||||
Adding a player puts the betastudio role on their next Studio sign-in. Developers are
|
||||
not on this list unless someone adds them.
|
||||
{kind === 'screenshare'
|
||||
? 'Only users on this list can share their screen.'
|
||||
: 'Add a username to grant access, or remove a player to revoke it.'}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
@@ -172,7 +198,7 @@ export function StudioAccessPage({
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={add.pending || username.trim() === ''}>
|
||||
Add
|
||||
{kind === 'screenshare' ? 'Enable' : 'Add'}
|
||||
</button>
|
||||
</form>
|
||||
{add.error && <p className="error">{add.error}</p>}
|
||||
@@ -180,7 +206,7 @@ export function StudioAccessPage({
|
||||
{loading ? (
|
||||
<p className="muted">Loading…</p>
|
||||
) : loadError ? null : rows.length === 0 ? (
|
||||
<p className="muted">Nobody is on the list.</p>
|
||||
<p className="muted">No users on this list.</p>
|
||||
) : (
|
||||
<ul className="studio-access-list">
|
||||
{rows.map((row) => (
|
||||
@@ -192,13 +218,12 @@ export function StudioAccessPage({
|
||||
{row.displayName && row.displayName !== row.username ? (
|
||||
<span className="muted"> · {row.displayName}</span>
|
||||
) : null}
|
||||
{kind === 'screenshare' && (
|
||||
<span className="muted"> - {row.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={removing === row.accountId}
|
||||
onClick={() => remove(row)}
|
||||
>
|
||||
Remove
|
||||
<button type="button" disabled={removing !== null} onClick={() => remove(row)}>
|
||||
{kind === 'screenshare' ? (row.enabled ? 'Disable' : 'Enable') : 'Remove'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -207,6 +232,20 @@ export function StudioAccessPage({
|
||||
{removeError && <p className="error">{removeError}</p>}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudioAccessPage(props: {
|
||||
account: { accountId: number; username: string } | null | undefined
|
||||
navigate: (to: string) => void
|
||||
}) {
|
||||
return (
|
||||
<main className="shell wide">
|
||||
<h1>RecFlare Studio access</h1>
|
||||
{(Object.keys(accessLabels) as AccessKind[]).map((kind) => (
|
||||
<AccessPage key={kind} kind={kind} {...props} />
|
||||
))}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,23 +219,32 @@ export function useAction() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `@username` to the account id the workers address a player by.
|
||||
*
|
||||
* `accounts` serves no exact-name lookup, so this goes through the PREFIX search and
|
||||
* keeps only an exact (case-insensitive) hit: a prefix match is a different player, and
|
||||
* acting on whoever happened to sort first would be worse than refusing. The exact name
|
||||
* always sorts first among its own prefixes, so it's inside the search limit whenever it
|
||||
* exists.
|
||||
* The public account behind an exact `@username`, or null when nobody has the name.
|
||||
* `accounts`' `GET /account/username/:name` is an indexed equality lookup — NOT the
|
||||
* prefix search, which this once went through and filtered client-side; one profile view
|
||||
* then cost a 20-row index walk, and the search bar's typeahead already keeps that query
|
||||
* busy enough. A 404 is the one non-ok answer that means "no such player"; anything else
|
||||
* is a real failure and throws.
|
||||
*/
|
||||
export async function accountByUsername(input: string): Promise<PublicAccount | null> {
|
||||
const name = input.trim().replace(/^@/, '')
|
||||
if (name === '') return null
|
||||
const res = await fetch(`${where().accounts}/account/username/${encodeURIComponent(name)}`)
|
||||
if (res.status === 404) return null
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (!res.ok) throw new Error(errorMessage(data, res.status))
|
||||
return data as unknown as PublicAccount
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `@username` to the account id the workers address a player by. An exact
|
||||
* match only: a prefix match is a different player, and acting on whoever happened to
|
||||
* sort first would be worse than refusing.
|
||||
*/
|
||||
export async function accountIdForUsername(input: string): Promise<number> {
|
||||
const name = input.trim().replace(/^@/, '')
|
||||
if (name === '') throw new Error('Enter a username.')
|
||||
const matches = await call<Array<{ accountId?: number; username?: string }>>(
|
||||
`${where().accounts}/account/search?name=${encodeURIComponent(name)}`
|
||||
)
|
||||
const found = Array.isArray(matches)
|
||||
? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase())
|
||||
: undefined
|
||||
const found = await accountByUsername(name)
|
||||
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
|
||||
return found.accountId
|
||||
}
|
||||
@@ -245,6 +254,9 @@ export interface PublicAccount {
|
||||
accountId: number
|
||||
username: string
|
||||
displayName: string
|
||||
profileImage: string
|
||||
bannerImage: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -135,8 +135,8 @@ body {
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
/* A link is one or two words; with the search bar in the row it has to give up space
|
||||
by wrapping the ROW (see the nav rule under player search), not by breaking mid-link. */
|
||||
/* A link is one or two words; short of room the ROW wraps (see the nav rule under
|
||||
site search), never a link mid-word. */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -447,6 +447,33 @@ body {
|
||||
border-color: #4752c4;
|
||||
}
|
||||
|
||||
/* The way back out of the Discord link, at the foot of the benefits card: set apart by a
|
||||
rule so it doesn't read as part of the claim, and the button in the error colour so it
|
||||
reads as the undo it is. The hint beside it says what has to be set first. */
|
||||
.unlink {
|
||||
margin-top: 22px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.unlink h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.linkish.danger {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.linkish.danger:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.linkish.danger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Secondary action: same shape, outlined instead of filled. */
|
||||
.cta.ghost {
|
||||
background: transparent;
|
||||
@@ -1770,50 +1797,101 @@ button[type='submit']:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Player search ------------------------------------------------------ */
|
||||
/* ---- Site search ------------------------------------------------------- */
|
||||
|
||||
.player-search {
|
||||
/* The panel hangs off the NAV, not the button: the nav can wrap on a phone, which moves
|
||||
the button, but the nav's right edge stays put — so the panel always opens flush right
|
||||
and never runs off the screen. */
|
||||
.nav {
|
||||
position: relative;
|
||||
width: min(280px, 100%);
|
||||
/* Wraps only when the row doesn't fit (a staff account's extra links on a phone). */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.player-search input {
|
||||
margin: 0;
|
||||
height: 40px;
|
||||
padding: 8px 12px;
|
||||
.site-search-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
border-color: var(--line);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.player-search input:focus {
|
||||
.site-search-toggle:hover,
|
||||
.site-search-toggle.active {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-hi);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.player-search-bubble {
|
||||
.site-search-panel {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 6px;
|
||||
top: calc(100% - 8px);
|
||||
right: 20px;
|
||||
width: min(380px, calc(100% - 40px));
|
||||
max-height: min(70vh, 560px);
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgb(0 0 0 / 28%);
|
||||
}
|
||||
|
||||
.player-search-result {
|
||||
.site-search-panel input {
|
||||
margin: 0;
|
||||
height: 42px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-hi);
|
||||
border-color: var(--line);
|
||||
/* 16px keeps iOS from zooming the page when the box takes focus. */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.site-search-panel input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.site-search-group {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.site-search-heading,
|
||||
.site-search-empty {
|
||||
margin: 6px 8px 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.site-search-empty {
|
||||
margin: 12px 8px 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.site-search-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
@@ -1825,22 +1903,35 @@ button[type='submit']:disabled {
|
||||
font-family: var(--body);
|
||||
}
|
||||
|
||||
.player-search-result:hover,
|
||||
.player-search-result:focus-visible {
|
||||
.site-search-result > span {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.site-search-result:hover,
|
||||
.site-search-result:focus-visible {
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
.player-search-avatar {
|
||||
.site-search-avatar,
|
||||
.site-search-thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
.player-search-name {
|
||||
.site-search-avatar {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.site-search-thumb {
|
||||
width: 54px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.site-search-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
@@ -1849,7 +1940,7 @@ button[type='submit']:disabled {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-search-handle {
|
||||
.site-search-sub {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
@@ -1860,11 +1951,32 @@ button[type='submit']:disabled {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The search bar is a third thing in a row built for two, so the nav wraps when it runs
|
||||
out of room rather than overflowing. No breakpoint: wrapping only happens when the row
|
||||
doesn't fit, so wide screens are untouched. */
|
||||
.nav {
|
||||
flex-wrap: wrap;
|
||||
/* The account page's banner upload, in the profile banner's shape so what's uploaded
|
||||
looks as it will across the top of the profile. */
|
||||
.banner-upload {
|
||||
display: block;
|
||||
margin: 12px 0 0;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.banner-preview {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
object-fit: cover;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 80% 20%,
|
||||
color-mix(in srgb, var(--accent) 24%, transparent),
|
||||
transparent 45%
|
||||
),
|
||||
linear-gradient(135deg, var(--surface-hi), var(--surface));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.banner-preview {
|
||||
height: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Public player profiles -------------------------------------------- */
|
||||
@@ -2074,3 +2186,19 @@ button[type='submit']:disabled {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* The login card's second door, under the password form: an "or" and the Discord button,
|
||||
separated from the form by a rule so the two aren't read as one. */
|
||||
.alt-signin {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.alt-signin > .muted:first-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -144,6 +144,14 @@ export type Env = SharedHonoEnv & {
|
||||
* all closes the claim, exactly as an unset one does.
|
||||
*/
|
||||
DISCORD_BENEFITS_ROLE_IDS?: string
|
||||
/**
|
||||
* Discord role ids whose holders have Rec Room Studio upload access without the
|
||||
* account's `hasStudio` flag (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`; the var
|
||||
* `auth` stamps `betastudio` from). `/api/studio-access` answers `granted` through the
|
||||
* same `hasStudioAccess`, so the Studio settings page agrees with the token. The staff
|
||||
* list and its add/remove still manage the flag alone.
|
||||
*/
|
||||
STUDIO_ROLLOUT?: string
|
||||
/**
|
||||
* The bot user's token for the scheduled role refresh (discord-roles.ts). Optional in
|
||||
* effect: the binding must exist for the deploy, but a placeholder value just leaves the
|
||||
|
||||
@@ -55,6 +55,15 @@ export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize'
|
||||
*/
|
||||
export const SCOPES = 'identify guilds.members.read'
|
||||
|
||||
/**
|
||||
* What "Sign in with Discord" asks for: the user's id and nothing else. The sign-in has no
|
||||
* question to ask about the guild — it only needs to know WHO, so the account linked to that
|
||||
* Discord user can be signed into (or made). Asking for the member read here would put a
|
||||
* permission on the consent screen that the sign-in never uses; the benefits claim is where
|
||||
* roles are read, with its own consent.
|
||||
*/
|
||||
export const LOGIN_SCOPES = 'identify'
|
||||
|
||||
/** Everything the claim needs configured. Resolved per request; see `discordConfig`. */
|
||||
export interface DiscordConfig {
|
||||
/** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */
|
||||
@@ -170,6 +179,15 @@ export async function readSecret(secret: SecretsStoreSecret, name: string): Prom
|
||||
*/
|
||||
export const redirectUri = (request: Request): string => new URL('/claim', request.url).toString()
|
||||
|
||||
/**
|
||||
* The sign-in's return leg — its own registered redirect, so Discord sends a sign-in back to
|
||||
* a page that knows it is one. `/claim` can't double for it: that page needs a session
|
||||
* before it does anything, and a sign-in is how the session gets made. Same derivation,
|
||||
* same byte-for-byte rule, same reason the SPA doesn't get to supply it. See App.tsx.
|
||||
*/
|
||||
export const loginRedirectUri = (request: Request): string =>
|
||||
new URL('/login/discord', request.url).toString()
|
||||
|
||||
/**
|
||||
* Swap an authorization code for an access token. Returns null on any refusal — a code
|
||||
* that was already spent, expired (they live ~1 minute), issued to another app, or paired
|
||||
@@ -278,6 +296,39 @@ export async function fetchGuildMembership(
|
||||
}
|
||||
}
|
||||
|
||||
/** Who a token belongs to — the `identify` scope's one answer. */
|
||||
export interface DiscordUser {
|
||||
/** The Discord user's id (a snowflake, kept as a string — it exceeds 2^53). */
|
||||
id: string
|
||||
/** Their Discord username, for the confirmation line. Display only, never stored. */
|
||||
username: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The token owner's own Discord user, or null when Discord won't say or can't be reached.
|
||||
* The sign-in's one read: it needs the id, and `identify` grants exactly `GET /users/@me`.
|
||||
*/
|
||||
export async function fetchUser(accessToken: string): Promise<DiscordUser | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/@me`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
logger.info('discord did not return the token owner', { status: res.status })
|
||||
return null
|
||||
}
|
||||
const user = (await res.json()) as { id?: unknown; username?: unknown }
|
||||
if (typeof user.id !== 'string' || user.id === '') {
|
||||
logger.error('discord returned a user with no id')
|
||||
return null
|
||||
}
|
||||
return { id: user.id, username: typeof user.username === 'string' ? user.username : '' }
|
||||
} catch (err) {
|
||||
logger.error('could not reach discord to read the token owner', { error: String(err) })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the access token back to Discord once the roles have been read.
|
||||
*
|
||||
|
||||
@@ -14,7 +14,7 @@ export const DISCORD_INVITE = 'https://join.recflare.net'
|
||||
export const DOWNLOAD_URL = 'https://github.com/recflare/client/releases'
|
||||
|
||||
/** The stage's "Download for Quest" button: the build's listing on the Meta store. */
|
||||
export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/7rvx0Cv9p'
|
||||
export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/6tSy6tIxY'
|
||||
|
||||
/** The public source repo, linked from the homepage and footer. */
|
||||
export const SOURCE_REPO = 'https://github.com/orgs/recflare/repositories'
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
clearDormSaveBlob,
|
||||
clearPasswordHash,
|
||||
createGift,
|
||||
deleteRoomInstancesByRoom,
|
||||
getAccount,
|
||||
getImageById,
|
||||
getOnlinePlayerIds,
|
||||
@@ -1016,6 +1017,28 @@ export async function giftRoomTokensHandler(c: Context<App>) {
|
||||
return c.json({ roomId, amount, paid, skipped })
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every instance of a room from `room_instance`, from the room's own page. For a
|
||||
* room whose sessions have gone bad: the next player to matchmake in gets a fresh one.
|
||||
* Clearing a room with no instances succeeds and deletes nothing.
|
||||
*/
|
||||
export async function clearRoomInstancesHandler(c: Context<App>) {
|
||||
const roomId = Number(c.req.param('roomId'))
|
||||
if (!Number.isInteger(roomId) || roomId <= 0) {
|
||||
return c.json({ error: 'A numeric room id is required' }, 400)
|
||||
}
|
||||
|
||||
const deleted = await deleteRoomInstancesByRoom(c.env.DB, roomId)
|
||||
|
||||
await recordPlayerAudit(c, 'clear_room_instances', { roomId, deleted })
|
||||
logger.info('staff cleared room instances', {
|
||||
moderatorId: staffId(c),
|
||||
roomId,
|
||||
deletedCount: deleted.length,
|
||||
})
|
||||
return c.json({ roomId, deleted })
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tokens to EVERYONE ONLINE right now — the server-wide drop behind the account page's
|
||||
* "Token drop" tab, beside the coach broadcast and the maintenance notice.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
AVATAR_STUDIO_ACCESS,
|
||||
avatarStudioFlags,
|
||||
getAccount,
|
||||
getAccountByUsername,
|
||||
listStudioAccounts,
|
||||
@@ -8,6 +10,8 @@ import {
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import { hasStudioAccess } from '../../auth/src/platform-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
@@ -20,25 +24,55 @@ import type { App } from './context'
|
||||
* the signed-in player. Staff add and remove people through the routes under
|
||||
* `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation.
|
||||
* The grant is the account's `hasStudio` flag; `auth` stamps `betastudio` from it
|
||||
* on the next Studio sign-in or refresh. Who granted what is on `audit_log`.
|
||||
* on the next Studio sign-in or refresh. Who granted what is on `audit_log`. A Discord
|
||||
* role in the `STUDIO_ROLLOUT` var grants the same access without the flag
|
||||
* (`hasStudioAccess`); the status below reports it, the staff list does not — that list
|
||||
* is the accounts staff added by hand.
|
||||
*/
|
||||
|
||||
/** The signed-in player's own flag, and nothing else. 401 with no session. */
|
||||
/** Whether the signed-in player can upload — flag or rollout role. 401 with no session. */
|
||||
export async function studioAccessStatusHandler(c: Context<App>) {
|
||||
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const account = await getAccount(c.env.DB, accountId)
|
||||
return c.json({ granted: account?.hasStudio === true })
|
||||
const flags = await avatarStudioFlags(c.env.DB, accountId)
|
||||
return c.json({
|
||||
...Object.fromEntries(
|
||||
Object.entries(AVATAR_STUDIO_ACCESS).map(([kind, mask]) => [kind, (flags & mask) === mask])
|
||||
),
|
||||
granted: await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT),
|
||||
avatar: (flags & 7) !== 0,
|
||||
screenshare: account?.hasScreenshare === true,
|
||||
})
|
||||
}
|
||||
|
||||
type AccessKind = 'studio' | 'avatar' | 'screenshare' | keyof typeof AVATAR_STUDIO_ACCESS
|
||||
function accessKind(value: unknown): AccessKind | null {
|
||||
if (value === undefined) return 'studio'
|
||||
if (value === 'studio' || value === 'avatar' || value === 'screenshare') return value
|
||||
return typeof value === 'string' && Object.hasOwn(AVATAR_STUDIO_ACCESS, value)
|
||||
? (value as AccessKind)
|
||||
: null
|
||||
}
|
||||
function avatarMask(kind: AccessKind): number {
|
||||
return kind === 'avatar'
|
||||
? 7
|
||||
: Object.hasOwn(AVATAR_STUDIO_ACCESS, kind)
|
||||
? AVATAR_STUDIO_ACCESS[kind as keyof typeof AVATAR_STUDIO_ACCESS]
|
||||
: 0
|
||||
}
|
||||
|
||||
/** Every account with the flag set. */
|
||||
export async function listStudioAccessHandler(c: Context<App>) {
|
||||
const accounts = await listStudioAccounts(c.env.DB)
|
||||
const kind = accessKind(c.req.query('kind'))
|
||||
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
|
||||
const accounts = await listStudioAccounts(c.env.DB, kind)
|
||||
return c.json({
|
||||
accounts: accounts.map((account) => ({
|
||||
accountId: account.accountId,
|
||||
username: account.username,
|
||||
displayName: account.displayName,
|
||||
enabled: kind !== 'screenshare' || account.hasScreenshare === true,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -53,7 +87,9 @@ export async function grantStudioAccessHandler(c: Context<App>) {
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return c.json({ error: 'Expected a JSON body' }, 400)
|
||||
}
|
||||
const posted = body as { username?: unknown; accountId?: unknown }
|
||||
const posted = body as { username?: unknown; accountId?: unknown; kind?: unknown }
|
||||
const kind = accessKind(posted.kind)
|
||||
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
|
||||
const username =
|
||||
typeof posted.username === 'string' ? posted.username.trim().replace(/^@/, '') : ''
|
||||
const accountId = postedAccountId(posted.accountId)
|
||||
@@ -72,9 +108,15 @@ export async function grantStudioAccessHandler(c: Context<App>) {
|
||||
return c.json({ error: 'Name a player by username or account id' }, 400)
|
||||
}
|
||||
|
||||
const alreadyGranted = account.hasStudio === true
|
||||
if (!alreadyGranted) await updateAccount(c.env.DB, account.accountId, { hasStudio: true })
|
||||
await recordAudit(c, 'grant_studio_access', {
|
||||
const alreadyGranted =
|
||||
avatarMask(kind) !== 0
|
||||
? ((await avatarStudioFlags(c.env.DB, account.accountId)) & avatarMask(kind)) ===
|
||||
avatarMask(kind)
|
||||
: kind === 'screenshare'
|
||||
? account.hasScreenshare === true
|
||||
: account.hasStudio === true
|
||||
await setAccess(c, account.accountId, kind, true)
|
||||
await recordAudit(c, `grant_${kind}_access`, {
|
||||
playerId: account.accountId,
|
||||
username: account.username,
|
||||
alreadyGranted,
|
||||
@@ -93,15 +135,22 @@ export async function grantStudioAccessHandler(c: Context<App>) {
|
||||
* account is a 404.
|
||||
*/
|
||||
export async function revokeStudioAccessHandler(c: Context<App>) {
|
||||
const kind = accessKind(c.req.query('kind'))
|
||||
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
|
||||
const accountId = Number(c.req.param('id'))
|
||||
if (!Number.isInteger(accountId) || accountId <= 0) {
|
||||
return c.json({ error: 'A numeric player id is required' }, 400)
|
||||
}
|
||||
const account = await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.json({ error: 'No player has that account id' }, 404)
|
||||
const removed = account.hasStudio === true
|
||||
if (removed) await updateAccount(c.env.DB, accountId, { hasStudio: false })
|
||||
await recordAudit(c, 'revoke_studio_access', {
|
||||
const removed =
|
||||
avatarMask(kind) !== 0
|
||||
? ((await avatarStudioFlags(c.env.DB, accountId)) & avatarMask(kind)) !== 0
|
||||
: kind === 'screenshare'
|
||||
? account.hasScreenshare === true
|
||||
: account.hasStudio === true
|
||||
await setAccess(c, accountId, kind, false)
|
||||
await recordAudit(c, `revoke_${kind}_access`, {
|
||||
playerId: accountId,
|
||||
username: account.username,
|
||||
removed,
|
||||
@@ -109,6 +158,26 @@ export async function revokeStudioAccessHandler(c: Context<App>) {
|
||||
return c.json({ accountId, granted: false, removed })
|
||||
}
|
||||
|
||||
async function setAccess(c: Context<App>, accountId: number, kind: AccessKind, enabled: boolean) {
|
||||
const mask = avatarMask(kind)
|
||||
if (mask !== 0) {
|
||||
const now = new Date().toISOString()
|
||||
await c.env.DB.prepare(
|
||||
`INSERT INTO avatar_studio_access (account_id, flags, created_at, modified_at)
|
||||
VALUES (?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET
|
||||
flags = ${enabled ? 'flags | ?' : 'flags & ~?'}, modified_at = excluded.modified_at`
|
||||
)
|
||||
.bind(accountId, enabled ? mask : 0, now, now, mask)
|
||||
.run()
|
||||
} else {
|
||||
await updateAccount(
|
||||
c.env.DB,
|
||||
accountId,
|
||||
kind === 'screenshare' ? { hasScreenshare: enabled } : { hasStudio: enabled }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A positive integer account id from a JSON field, or null when it isn't one. */
|
||||
function postedAccountId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
updateAccount,
|
||||
} from '@repo/domain/src/accounts-db'
|
||||
import { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-db'
|
||||
import { AVATAR_STUDIO_SCHEMA, avatarStudioFlags } from '@repo/domain/src/avatar-studio'
|
||||
import { PlatformType } from '@repo/domain/src/enums'
|
||||
import { getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain/src/gifts-db'
|
||||
import {
|
||||
@@ -22,7 +23,11 @@ import {
|
||||
setPresence,
|
||||
} from '@repo/domain/src/presence-db'
|
||||
import { getProgression, PROGRESSION_SCHEMA_DDL } from '@repo/domain/src/progression-db'
|
||||
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
|
||||
import {
|
||||
createRoomInstance,
|
||||
getRoomInstancesByRoom,
|
||||
ROOM_INSTANCE_SCHEMA_DDL,
|
||||
} from '@repo/domain/src/room-instance-db'
|
||||
// Banning a player moves them into their own dorm, which is a room (created on demand) with
|
||||
// a subroom — so those tables have to be here, as they are on the shared database.
|
||||
import { getOrCreateDormRoom, ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
|
||||
@@ -44,9 +49,12 @@ import {
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForAccount,
|
||||
getLinksForPlatform,
|
||||
hasRecordedRoles,
|
||||
isPlatformIdentityLinked,
|
||||
linkPlatformIdentity,
|
||||
PLATFORM_SCHEMA_DDL,
|
||||
recordFirstRoles,
|
||||
unlinkPlatform,
|
||||
} from '../../../../auth/src/platform-db'
|
||||
import {
|
||||
BALANCE_SCHEMA_DDL,
|
||||
@@ -68,7 +76,13 @@ import {
|
||||
INVENTORY_CUSTOM_SCHEMA_DDL,
|
||||
ownedCustomAvatarItemIds,
|
||||
} from '../../../../econ/src/inventory-custom-db'
|
||||
import { discordConfig, parseRoleIds, qualifies } from '../../discord'
|
||||
import {
|
||||
discordConfig,
|
||||
loginRedirectUri,
|
||||
parseRoleIds,
|
||||
qualifies,
|
||||
redirectUri,
|
||||
} from '../../discord'
|
||||
import { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles'
|
||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||||
@@ -119,6 +133,7 @@ beforeAll(async () => {
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// `account` likewise: owned by `auth`, read and (for the benefits claim) written here.
|
||||
for (const stmt of ACCOUNT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of AVATAR_STUDIO_SCHEMA) await env.DB.prepare(stmt).run()
|
||||
// And `platform_account`, where a claimed Discord identity is linked.
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// `report` and `warning` are owned (and migrated) by `api`; www serves the staff panel
|
||||
@@ -167,6 +182,8 @@ it('advertises signup and where the other workers live', async () => {
|
||||
// even be started. Note the client id is NOT leaked by a closed config.
|
||||
benefitsEnabled: false,
|
||||
discordAuthorizeUrl: null,
|
||||
// "Sign in with Discord" is on the same switch, and closed with it.
|
||||
discordLoginUrl: null,
|
||||
hosts: {
|
||||
auth: 'https://auth.rec.example.com',
|
||||
accounts: 'https://accounts.rec.example.com',
|
||||
@@ -669,16 +686,220 @@ it('reports where an account stands on benefits', async () => {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ hasPlus: false, linked: false })
|
||||
// No email either: the unlink's one precondition, served alongside so the page can say
|
||||
// so first. Nothing linked, so there is nothing to unlink yet.
|
||||
expect(await res.json()).toEqual({
|
||||
hasPlus: false,
|
||||
linked: false,
|
||||
discordLinked: false,
|
||||
hasEmail: false,
|
||||
})
|
||||
|
||||
// A bare link — what "Sign in with Discord" leaves on an account it just made — is NOT a
|
||||
// claim: no roles were ever recorded, so the page must still offer the claim. It IS a
|
||||
// Discord on the account, though, so `discordLinked` says so: the unlink is offered for
|
||||
// it as much as for a claim's link.
|
||||
await updateAccount(env.DB, 4003, { hasPlus: true })
|
||||
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
|
||||
res = await SELF.fetch('https://example.com/api/benefits/status', {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
// `linked` says THAT a Discord identity is attached, never which one — the id is of no
|
||||
// use to the page, and an account's linked identities have no business on the wire.
|
||||
expect(await res.json()).toEqual({ hasPlus: true, linked: true })
|
||||
expect(await res.json()).toEqual({
|
||||
hasPlus: true,
|
||||
linked: false,
|
||||
discordLinked: true,
|
||||
hasEmail: false,
|
||||
})
|
||||
|
||||
// Claimed (roles recorded, even none). `linked` says THAT a Discord identity has claimed,
|
||||
// never which one — the id is of no use to the page, and an account's linked identities
|
||||
// have no business on the wire.
|
||||
await updateAccount(env.DB, 4003, { email: 'p@example.com' })
|
||||
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001', [])
|
||||
res = await SELF.fetch('https://example.com/api/benefits/status', {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(await res.json()).toEqual({
|
||||
hasPlus: true,
|
||||
linked: true,
|
||||
discordLinked: true,
|
||||
hasEmail: true,
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Discord unlink --------------------------------------------------------
|
||||
// Taking the Discord off an account. The link may be the only way in — an account made by
|
||||
// "Sign in with Discord" has no email until the player sets one — so the unlink is refused
|
||||
// until the account has an address to be recovered through, decided from the row and never
|
||||
// from the page. A password is NOT required: a passwordless account sets one in game.
|
||||
|
||||
const unlink = (token: string) =>
|
||||
SELF.fetch('https://example.com/api/benefits/unlink', {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
|
||||
it('requires a valid session to unlink a discord', async () => {
|
||||
expect((await unlink('')).status).toBe(401)
|
||||
const forged = await generateToken('4020', '', 4, 'not-the-real-secret')
|
||||
expect((await unlink(forged)).status).toBe(401)
|
||||
})
|
||||
|
||||
it('refuses to unlink a discord until the account has an email address', async () => {
|
||||
const token = await tokenFor(4021)
|
||||
await linkPlatformIdentity(env.DB, 4021, PlatformType.Discord, '99021')
|
||||
|
||||
// No email — and a password doesn't stand in for one.
|
||||
await updateAccount(env.DB, 4021, { passwordHash: 'salt:hash' })
|
||||
const res = await unlink(token)
|
||||
expect(res.status).toBe(403)
|
||||
expect(await res.json()).toEqual({
|
||||
error:
|
||||
'Add an email address to your account before unlinking Discord, so the account can be recovered without it.',
|
||||
})
|
||||
|
||||
// Refused means untouched: the link is still there.
|
||||
await expect(isPlatformIdentityLinked(env.DB, 4021, PlatformType.Discord, '99021')).resolves.toBe(
|
||||
true
|
||||
)
|
||||
|
||||
// An email alone, no password, is enough.
|
||||
await updateAccount(env.DB, 4021, { passwordHash: undefined, email: 'p@example.com' })
|
||||
expect((await unlink(token)).status).toBe(200)
|
||||
await expect(isPlatformIdentityLinked(env.DB, 4021, PlatformType.Discord, '99021')).resolves.toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('unlinks every discord on the account, and takes plus and studio with it', async () => {
|
||||
const token = await tokenFor(4022)
|
||||
await updateAccount(env.DB, 4022, {
|
||||
username: 'Leaver',
|
||||
email: 'p@example.com',
|
||||
hasPlus: true,
|
||||
hasStudio: true,
|
||||
})
|
||||
// A claimed link and (the once-only guard aside) a second identity, both Discord — and a
|
||||
// Steam login that must survive: only the Discord goes.
|
||||
await linkPlatformIdentity(env.DB, 4022, PlatformType.Discord, '99022', ['r1'])
|
||||
await linkPlatformIdentity(env.DB, 4022, PlatformType.Discord, '99023')
|
||||
await linkPlatformIdentity(env.DB, 4022, PlatformType.Steam, '76561000000000022')
|
||||
|
||||
const res = await unlink(token)
|
||||
expect(res.status).toBe(200)
|
||||
// The status as it now stands, in `/api/benefits/status`'s shape: the grants went with
|
||||
// the link.
|
||||
expect(await res.json()).toEqual({
|
||||
hasPlus: false,
|
||||
linked: false,
|
||||
discordLinked: false,
|
||||
hasEmail: true,
|
||||
})
|
||||
expect((await getLinksForAccount(env.DB, 4022)).map((l) => [l.platform, l.platformId])).toEqual([
|
||||
[PlatformType.Steam, '76561000000000022'],
|
||||
])
|
||||
// The identity is free again: the once-only guard counts nobody holding it now.
|
||||
await expect(
|
||||
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, '99022')
|
||||
).resolves.toBe(0)
|
||||
// The account remembers it CLAIMED (a link with roles recorded was removed), so a re-link
|
||||
// won't be a "first" one to the gift — see the test below. Plus and Studio are CLEARED —
|
||||
// dropped from the blob, not written false — and the rest of the row is untouched.
|
||||
const after = await getAccount(env.DB, 4022)
|
||||
expect(after?.hasClaimedDiscord).toBe(true)
|
||||
expect(after?.hasPlus).toBeUndefined()
|
||||
expect(after?.hasStudio).toBeUndefined()
|
||||
expect(after?.username).toBe('Leaver')
|
||||
expect(after?.email).toBe('p@example.com')
|
||||
|
||||
// Nothing left to unlink.
|
||||
const again = await unlink(token)
|
||||
expect(again.status).toBe(404)
|
||||
expect(await again.json()).toEqual({ error: 'No Discord account is linked to this account.' })
|
||||
})
|
||||
|
||||
// Removing a BARE link (a Discord sign-in's, no roles recorded) is not a claim to remember:
|
||||
// that player's first claim, on whatever account they next link, is still their first.
|
||||
it('does not remember a claim when the unlinked discord never claimed', async () => {
|
||||
const token = await tokenFor(4023)
|
||||
await updateAccount(env.DB, 4023, { email: 'p@example.com' })
|
||||
await linkPlatformIdentity(env.DB, 4023, PlatformType.Discord, '99024')
|
||||
expect((await unlink(token)).status).toBe(200)
|
||||
expect((await getAccount(env.DB, 4023))?.hasClaimedDiscord).toBeUndefined()
|
||||
|
||||
// But a claim that found NO roles is still a claim — `[]` is recorded, NULL is not — and
|
||||
// `unlinkPlatform`'s parsed rows can't tell the two apart, which is why the route asks
|
||||
// the raw column before it deletes.
|
||||
await linkPlatformIdentity(env.DB, 4023, PlatformType.Discord, '99024', [])
|
||||
expect((await unlink(token)).status).toBe(200)
|
||||
expect((await getAccount(env.DB, 4023))?.hasClaimedDiscord).toBe(true)
|
||||
})
|
||||
|
||||
// The helper itself: returns what it removed (so the caller knows what the rows carried),
|
||||
// and an account with nothing on that platform yields an empty list and no write.
|
||||
it('removes every link on one platform and returns them', async () => {
|
||||
await linkPlatformIdentity(env.DB, 4024, PlatformType.Discord, '99025', ['r1', 'r2'])
|
||||
await linkPlatformIdentity(env.DB, 4024, PlatformType.Steam, '76561000000000024')
|
||||
await expect(unlinkPlatform(env.DB, 4024, PlatformType.Oculus)).resolves.toEqual([])
|
||||
const removed = await unlinkPlatform(env.DB, 4024, PlatformType.Discord)
|
||||
expect(removed.map((l) => [l.platformId, l.roles])).toEqual([['99025', ['r1', 'r2']]])
|
||||
await expect(unlinkPlatform(env.DB, 4024, PlatformType.Discord)).resolves.toEqual([])
|
||||
expect((await getLinksForAccount(env.DB, 4024)).map((l) => l.platform)).toEqual([
|
||||
PlatformType.Steam,
|
||||
])
|
||||
})
|
||||
|
||||
// How the claim decides "first claim" now that a Discord link can predate it: a link made by
|
||||
// "Sign in with Discord" has no roles, and writing the first roles onto it is a one-shot
|
||||
// flip — the single-winner write the gift is gated on, which a fresh link gets from its
|
||||
// INSERT instead. Exercised at the helpers, since the route runs through discord.com.
|
||||
it('tells a sign-in’s bare link from a claim, and records the first roles once', async () => {
|
||||
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(false)
|
||||
await linkPlatformIdentity(env.DB, 4010, PlatformType.Discord, '99010')
|
||||
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(false)
|
||||
|
||||
// Nothing to flip on a link that isn't there.
|
||||
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99011', ['r1'])).resolves.toBe(
|
||||
false
|
||||
)
|
||||
// The first write wins; the second finds roles already recorded and writes nothing.
|
||||
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99010', ['r1'])).resolves.toBe(
|
||||
true
|
||||
)
|
||||
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99010', ['r2'])).resolves.toBe(
|
||||
false
|
||||
)
|
||||
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(true)
|
||||
const [link] = await getLinksForAccount(env.DB, 4010)
|
||||
expect(link.roles).toEqual(['r1'])
|
||||
|
||||
// A claim on a FRESH link records roles in the insert itself — an empty list included.
|
||||
await linkPlatformIdentity(env.DB, 4011, PlatformType.Discord, '99012', [])
|
||||
await expect(hasRecordedRoles(env.DB, 4011, PlatformType.Discord)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
// ---- Sign in with Discord ------------------------------------------------
|
||||
|
||||
// Closed on the same switch as the claim, and refused the same way: the half-configured
|
||||
// app the tests run with has a client id and secret and nothing else.
|
||||
it('refuses a discord sign-in while the discord app is only half configured', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/login/discord', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'whatever' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
expect(await res.json()).toEqual({ error: 'Signing in with Discord is not available here.' })
|
||||
})
|
||||
|
||||
// The two return legs are different registered redirects, both derived from the request's
|
||||
// own origin (never the body). A sign-in can't come back to `/claim`: that page wants a
|
||||
// session before it does anything, and a sign-in is how one gets made.
|
||||
it('sends a sign-in back to its own redirect, derived from the request origin', () => {
|
||||
const request = new Request('https://play.example.com/api/config')
|
||||
expect(redirectUri(request)).toBe('https://play.example.com/claim')
|
||||
expect(loginRedirectUri(request)).toBe('https://play.example.com/login/discord')
|
||||
})
|
||||
|
||||
// The once-only guard the claim is built on. Without it, one Discord member holding the
|
||||
@@ -872,6 +1093,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
|
||||
'/api/staff/players/1/username-changes',
|
||||
'/api/staff/players/1/clear-password',
|
||||
'/api/staff/players/1/clear-dorm',
|
||||
'/api/staff/rooms/1/clear-instances',
|
||||
'/api/staff/studio-access',
|
||||
'/api/staff/players/1/grant-plus',
|
||||
]
|
||||
@@ -2257,6 +2479,31 @@ it('clears a player’s dorm by publishing an empty save over the newest one', a
|
||||
])
|
||||
})
|
||||
|
||||
it('clears every instance of a room, for any staff member', async () => {
|
||||
const make = (roomId: number, photonRoomId: string) =>
|
||||
createRoomInstance(env.DB, { ownerAccountId: 8110, roomId, photonRoomId })
|
||||
const a = await make(7740, 'clear-a')
|
||||
const b = await make(7740, 'clear-b')
|
||||
const other = await make(7741, 'clear-other')
|
||||
|
||||
// A moderator, not only a developer, may clear a room.
|
||||
const res = await staffPost('/api/staff/rooms/7740/clear-instances', 8101, {})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { roomId: number; deleted: number[] }
|
||||
expect(body.roomId).toBe(7740)
|
||||
expect(body.deleted.toSorted()).toEqual([a.roomInstanceId, b.roomInstanceId].toSorted())
|
||||
expect(await getRoomInstancesByRoom(env.DB, 7740)).toEqual([])
|
||||
// Another room's instances are untouched.
|
||||
expect((await getRoomInstancesByRoom(env.DB, 7741)).map((i) => i.roomInstanceId)).toEqual([
|
||||
other.roomInstanceId,
|
||||
])
|
||||
|
||||
// Clearing an empty room succeeds and deletes nothing.
|
||||
const again = await staffPost('/api/staff/rooms/7740/clear-instances', 8101, {})
|
||||
expect(await again.json()).toEqual({ roomId: 7740, deleted: [] })
|
||||
expect((await staffPost('/api/staff/rooms/abc/clear-instances', 8101, {})).status).toBe(400)
|
||||
})
|
||||
|
||||
it('grants a player Plus, developers only', async () => {
|
||||
await updateAccount(env.DB, 8330, { username: 'Plusless' })
|
||||
const hasPlus = async () =>
|
||||
@@ -3022,7 +3269,28 @@ it('tells a signed-in player whether they can upload, and nobody else', async ()
|
||||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ granted: false })
|
||||
expect(await res.json()).toMatchObject({ granted: false, avatar: false, screenshare: false })
|
||||
|
||||
// A Discord role named in STUDIO_ROLLOUT grants the same access without the flag, and the
|
||||
// page reads it from the same helper `auth` stamps `betastudio` from, so the two agree.
|
||||
// The account has to exist: a link alone, with no account behind it, grants nothing.
|
||||
await updateAccount(env.DB, 8601, { username: 'StudioFan' })
|
||||
await linkPlatformIdentity(env.DB, 8601, PlatformType.Discord, '500000000000008601', [
|
||||
'1077000000000000031',
|
||||
])
|
||||
const original = env.STUDIO_ROLLOUT
|
||||
try {
|
||||
env.STUDIO_ROLLOUT = '1077000000000000031'
|
||||
const rolled = await SELF.fetch('https://example.com/api/studio-access', {
|
||||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||||
})
|
||||
expect(await rolled.json()).toMatchObject({ granted: true, avatar: false, screenshare: false })
|
||||
// The flag itself never moved; the staff list is still empty of this account.
|
||||
expect((await getAccount(env.DB, 8601))?.hasStudio).not.toBe(true)
|
||||
} finally {
|
||||
if (original === undefined) delete env.STUDIO_ROLLOUT
|
||||
else env.STUDIO_ROLLOUT = original
|
||||
}
|
||||
})
|
||||
|
||||
it('lets staff add, list, and remove studio upload access', async () => {
|
||||
@@ -3059,13 +3327,13 @@ it('lets staff add, list, and remove studio upload access', async () => {
|
||||
const mine = await SELF.fetch('https://example.com/api/studio-access', {
|
||||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||||
})
|
||||
expect(await mine.json()).toEqual({ granted: true })
|
||||
expect(await mine.json()).toMatchObject({ granted: true, avatar: false, screenshare: false })
|
||||
|
||||
const list = await staffGet('/api/staff/studio-access', 8110)
|
||||
expect(list.status).toBe(200)
|
||||
const body = (await list.json()) as { accounts: Array<{ accountId: number }> }
|
||||
expect(body.accounts).toEqual([
|
||||
{ accountId: 8601, username: 'StudioFan', displayName: expect.any(String) },
|
||||
{ accountId: 8601, username: 'StudioFan', displayName: expect.any(String), enabled: true },
|
||||
])
|
||||
expect((await getAccount(env.DB, 8601))?.hasStudio).toBe(true)
|
||||
|
||||
@@ -3082,7 +3350,7 @@ it('lets staff add, list, and remove studio upload access', async () => {
|
||||
const after = await SELF.fetch('https://example.com/api/studio-access', {
|
||||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||||
})
|
||||
expect(await after.json()).toEqual({ granted: false })
|
||||
expect(await after.json()).toMatchObject({ granted: false, avatar: false, screenshare: false })
|
||||
|
||||
expect(await auditRows('grant_studio_access', 8601)).toEqual([
|
||||
{
|
||||
@@ -3158,3 +3426,136 @@ it('lets staff opt themselves in and out of the sandbox pool, and only themselve
|
||||
[8120, false],
|
||||
])
|
||||
})
|
||||
|
||||
it('manages Avatar Studio flags and screen sharing independently, with staff authorization', async () => {
|
||||
await updateAccount(env.DB, 8611, { username: 'AvatarFan', hasStudio: true })
|
||||
await updateAccount(env.DB, 8612, { username: 'ScreenFan' })
|
||||
const url = '/api/staff/studio-access'
|
||||
for (const kind of ['avatar', 'screenshare']) {
|
||||
const denied = await SELF.fetch(`https://example.com${url}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${await tokenFor(8612)}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ accountId: 8612, kind }),
|
||||
})
|
||||
expect(denied.status).toBe(403)
|
||||
expect((await SELF.fetch(`https://example.com${url}?kind=${kind}`)).status).toBe(401)
|
||||
const deniedDelete = await SELF.fetch(`https://example.com${url}/8611?kind=${kind}`, {
|
||||
method: 'DELETE',
|
||||
headers: { authorization: `Bearer ${await tokenFor(8612)}` },
|
||||
})
|
||||
expect(deniedDelete.status).toBe(403)
|
||||
}
|
||||
expect((await staffPost(url, 8110, { accountId: 8611, kind: 'unknown' })).status).toBe(400)
|
||||
expect((await staffGet(`${url}?kind=unknown`, 8110)).status).toBe(400)
|
||||
expect((await staffDelete(`${url}/8611?kind=unknown`, 8110)).status).toBe(400)
|
||||
expect((await staffPost(url, 8110, { accountId: 99999999, kind: 'avatar' })).status).toBe(404)
|
||||
await env.DB.prepare('INSERT INTO avatar_studio_access VALUES (?, ?, ?, ?)')
|
||||
.bind(8611, 9, 'before', 'before')
|
||||
.run()
|
||||
expect((await staffPost(url, 8110, { username: '@AvatarFan', kind: 'avatar' })).status).toBe(200)
|
||||
expect(await avatarStudioFlags(env.DB, 8611)).toBe(15)
|
||||
const repeated = await staffPost(url, 8110, { accountId: 8611, kind: 'avatar' })
|
||||
expect(await repeated.json()).toMatchObject({ alreadyGranted: true })
|
||||
const avatarList = await staffGet(`${url}?kind=avatar`, 8110)
|
||||
expect(await avatarList.json()).toMatchObject({ accounts: [{ accountId: 8611, enabled: true }] })
|
||||
expect((await getAccount(env.DB, 8611))?.hasScreenshare).toBeUndefined()
|
||||
await staffDelete(`${url}/8611?kind=avatar`, 8110)
|
||||
expect(await avatarStudioFlags(env.DB, 8611)).toBe(8)
|
||||
expect((await getAccount(env.DB, 8611))?.hasStudio).toBe(true)
|
||||
expect((await staffPost(url, 8110, { username: 'ScreenFan', kind: 'screenshare' })).status).toBe(
|
||||
200
|
||||
)
|
||||
expect((await getAccount(env.DB, 8612))?.hasScreenshare).toBe(true)
|
||||
const screenList = await staffGet(`${url}?kind=screenshare`, 8110)
|
||||
expect(await screenList.json()).toMatchObject({ accounts: [{ accountId: 8612, enabled: true }] })
|
||||
const status = await SELF.fetch('https://example.com/api/studio-access', {
|
||||
headers: { authorization: `Bearer ${await tokenFor(8612)}` },
|
||||
})
|
||||
expect(await status.json()).toMatchObject({ granted: false, avatar: false, screenshare: true })
|
||||
await staffDelete(`${url}/8612?kind=screenshare`, 8110)
|
||||
expect((await getAccount(env.DB, 8612))?.hasScreenshare).toBe(false)
|
||||
expect(await (await staffGet(`${url}?kind=screenshare`, 8110)).json()).toEqual({ accounts: [] })
|
||||
expect(await auditRows('grant_avatar_access', 8611)).toHaveLength(2)
|
||||
expect(await auditRows('revoke_screenshare_access', 8612)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('grants and revokes each Avatar Studio feature without changing other permissions', async () => {
|
||||
const features = {
|
||||
'avatar-items': 1,
|
||||
'avatar-costumes': 2,
|
||||
'avatar-badges': 4,
|
||||
'avatar-consumables': 8,
|
||||
}
|
||||
const url = '/api/staff/studio-access'
|
||||
await updateAccount(env.DB, 8621, {
|
||||
username: 'FeatureFan',
|
||||
hasStudio: true,
|
||||
hasScreenshare: false,
|
||||
})
|
||||
await env.DB.prepare('INSERT INTO avatar_studio_access VALUES (?, ?, ?, ?)')
|
||||
.bind(8621, 16, 'original', 'original')
|
||||
.run()
|
||||
let expected = 16
|
||||
const status = async () => {
|
||||
const response = await SELF.fetch('https://example.com/api/studio-access', {
|
||||
headers: { authorization: `Bearer ${await tokenFor(8621)}` },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
return response.json() as Promise<Record<string, boolean>>
|
||||
}
|
||||
for (const [kind, mask] of Object.entries(features)) {
|
||||
expect((await status())[kind]).toBe(false)
|
||||
const denied = await SELF.fetch(`https://example.com${url}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${await tokenFor(8621)}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ accountId: 8621, kind }),
|
||||
})
|
||||
expect(denied.status).toBe(403)
|
||||
const deniedDelete = await SELF.fetch(`https://example.com${url}/8621?kind=${kind}`, {
|
||||
method: 'DELETE',
|
||||
headers: { authorization: `Bearer ${await tokenFor(8621)}` },
|
||||
})
|
||||
expect(deniedDelete.status).toBe(403)
|
||||
expect((await staffPost(url, 8110, { username: '@FeatureFan', kind })).status).toBe(200)
|
||||
expected |= mask
|
||||
expect(await avatarStudioFlags(env.DB, 8621)).toBe(expected)
|
||||
const repeated = await staffPost(url, 8110, { accountId: 8621, kind })
|
||||
expect(await repeated.json()).toMatchObject({ alreadyGranted: true })
|
||||
expect((await status())[kind]).toBe(true)
|
||||
const list = await staffGet(`${url}?kind=${kind}`, 8110)
|
||||
expect(await list.json()).toMatchObject({
|
||||
accounts: expect.arrayContaining([
|
||||
expect.objectContaining({ accountId: 8621, enabled: true }),
|
||||
]),
|
||||
})
|
||||
}
|
||||
for (const [kind, mask] of Object.entries(features)) {
|
||||
expect((await staffDelete(`${url}/8621?kind=${kind}`, 8110)).status).toBe(200)
|
||||
expected &= ~mask
|
||||
expect(await avatarStudioFlags(env.DB, 8621)).toBe(expected)
|
||||
expect((await status())[kind]).toBe(false)
|
||||
for (const [otherKind, otherMask] of Object.entries(features)) {
|
||||
expect((await status())[otherKind]).toBe((expected & otherMask) !== 0)
|
||||
}
|
||||
const list = await staffGet(`${url}?kind=${kind}`, 8110)
|
||||
const remaining = (await list.json()) as { accounts: Array<{ accountId: number }> }
|
||||
expect(remaining.accounts.some((a) => a.accountId === 8621)).toBe(false)
|
||||
const repeat = await staffDelete(`${url}/8621?kind=${kind}`, 8110)
|
||||
expect(await repeat.json()).toMatchObject({ removed: false })
|
||||
expect(await auditRows(`grant_${kind}_access`, 8621)).toHaveLength(2)
|
||||
expect(await auditRows(`revoke_${kind}_access`, 8621)).toHaveLength(2)
|
||||
}
|
||||
expect((await getAccount(env.DB, 8621))?.hasStudio).toBe(true)
|
||||
expect((await getAccount(env.DB, 8621))?.hasScreenshare).toBe(false)
|
||||
expect(
|
||||
await env.DB.prepare('SELECT created_at FROM avatar_studio_access WHERE account_id=8621').first(
|
||||
'created_at'
|
||||
)
|
||||
).toBe('original')
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PlatformType } from '@repo/domain/src/enums'
|
||||
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
||||
import { getStatSeries } from '@repo/domain/src/stats-db'
|
||||
import { intVar, logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import { generateIdentityAssertion, validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
|
||||
// Discord identity is stored there as a PlatformType.Discord link — it is an account ↔
|
||||
@@ -17,8 +17,11 @@ import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import {
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForAccount,
|
||||
hasRecordedRoles,
|
||||
isPlatformIdentityLinked,
|
||||
linkPlatformIdentity,
|
||||
recordFirstRoles,
|
||||
unlinkPlatform,
|
||||
} from '../../auth/src/platform-db'
|
||||
// The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is
|
||||
// econ's cron paid on demand: the first time an account links its Discord, the claim hands
|
||||
@@ -31,6 +34,9 @@ import {
|
||||
discordConfig,
|
||||
exchangeCode,
|
||||
fetchGuildMembership,
|
||||
fetchUser,
|
||||
LOGIN_SCOPES,
|
||||
loginRedirectUri,
|
||||
qualifies,
|
||||
redirectUri,
|
||||
revokeToken,
|
||||
@@ -45,6 +51,7 @@ import {
|
||||
bansInForceHandler,
|
||||
clearDormHandler,
|
||||
clearPasswordHandler,
|
||||
clearRoomInstancesHandler,
|
||||
createReportHandler,
|
||||
getReportHandler,
|
||||
giftItemHandler,
|
||||
@@ -132,14 +139,27 @@ const claimant = async (c: Context<App>): Promise<number | null> =>
|
||||
* (see App.tsx). Everything else about the request is fixed by the server.
|
||||
*/
|
||||
function authorizeUrl(request: Request, clientId: string): string {
|
||||
return consentUrl(clientId, SCOPES, redirectUri(request))
|
||||
}
|
||||
|
||||
/**
|
||||
* The consent URL for "Sign in with Discord": the same app, the narrower `identify` scope,
|
||||
* and the sign-in's own return leg (`/login/discord`). Served beside the claim's URL so the
|
||||
* SPA never assembles either.
|
||||
*/
|
||||
function loginAuthorizeUrl(request: Request, clientId: string): string {
|
||||
return consentUrl(clientId, LOGIN_SCOPES, loginRedirectUri(request))
|
||||
}
|
||||
|
||||
function consentUrl(clientId: string, scope: string, redirect: string): string {
|
||||
const url = new URL(AUTHORIZE_URL)
|
||||
url.search = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: 'code',
|
||||
scope: SCOPES,
|
||||
redirect_uri: redirectUri(request),
|
||||
scope,
|
||||
redirect_uri: redirect,
|
||||
// Skip Discord's "you've already authorized this app, continue?" interstitial on a
|
||||
// repeat claim; the player has already pressed a button that says what this does.
|
||||
// repeat visit; the player has already pressed a button that says what this does.
|
||||
prompt: 'none',
|
||||
}).toString()
|
||||
return url.toString()
|
||||
@@ -195,6 +215,10 @@ const app = new Hono<App>()
|
||||
// ids never leave the worker, since it's the worker that asks Discord the question.
|
||||
benefitsEnabled: discord !== null,
|
||||
discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : null,
|
||||
// "Sign in with Discord", on the SAME switch: the sign-in resolves (or makes) the
|
||||
// account through the Discord link, and only the claim ever writes one with roles, so
|
||||
// an operator who hasn't set the claim up has no reason to offer the door either.
|
||||
discordLoginUrl: discord ? loginAuthorizeUrl(c.req.raw, discord.clientId) : null,
|
||||
hosts: {
|
||||
auth: authBase(c.env),
|
||||
accounts: accountsBase(c.env),
|
||||
@@ -344,6 +368,90 @@ const app = new Hono<App>()
|
||||
return c.json(token)
|
||||
})
|
||||
|
||||
// ---- Sign in with Discord -----------------------------------------------
|
||||
|
||||
/**
|
||||
* Redeem a Discord authorization code from the SIGN-IN consent (`/login/discord`), prove
|
||||
* who the Discord user is, and ask `auth` for a session on the account linked to them —
|
||||
* or, when there is none, for a brand-new account linked to them.
|
||||
*
|
||||
* The browser sends ONLY the code, as it does for the claim, and for the same reason: the
|
||||
* exchange needs the client secret. What is different is what comes after. The claim acts
|
||||
* on a session the caller already has; this is how one GETS a session, so there is no
|
||||
* bearer token and nothing here writes to the account. Instead the proven identity is
|
||||
* signed into an ASSERTION with the shared JWT_SECRET and posted to `auth`'s
|
||||
* `identity_assertion` grant, which resolves it through the link table exactly as
|
||||
* `cached_login` resolves a Steam ticket, and answers the ordinary token body. Resolving
|
||||
* the account — and minting one — stays `auth`'s: www never picks an account id.
|
||||
*
|
||||
* Posted over the service binding with the browser's real IP forwarded, like signup, so
|
||||
* the per-IP cap on the sign-up path counts the browser and not Cloudflare. The token
|
||||
* response is relayed verbatim; `created: true` on it is the SPA's cue to say an account
|
||||
* was just made and that setting a password is what makes it usable in the game.
|
||||
*/
|
||||
.post('/api/login/discord', async (c) => {
|
||||
const config = await discordConfig(c.env)
|
||||
if (!config) return c.json({ error: 'Signing in with Discord is not available here.' }, 403)
|
||||
|
||||
type LoginBody = { code?: string }
|
||||
const { code } = await c.req.json<LoginBody>().catch(() => ({}) as LoginBody)
|
||||
if (!code) return c.json({ error: 'No Discord authorization code was provided.' }, 400)
|
||||
|
||||
const accessToken = await exchangeCode(config, code, loginRedirectUri(c.req.raw))
|
||||
if (accessToken === null) {
|
||||
return c.json(
|
||||
{ error: 'That Discord sign-in could not be completed. Please try again.' },
|
||||
400
|
||||
)
|
||||
}
|
||||
const user = await fetchUser(accessToken)
|
||||
await revokeToken(config, accessToken)
|
||||
if (user === null) {
|
||||
return c.json(
|
||||
{ error: 'That Discord sign-in could not be completed. Please try again.' },
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const jwtSecret = await c.env.JWT_SECRET.get().catch(() => '')
|
||||
if (jwtSecret === '') {
|
||||
logger.error('cannot sign an identity assertion: JWT_SECRET is unreadable')
|
||||
return c.json({ error: authUnreachable('login') }, 502)
|
||||
}
|
||||
const assertion = await generateIdentityAssertion(
|
||||
{ platform: PlatformType.Discord, platformId: user.id },
|
||||
jwtSecret
|
||||
)
|
||||
const res = await postAuthForm(
|
||||
c.env,
|
||||
'/connect/token',
|
||||
{ grant_type: 'identity_assertion', assertion },
|
||||
{ clientIp: c.req.header('cf-connecting-ip') }
|
||||
).catch(() => null)
|
||||
if (res === null) {
|
||||
logger.error('could not reach auth to redeem an identity assertion')
|
||||
return c.json({ error: authUnreachable('login') }, 502)
|
||||
}
|
||||
if (!res.ok) {
|
||||
const failure = await readAuthError(res, 'login')
|
||||
logger.info('auth refused a discord sign-in', {
|
||||
status: res.status,
|
||||
upstream: failure.upstream,
|
||||
})
|
||||
return c.json({ error: failure.message }, failure.status)
|
||||
}
|
||||
const token = (await res.json().catch(() => null)) as {
|
||||
access_token?: string
|
||||
created?: boolean
|
||||
} | null
|
||||
if (!token?.access_token) {
|
||||
logger.error('auth answered a discord sign-in with no access_token')
|
||||
return c.json({ error: authUnreachable('login') }, 502)
|
||||
}
|
||||
logger.info(token.created ? 'created an account from a discord sign-in' : 'discord sign-in')
|
||||
return c.json({ ...token, discordUsername: user.username })
|
||||
})
|
||||
|
||||
// ---- Discord benefits claim ---------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -358,16 +466,106 @@ const app = new Hono<App>()
|
||||
.get('/api/benefits/status', async (c) => {
|
||||
const accountId = await claimant(c)
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
const [account, links] = await Promise.all([
|
||||
const [account, claimed, links] = await Promise.all([
|
||||
getAccount(c.env.DB, accountId),
|
||||
hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord),
|
||||
getLinksForAccount(c.env.DB, accountId),
|
||||
])
|
||||
return c.json({
|
||||
hasPlus: account?.hasPlus ?? false,
|
||||
// Whether this account is already tied to a Discord identity — not WHICH one. The
|
||||
// player knows their own Discord; the id is of no use to the page and every reason
|
||||
// to keep an account's linked identities off the wire.
|
||||
linked: links.some((link) => link.platform === PlatformType.Discord),
|
||||
// Whether this account has CLAIMED through a Discord identity — a link with roles
|
||||
// recorded — not WHICH one. The player knows their own Discord; the id is of no use to
|
||||
// the page and every reason to keep an account's linked identities off the wire. A
|
||||
// bare link, which "Sign in with Discord" leaves on a new account, reads as not yet
|
||||
// claimed: the page then offers the claim, which is what that player still has to do.
|
||||
linked: claimed,
|
||||
// Whether ANY Discord identity is on the account, claimed or bare — what the unlink
|
||||
// button keys on, since a sign-in's bare link is as much a link to take off as a
|
||||
// claim's. Still never WHICH one.
|
||||
discordLinked: links.some((link) => link.platform === PlatformType.Discord),
|
||||
// The unlink's one precondition (see `/api/benefits/unlink`), served beside the link
|
||||
// so the page can say so before the button fails. The self DTO carries the address
|
||||
// too; this is the server's own reading of the same row the unlink will decide from.
|
||||
hasEmail: !!account?.email,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Take the account's Discord off it: every `PlatformType.Discord` link goes, whether a
|
||||
* claim wrote it or "Sign in with Discord" did. Afterwards that Discord opens no account —
|
||||
* signing in with it makes a NEW one — and the benefits claim's once-only guard lets it
|
||||
* claim on another account.
|
||||
*
|
||||
* Refused unless the account has an EMAIL ADDRESS. The link may be the only way into the
|
||||
* account: an account made by "Sign in with Discord" has no password and no email until
|
||||
* the player sets them, and removing its link would leave nothing to recover the account
|
||||
* by. An address is enough — it is what the account can be recovered through — so a
|
||||
* password is NOT required: a player with an email and no password sets one in game, as
|
||||
* any passwordless account does. Checked here, on the row, and not trusted from the page
|
||||
* (which shows the same flag from `/api/benefits/status` so it can say so before anyone
|
||||
* presses anything).
|
||||
*
|
||||
* The grants the link carried go with it. `hasPlus` and `hasStudio` are both cleared:
|
||||
* the Discord roles are what the claim granted Plus for and what the Studio rollout reads,
|
||||
* and a player taking the link off is giving that proof back — unlike a role that merely
|
||||
* lapses, which the claim deliberately never revokes for. This clears an operator's
|
||||
* grant too (`runx admin grant-plus`/`grant-studio` write the same flags, and nothing on
|
||||
* the row says which wrote them); the page warns, and staff can re-grant. Both reach the
|
||||
* game on the NEXT token, like every account flag.
|
||||
*
|
||||
* What it does NOT do is forget that the account has claimed. A link with roles recorded
|
||||
* was a claim, and the first-link supporter gift keys on "never claimed before", read from
|
||||
* the link table — which would read as never-linked once the row is gone. So a claimed
|
||||
* link's removal stamps `hasClaimedDiscord` on the account, and the claim consults both;
|
||||
* otherwise link → unlink → link would pay the one-time box on every cycle.
|
||||
*
|
||||
* Not gated on the Discord configuration: the link is in the table whether or not the
|
||||
* operator still runs the claim, and a player can always take it off. Answers the same
|
||||
* shape `/api/benefits/status` does, as the account now stands.
|
||||
*/
|
||||
.post('/api/benefits/unlink', async (c) => {
|
||||
const accountId = await claimant(c)
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
|
||||
const account = await getAccount(c.env.DB, accountId)
|
||||
if (!account?.email) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Add an email address to your account before unlinking Discord, so the account can be recovered without it.',
|
||||
},
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
// Whether what's being removed includes a CLAIM — a link with `role` recorded, even as
|
||||
// `[]`. Asked of the raw column BEFORE the delete: the parsed `PlatformLink` reads a
|
||||
// NULL role and an empty list both as `[]`, and only the column tells a sign-in's bare
|
||||
// link from a claim that found no roles.
|
||||
const claimed = await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord)
|
||||
const removed = await unlinkPlatform(c.env.DB, accountId, PlatformType.Discord)
|
||||
if (removed.length === 0) {
|
||||
return c.json({ error: 'No Discord account is linked to this account.' }, 404)
|
||||
}
|
||||
// One write: remember the claim (when there was one), and take back what the link
|
||||
// carried. `undefined` drops the key from the blob, which is how "no Plus" is spelled.
|
||||
await updateAccount(c.env.DB, accountId, {
|
||||
hasClaimedDiscord: claimed || account.hasClaimedDiscord === true ? true : undefined,
|
||||
hasPlus: undefined,
|
||||
hasStudio: undefined,
|
||||
})
|
||||
logger.info('unlinked a discord from an account', {
|
||||
accountId,
|
||||
links: removed.length,
|
||||
claimed,
|
||||
hadPlus: account.hasPlus === true,
|
||||
hadStudio: account.hasStudio === true,
|
||||
})
|
||||
return c.json({
|
||||
hasPlus: false,
|
||||
linked: false,
|
||||
discordLinked: false,
|
||||
hasEmail: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -399,7 +597,8 @@ const app = new Hono<App>()
|
||||
* Nothing here ever REVOKES Plus: losing the Discord role later leaves the flag set.
|
||||
* That's deliberate for now — a sweep would need a bot token to enumerate the guild,
|
||||
* which this design specifically avoids — but it does mean the flag records "held the
|
||||
* role once", not "holds it today".
|
||||
* role once", not "holds it today". The player's own unlink (`/api/benefits/unlink`) is
|
||||
* the one thing that takes it back.
|
||||
*
|
||||
* The FIRST time an account links a Discord, the claim also pays the supporter gift
|
||||
* econ's cron would (`DISCORD_ROLE_TOKENS`, the amount for the best of the roles just
|
||||
@@ -456,12 +655,6 @@ const app = new Hono<App>()
|
||||
PlatformType.Discord,
|
||||
membership.userId
|
||||
)
|
||||
// Whether this claim is the account's FIRST Discord link — what the one-time gift below
|
||||
// keys on. Decided before the write, from the table: an account re-claiming with the
|
||||
// same Discord is not first (`alreadyMine`), and neither is one that already carries a
|
||||
// DIFFERENT Discord identity, which the once-only guard doesn't stop. Only asked when
|
||||
// it could be true, to spare the re-claim a query.
|
||||
let firstLink = false
|
||||
if (!alreadyMine) {
|
||||
const claimedElsewhere = await countAccountsForPlatformIdentity(
|
||||
c.env.DB,
|
||||
@@ -477,9 +670,32 @@ const app = new Hono<App>()
|
||||
409
|
||||
)
|
||||
}
|
||||
const links = await getLinksForAccount(c.env.DB, accountId)
|
||||
firstLink = !links.some((link) => link.platform === PlatformType.Discord)
|
||||
}
|
||||
// Whether this is the account's FIRST CLAIM — what the one-time gift below keys on.
|
||||
// Decided before the write, from the table: a claim is recorded as roles on a Discord
|
||||
// link, so an account with any Discord link carrying roles (this identity's, or a
|
||||
// DIFFERENT one the once-only guard doesn't stop) has claimed before. A link with NO
|
||||
// roles has not: that is what "Sign in with Discord" leaves on an account it just made,
|
||||
// and that player's first claim is still their first.
|
||||
//
|
||||
// AND from the account: an unlink removes a claimed link's row, and stamps
|
||||
// `hasClaimedDiscord` on the account so the table reading as never-linked afterwards
|
||||
// doesn't make the next link a "first" one. Both are asked; either says not-first.
|
||||
const firstClaim =
|
||||
(await getAccount(c.env.DB, accountId))?.hasClaimedDiscord !== true &&
|
||||
!(await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord))
|
||||
// On a link that already exists without roles, writing them is the claim's one-shot
|
||||
// write — the single-winner guard a fresh link gets from its INSERT below. Asked only
|
||||
// when the link is the caller's: on anyone else's it would be refused above.
|
||||
const recordedFirst =
|
||||
alreadyMine &&
|
||||
(await recordFirstRoles(
|
||||
c.env.DB,
|
||||
accountId,
|
||||
PlatformType.Discord,
|
||||
membership.userId,
|
||||
membership.roles
|
||||
))
|
||||
|
||||
// The link is written for EVERY guild member, qualifying role or not, and it is
|
||||
// idempotent: INSERT OR IGNORE (so `linkedAt` keeps the FIRST claim's time), with the
|
||||
@@ -519,13 +735,14 @@ const app = new Hono<App>()
|
||||
}
|
||||
|
||||
// The one-time supporter gift: the box econ's cron would hand this member, paid now,
|
||||
// once. Gated on the insert having happened too (`created`), so two claims racing on
|
||||
// once. Gated on a single-winner write having happened too — the insert (`created`), or
|
||||
// the first roles onto a sign-in's bare link (`recordedFirst`) — so two claims racing on
|
||||
// one fresh account can't both pay. Best-effort AFTER Plus is granted and the link is
|
||||
// written: a grant that fails partway is logged with what to check, as econ's cron
|
||||
// logs its own, and the claim still answers — the player got what they came for, and
|
||||
// a retry that also failed partway is how a balance gets credited twice.
|
||||
let tokensAwarded: number | null = null
|
||||
if (created && firstLink) {
|
||||
if ((created || recordedFirst) && firstClaim) {
|
||||
try {
|
||||
const role = await grantDiscordRoleGift(
|
||||
c.env,
|
||||
@@ -597,6 +814,8 @@ const app = new Hono<App>()
|
||||
.post('/api/staff/players/:id/gift-xp', requireDeveloper, giftXpHandler)
|
||||
// Everyone standing in a room, across all of its instances.
|
||||
.post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler)
|
||||
// Delete every live instance of a room — for one whose sessions are stuck. All staff.
|
||||
.post('/api/staff/rooms/:roomId/clear-instances', clearRoomInstancesHandler)
|
||||
// The account page's token drop: the same gift to everyone online at once.
|
||||
.post('/api/staff/online/gift-tokens', requireDeveloper, giftOnlineTokensHandler)
|
||||
// Its sibling: the same gift to every account whose Discord link holds a role, offline
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* it from `@repo/domain` (each uses the subset it needs).
|
||||
*/
|
||||
|
||||
import { AVATAR_STUDIO_ACCESS } from './avatar-studio'
|
||||
import { bindPlaceholders, chunkForBinds } from './d1-binds'
|
||||
|
||||
/**
|
||||
@@ -168,7 +169,10 @@ export interface Account {
|
||||
*
|
||||
* So never read a Discord link as a precondition for Plus, and never revoke one because
|
||||
* the other is missing: a manually granted account has `hasPlus` and no link at all, and
|
||||
* that is a normal, supported state.
|
||||
* that is a normal, supported state. The one thing that clears it besides an operator is
|
||||
* the player UNLINKING their Discord (`www` `POST /api/benefits/unlink`), which clears
|
||||
* this and `hasStudio` together — the player is handing back the proof — and does so
|
||||
* whoever set them, since nothing on the row says which.
|
||||
*
|
||||
* Nothing reads this per request. `auth` stamps it into every token it mints as the
|
||||
* `rn.plus` claim, and `econ` decides the CampusCard and the subscriber discount from
|
||||
@@ -177,6 +181,21 @@ export interface Account {
|
||||
* real: the website's claim page warns about it, and so does `grant-plus`.
|
||||
*/
|
||||
hasPlus?: boolean
|
||||
/**
|
||||
* Whether this account has EVER claimed its Discord benefits — had roles recorded on a
|
||||
* `PlatformType.Discord` link by `www`'s `POST /api/benefits/claim` — even if that link is
|
||||
* gone. Set only by the website's Discord UNLINK, when the link it removes carries roles:
|
||||
* while the link exists the link table itself says so (`hasRecordedRoles`), and a link
|
||||
* that never claimed has nothing to remember.
|
||||
*
|
||||
* What it protects is the first-link supporter gift. The claim pays that box the FIRST
|
||||
* time an account links a Discord, deciding "first" from the link table — which, after an
|
||||
* unlink, reads as if the account had never linked. Without this flag, link → unlink →
|
||||
* link would pay the one-time gift on every cycle. Nothing else reads it: `hasPlus` stands
|
||||
* on its own (above), and the once-only-per-Discord-user guard is the link table's.
|
||||
* Absent/false means no claim remembered.
|
||||
*/
|
||||
hasClaimedDiscord?: boolean
|
||||
/**
|
||||
* Whether this account may upload from Rec Room Studio. The editor decides Full access
|
||||
* by reading the token for a `role` claim of exactly `betastudio`, which `auth` stamps
|
||||
@@ -188,6 +207,8 @@ export interface Account {
|
||||
* open keeps its old access until it signs in again or refreshes.
|
||||
*/
|
||||
hasStudio?: boolean
|
||||
/** Staff override for screen sharing. Absent means screen sharing is disabled. */
|
||||
hasScreenshare?: boolean
|
||||
}
|
||||
|
||||
interface AccountRow {
|
||||
@@ -290,12 +311,29 @@ export async function getAccountByUsername(
|
||||
export const SEARCH_LIMIT = 20
|
||||
|
||||
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
|
||||
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
|
||||
export const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
|
||||
|
||||
/**
|
||||
* The smallest string that sorts after every string beginning with `prefix` under
|
||||
* SQLite's default BINARY (byte-wise) collation: the prefix followed by U+10FFFF, whose
|
||||
* UTF-8 encoding (F4 8F BF BF) is the highest valid sequence. The pair
|
||||
* `>= prefix AND < prefixUpperBound(prefix)` is the prefix match as a range.
|
||||
*/
|
||||
export const prefixUpperBound = (prefix: string): string => `${prefix}\u{10FFFF}`
|
||||
|
||||
/**
|
||||
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered
|
||||
* alphabetically. Backed by the indexed `username_lower` generated column, so the
|
||||
* `name%` LIKE stays index-friendly. Returns up to `limit` matches.
|
||||
* alphabetically. Returns up to `limit` matches.
|
||||
*
|
||||
* The prefix is applied as a RANGE on the indexed `username_lower` generated column
|
||||
* (`>= name AND < name+U+10FFFF`), not as `LIKE 'name%'`. SQLite only turns a LIKE into
|
||||
* an index seek when the LIKE is case-sensitive or the index is NOCASE, and never when
|
||||
* it carries an ESCAPE clause, so the LIKE form was a full index scan on every call —
|
||||
* 48k rows read per keystroke in the site's search bar, tens of millions a day on D1's
|
||||
* meter. The range is a seek (`SEARCH … (username_lower>? AND username_lower<?)`) and
|
||||
* walks the index in `ORDER BY` order, so the LIMIT stops it after `limit` rows. The
|
||||
* LIKE stays as a residual filter on those rows so the match is exactly "begins with",
|
||||
* with the user's `%`/`_` taken literally.
|
||||
*/
|
||||
export async function searchAccounts(
|
||||
db: D1Database,
|
||||
@@ -306,9 +344,11 @@ export async function searchAccounts(
|
||||
if (q === '') return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM account WHERE username_lower LIKE ?1 ESCAPE '\\' ORDER BY username_lower LIMIT ?2`
|
||||
`SELECT data FROM account
|
||||
WHERE username_lower >= ?1 AND username_lower < ?2 AND username_lower LIKE ?3 ESCAPE '\\'
|
||||
ORDER BY username_lower LIMIT ?4`
|
||||
)
|
||||
.bind(`${escapeLike(q)}%`, limit)
|
||||
.bind(q, prefixUpperBound(q), `${escapeLike(q)}%`, limit)
|
||||
.all<AccountRow>()
|
||||
return parseAll(results)
|
||||
}
|
||||
@@ -463,10 +503,23 @@ export async function listInfluencerIds(db: D1Database): Promise<number[]> {
|
||||
* Every account flagged `hasStudio`, lowest id first — the website's staff list. A JSON
|
||||
* scan rather than a generated column: the list is short and only staff read it.
|
||||
*/
|
||||
export async function listStudioAccounts(db: D1Database): Promise<Account[]> {
|
||||
export async function listStudioAccounts(
|
||||
db: D1Database,
|
||||
kind: 'studio' | 'avatar' | 'screenshare' | keyof typeof AVATAR_STUDIO_ACCESS = 'studio'
|
||||
): Promise<Account[]> {
|
||||
const mask =
|
||||
kind === 'avatar'
|
||||
? 7
|
||||
: Object.hasOwn(AVATAR_STUDIO_ACCESS, kind)
|
||||
? AVATAR_STUDIO_ACCESS[kind as keyof typeof AVATAR_STUDIO_ACCESS]
|
||||
: 0
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM account WHERE json_extract(data, '$.hasStudio') = 1 ORDER BY account_id`
|
||||
mask !== 0
|
||||
? `SELECT data FROM account WHERE account_id IN (SELECT account_id FROM avatar_studio_access WHERE (flags & ${mask}) != 0) ORDER BY account_id`
|
||||
: kind === 'screenshare'
|
||||
? `SELECT data FROM account WHERE json_extract(data, '$.hasScreenshare') = 1 ORDER BY account_id`
|
||||
: `SELECT data FROM account WHERE json_extract(data, '$.hasStudio') = 1 ORDER BY account_id`
|
||||
)
|
||||
.all<AccountRow>()
|
||||
return parseAll(results)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/** Shared persisted Studio authoring state. Existing storefront items remain unchanged. */
|
||||
export const AVATAR_STUDIO_SCHEMA = [
|
||||
`CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id)`,
|
||||
`CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL)`,
|
||||
]
|
||||
|
||||
export interface AvatarStudioItem {
|
||||
CustomAvatarItemId: string
|
||||
CreatorAccountId: number
|
||||
OutfitType: number
|
||||
Accessibility: number
|
||||
CurrentSaves: Array<Record<string, unknown>>
|
||||
[key: string]: unknown
|
||||
}
|
||||
export interface AvatarStudioSave {
|
||||
id: number
|
||||
item_id: string
|
||||
body_type: number
|
||||
unity_asset_id: string
|
||||
source_filename: string
|
||||
source_hash: string
|
||||
thumbnail: string
|
||||
moderation_image: string | null
|
||||
configuration: string
|
||||
description: string | null
|
||||
update_current: number
|
||||
created_at: string
|
||||
}
|
||||
/** Independent authoring permissions reported by Studio's access override. */
|
||||
export const AVATAR_STUDIO_ACCESS = {
|
||||
'avatar-items': 1,
|
||||
'avatar-costumes': 2,
|
||||
'avatar-badges': 4,
|
||||
'avatar-consumables': 8,
|
||||
} as const
|
||||
|
||||
export async function avatarStudioFlags(db: D1Database, accountId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT flags FROM avatar_studio_access WHERE account_id=?')
|
||||
.bind(accountId)
|
||||
.first<{ flags: number }>()
|
||||
return row?.flags ?? 0
|
||||
}
|
||||
export const AVATAR_STUDIO_SLOTS = [
|
||||
0, 2, 3, 10, 20, 100, 101, 102, 103, 104, 200, 203, 300, 301, 500, 501, 502, 503, 504, 505, 506,
|
||||
507, 508, 509, 510, 511, 512, 1000,
|
||||
]
|
||||
export function avatarItemAccessBit(outfitType: number): number {
|
||||
return outfitType === 1000 ? 4 : outfitType >= 500 && outfitType <= 512 ? 2 : 1
|
||||
}
|
||||
export async function avatarStudioItem(
|
||||
db: D1Database,
|
||||
id: string
|
||||
): Promise<AvatarStudioItem | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
|
||||
.bind(id)
|
||||
.first<{ data: string }>()
|
||||
return row ? (JSON.parse(row.data) as AvatarStudioItem) : null
|
||||
}
|
||||
export async function avatarStudioSave(
|
||||
db: D1Database,
|
||||
itemId: string,
|
||||
id: number
|
||||
): Promise<AvatarStudioSave | null> {
|
||||
return db
|
||||
.prepare('SELECT * FROM avatar_studio_save WHERE item_id=? AND id=?')
|
||||
.bind(itemId, id)
|
||||
.first<AvatarStudioSave>()
|
||||
}
|
||||
export async function avatarSaveDto(db: D1Database, row: AvatarStudioSave, target = 0) {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
'SELECT unity_asset_id AS UnityAssetId,target AS Target,version AS Version,filename AS Filename,hash AS Hash,kind FROM unity_asset WHERE unity_asset_id=?'
|
||||
)
|
||||
.bind(row.unity_asset_id)
|
||||
.all<{
|
||||
UnityAssetId: string
|
||||
Target: number
|
||||
Version: number
|
||||
Filename: string
|
||||
Hash: string
|
||||
kind: string
|
||||
}>()
|
||||
const main = results.find((a) => a.Target === target && a.kind === 'main')
|
||||
const material = results.find((a) => a.Target === target && a.kind === 'material')
|
||||
return {
|
||||
CustomAvatarItemSaveId: row.id,
|
||||
CustomAvatarItemId: row.item_id,
|
||||
UnityAssetId: row.unity_asset_id,
|
||||
BodyType: row.body_type,
|
||||
CreatedAt: row.created_at,
|
||||
ModifiedAt: row.created_at,
|
||||
Description: row.description,
|
||||
ThumbnailFileName: row.thumbnail,
|
||||
AdditionalConfiguration: row.configuration,
|
||||
SourceUnityAssetFilename: row.source_filename,
|
||||
SourceUnityAssetHash: row.source_hash,
|
||||
ModerationImageFileName: row.moderation_image,
|
||||
UnityAsset: main?.Filename ?? null,
|
||||
UnityAssetHash: main?.Hash ?? null,
|
||||
UnityAsset2: material?.Filename ?? null,
|
||||
UnityAsset2Hash: material?.Hash ?? null,
|
||||
BakedAssets: results.filter((a) => a.kind !== 'stripped').map(({ kind: _kind, ...a }) => a),
|
||||
}
|
||||
}
|
||||
/** Publish exactly this built revision for its body type, preserving other body types. */
|
||||
export async function publishAvatarSave(
|
||||
db: D1Database,
|
||||
itemId: string,
|
||||
saveId: number,
|
||||
automatic = false,
|
||||
attempt = 0
|
||||
): Promise<boolean> {
|
||||
const item = await avatarStudioItem(db, itemId)
|
||||
const save = await avatarStudioSave(db, itemId, saveId)
|
||||
if (!item || !save) return false
|
||||
const dto = await avatarSaveDto(db, save)
|
||||
// Badge images are uploaded and published directly by the SDK, without a Unity build.
|
||||
if (!dto.UnityAsset && item.OutfitType !== 1000) return false
|
||||
const quest = await avatarSaveDto(db, save, 2)
|
||||
const {
|
||||
SourceUnityAssetFilename: _source,
|
||||
SourceUnityAssetHash: _hash,
|
||||
BakedAssets: _baked,
|
||||
...published
|
||||
} = dto
|
||||
// JSON mutation is conditional on the complete prior snapshot: concurrent body-type
|
||||
// publications cannot overwrite each other or resurrect a deleted item.
|
||||
const prior = JSON.stringify(item)
|
||||
item.CurrentSaves = [
|
||||
...(item.CurrentSaves ?? []).filter((s) => s.BodyType !== save.body_type),
|
||||
{
|
||||
...published,
|
||||
OutfitType: item.OutfitType,
|
||||
QuestUnityAssetHash: quest.UnityAssetHash,
|
||||
QuestUnityAsset2Hash: quest.UnityAsset2Hash,
|
||||
},
|
||||
]
|
||||
item.ModifiedAt = new Date().toISOString()
|
||||
item.ThumbnailImageFilename = save.thumbnail
|
||||
if (item.OutfitType === 1000)
|
||||
item.CustomBadgeMetadata = { RoomId: JSON.parse(save.configuration)?.d?.d?.r ?? 0 }
|
||||
const result = await db
|
||||
.prepare(
|
||||
`UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)
|
||||
AND (?=0 OR ?=(SELECT MAX(id) FROM avatar_studio_save WHERE item_id=? AND body_type=?))`
|
||||
)
|
||||
.bind(JSON.stringify(item), itemId, prior, automatic ? 1 : 0, saveId, itemId, save.body_type)
|
||||
.run()
|
||||
if (!result.meta.changes && attempt < 5 && (await avatarStudioItem(db, itemId)))
|
||||
return publishAvatarSave(db, itemId, saveId, automatic, attempt + 1)
|
||||
return result.meta.changes > 0
|
||||
}
|
||||
@@ -31,3 +31,4 @@ export * from './outfits-db'
|
||||
export * from './progression-db'
|
||||
export * from './relationships-db'
|
||||
export * from './validation'
|
||||
export * from './avatar-studio'
|
||||
|
||||
@@ -476,6 +476,25 @@ export async function deleteEmptyRoomInstances(
|
||||
return results.map((r) => r.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete EVERY instance of a room, occupied or not — the staff "Clear instances" button
|
||||
* for a room whose sessions are stuck. Presence is left alone: a player still in a
|
||||
* deleted instance keeps their row until it expires or they move, and their next
|
||||
* matchmake lands them in a fresh instance.
|
||||
*
|
||||
* Returns the ids deleted.
|
||||
*/
|
||||
export async function deleteRoomInstancesByRoom(db: D1Database, roomId: number): Promise<number[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`DELETE FROM room_instance WHERE room_id = ?1
|
||||
RETURNING json_extract(data, '$.roomInstanceId') AS id`
|
||||
)
|
||||
.bind(roomId)
|
||||
.all<{ id: number }>()
|
||||
return results.map((r) => r.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The oldest joinable public instance of a room (not private, not full, joins
|
||||
* enabled, not already in progress, not set to `Ignore`) that is running `gameVersion`,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* database read-only and uses `getRoomById`. Each imports the subset it needs.
|
||||
*/
|
||||
|
||||
import { escapeLike, prefixUpperBound } from './accounts-db'
|
||||
import { chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
|
||||
import { Accessibility, Role } from './enums'
|
||||
import { countPlayersByRoom } from './presence-db'
|
||||
@@ -72,6 +73,13 @@ export const ROOM_SCHEMA_DDL: string[] = [
|
||||
// feeds all order by it eventually; the WHERE clause is the point, not the key.
|
||||
`CREATE INDEX IF NOT EXISTS idx_room_public ON room (room_id)
|
||||
WHERE is_dorm IS NOT 1 AND accessibility = 1`,
|
||||
// The searchable rooms by lowercased name (migrations/0032_room_search_name.sql) — what
|
||||
// {@link searchRoomsByName} seeks. PARTIAL over exactly {@link SEARCHABLE_WHERE}, so the
|
||||
// walk never steps over a dorm or a private room: the rows it reads are the rows it
|
||||
// serves. `idx_rooms_name_lower` can't do that — it holds every dorm, and a prefix seek
|
||||
// on it checks each private room it passes against the blob.
|
||||
`CREATE INDEX IF NOT EXISTS idx_room_search_name ON room (name_lower)
|
||||
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1`,
|
||||
// A room's tags, one row per tag (migrations/0013_room_tag.sql). Modelled on the
|
||||
// `api` worker's `event_tag`, and the table is AUTHORITATIVE: `serializeRoom` strips
|
||||
// `Tags` from the blob and the reads re-attach it, the same arrangement `subroom` and
|
||||
@@ -292,6 +300,35 @@ export function roomRoles(room: Room): RoomRole[] {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `Roles` entry records a role the account actually HOLDS, as opposed to one it has
|
||||
* only been offered. An invite is an entry with `Role` {@link Role.None} and the offer on
|
||||
* `InvitedRole`; it stays that way until the player accepts ({@link answerRoomRoleInvite})
|
||||
* or declines (which drops the entry). Tolerates the raw blob shape — older rooms carry
|
||||
* entries without a `Role` key, which read as None, and a malformed entry is not a role.
|
||||
*/
|
||||
function holdsRole(entry: unknown): boolean {
|
||||
if (entry === null || typeof entry !== 'object') return false
|
||||
return Number((entry as Partial<RoomRole>).Role ?? Role.None) !== Role.None
|
||||
}
|
||||
|
||||
/**
|
||||
* The room as SERVED, with pending invites dropped from its `Roles` — a shallow copy; the
|
||||
* room passed in is untouched, so a caller can keep writing from it. Entries are kept in the
|
||||
* blob's own shape (not normalised through {@link roomRoles}), so a room that had no
|
||||
* pending invite serializes exactly as before. A room with no `Roles` array is returned as
|
||||
* is.
|
||||
*
|
||||
* Apply this at the response boundary ONLY, never to a room that is about to be written
|
||||
* back. Rooms are stored as whole blobs and every writer rewrites the blob from the loaded
|
||||
* room, so dropping the pending entries any earlier would silently erase every open invite
|
||||
* on the room's next unrelated edit (a renamed room, a new load screen).
|
||||
*/
|
||||
export function withConfirmedRoles<T extends Room>(room: T): T {
|
||||
if (!Array.isArray(room.Roles)) return room
|
||||
return { ...room, Roles: room.Roles.filter(holdsRole) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The statements that bring `room_role` in line with a room's `Roles`: drop the room's rows,
|
||||
* then insert each entry whole, as {@link roomRoles} reads it. Every entry is stored,
|
||||
@@ -1924,6 +1961,19 @@ export async function saveSubRoomData(
|
||||
createdAt: new Date().toISOString(),
|
||||
unityAssetId: input.unityAssetId,
|
||||
})
|
||||
// A live client save changes the scene data while reusing the baked Studio
|
||||
// asset. Its export manifest belongs to that asset and is needed again on the
|
||||
// save's immediate reload (prefabs/materials live in separate bundles).
|
||||
// A new Studio source upload must wait for its own cloud-build manifest.
|
||||
if (
|
||||
!input.unitySource &&
|
||||
typeof input.unityAssetId === 'string' &&
|
||||
typeof previous?.UnityAssetId === 'string' &&
|
||||
input.unityAssetId.toLowerCase() === previous.UnityAssetId.toLowerCase() &&
|
||||
previous.ServerData != null
|
||||
) {
|
||||
saveData.ServerData = previous.ServerData
|
||||
}
|
||||
if (input.unitySource) {
|
||||
saveData.StudioSourceAsset = input.unitySource
|
||||
saveData.SavedOnPlatform = null
|
||||
@@ -2450,7 +2500,7 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
|
||||
* it.
|
||||
*/
|
||||
const serializeRoom = (room: Room): string => {
|
||||
const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room
|
||||
const { SubRooms: _subRooms, Tags: _tags, Badges: _badges, Stats: stats, ...rest } = room
|
||||
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
|
||||
}
|
||||
|
||||
@@ -2512,13 +2562,21 @@ export async function attachStudioUnityAssets(
|
||||
db: D1Database,
|
||||
saves: SubRoomDataSave[]
|
||||
): Promise<void> {
|
||||
const ids = [
|
||||
...new Set(
|
||||
saves
|
||||
.map((save) => save.UnityAssetId)
|
||||
.filter((id): id is string => typeof id === 'string' && id !== '')
|
||||
),
|
||||
]
|
||||
// The cloud build partitions prefab/material exports into separate bundles.
|
||||
// The main scene keeps external references to them, so the loader must receive
|
||||
// every export, including shared dependencies, before it instantiates the scene.
|
||||
const assetIds = (save: SubRoomDataSave): string[] => {
|
||||
if (typeof save.UnityAssetId !== 'string' || save.UnityAssetId === '') return []
|
||||
const serverData = save.ServerData as { Exports?: unknown } | null | undefined
|
||||
const exports = Array.isArray(serverData?.Exports) ? serverData.Exports : []
|
||||
const ids = new Set([save.UnityAssetId.toLowerCase()])
|
||||
for (const item of exports) {
|
||||
const id = (item as { u?: unknown } | null)?.u
|
||||
if (typeof id === 'string' && id !== '') ids.add(id.toLowerCase())
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
const ids = [...new Set(saves.flatMap(assetIds))]
|
||||
if (ids.length === 0) return
|
||||
const builds = (await listUnityAssetBuilds(db, ids)).filter((build) =>
|
||||
isStudioBundleFilename(build.Filename)
|
||||
@@ -2533,7 +2591,7 @@ export async function attachStudioUnityAssets(
|
||||
for (const save of saves) {
|
||||
const id = save.UnityAssetId
|
||||
if (typeof id !== 'string') continue
|
||||
const baked = bakedUnityAssets(byAsset.get(id.toLowerCase()) ?? [])
|
||||
const baked = bakedUnityAssets(assetIds(save).flatMap((assetId) => byAsset.get(assetId) ?? []))
|
||||
if (baked.length === 0) continue
|
||||
save.UnitySubAssets = baked
|
||||
}
|
||||
@@ -2842,10 +2900,31 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
|
||||
// game's Nullable<DateTime> decoder and discards the entire owned-room list.
|
||||
if (room.PublishedAt === '') room.PublishedAt = null
|
||||
}
|
||||
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms)])
|
||||
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms), attachBadges(db, rooms)])
|
||||
return rooms
|
||||
}
|
||||
|
||||
/** Published room-linked image badges; drafts remain on the creator's Studio shelf. */
|
||||
async function attachBadges(db: D1Database, rooms: Room[]): Promise<void> {
|
||||
if (!rooms.length) return
|
||||
// `custom_avatar_item` is owned by api's migrations (0015, badge index 0035), both long
|
||||
// applied to the shared database; this no longer probes `sqlite_master` for it.
|
||||
// Served by `idx_custom_avatar_item_badge_room` (api migrations/0035), a PARTIAL index over
|
||||
// `outfit_type = 1000` keyed by the `json_extract(data,'$.CustomBadgeMetadata.RoomId')`
|
||||
// expression. SQLite matches both only as written: drop or rephrase either term and this
|
||||
// goes back to scanning every custom avatar item on every room page.
|
||||
const rows = await db
|
||||
.prepare(
|
||||
"SELECT data,json_extract(data,'$.CustomBadgeMetadata.RoomId') AS room_id FROM custom_avatar_item WHERE outfit_type=1000 AND accessibility<>0 AND json_array_length(json_extract(data,'$.CurrentSaves'))>0 AND json_extract(data,'$.CustomBadgeMetadata.RoomId') IN (SELECT value FROM json_each(?))"
|
||||
)
|
||||
.bind(JSON.stringify(rooms.map(roomIdOf)))
|
||||
.all<{ data: string; room_id: number }>()
|
||||
for (const room of rooms)
|
||||
room.Badges = rows.results
|
||||
.filter((r) => r.room_id === roomIdOf(room))
|
||||
.map((r) => JSON.parse(r.data))
|
||||
}
|
||||
|
||||
/** A single subroom of a room (columns authoritative), or null if it doesn't exist. */
|
||||
export async function getSubRoom(
|
||||
db: D1Database,
|
||||
@@ -3275,6 +3354,13 @@ export async function countRoomsByCreator(db: D1Database, accountId: number): Pr
|
||||
* room the player made. A room matching BOTH halves appears once — the roles half is an
|
||||
* EXISTS, not a join.
|
||||
*
|
||||
* A PENDING invite is not a job. An invited account holds a `Roles` entry with `Role` 0 and
|
||||
* the offer on `InvitedRole` until it answers, and that entry is indexed like any other —
|
||||
* so without the `role <> 0` guard a room showed up in the player's "rooms you contribute
|
||||
* to" (and, through `ownedby/me`, was considered for "rooms you own") the moment someone
|
||||
* invited them, before they had accepted anything. Only a held role counts here; the offer
|
||||
* reaches them as a `RoomCoOwnerInvited` message, not through this list.
|
||||
*
|
||||
* Roles live inside the room blob, so the roles half reads `room_role`, the trigger-kept
|
||||
* index of them, rather than a `json_each` over every room's `$.Roles` (which scanned and
|
||||
* parsed the whole table on every call). Both halves are index lookups, and SQLite runs
|
||||
@@ -3286,9 +3372,9 @@ export async function getContributedRooms(db: D1Database, accountId: number): Pr
|
||||
.prepare(
|
||||
`SELECT ${ROOM_COLUMNS} FROM room
|
||||
WHERE creator_account_id = ?1
|
||||
OR room_id IN (SELECT room_id FROM room_role WHERE account_id = ?1)`
|
||||
OR room_id IN (SELECT room_id FROM room_role WHERE account_id = ?1 AND role <> ?2)`
|
||||
)
|
||||
.bind(accountId)
|
||||
.bind(accountId, Role.None)
|
||||
.all<RoomRow>()
|
||||
return (await hydrateRooms(db, parseAll(results))).filter((r) => r.IsDorm !== true)
|
||||
}
|
||||
@@ -3613,6 +3699,38 @@ export async function searchRooms(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable rooms whose name BEGINS with `query` (case-insensitive), alphabetically, at
|
||||
* most `take` — the website's header search, which asks on every pause in typing.
|
||||
*
|
||||
* Not {@link searchRooms}: that is the game's search, a SUBSTRING match no index can
|
||||
* serve, so it reads every searchable room per call. This is a range seek on
|
||||
* `idx_room_search_name` (`>= q AND < q+U+10FFFF`, as {@link searchAccounts} does for
|
||||
* usernames), walked in `ORDER BY` order so the LIMIT stops it: D1 bills `take` rows
|
||||
* however many rooms exist. The WHERE repeats {@link SEARCHABLE_WHERE} verbatim because
|
||||
* SQLite only uses a partial index whose condition the query states; the LIKE is a
|
||||
* residual so `%`/`_` match literally. Rooms come back un-hydrated (no subrooms, tags or
|
||||
* badges) — the caller draws a name, a picture and the counters.
|
||||
*/
|
||||
export async function searchRoomsByName(
|
||||
db: D1Database,
|
||||
query: string,
|
||||
take: number
|
||||
): Promise<Room[]> {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q === '' || take <= 0) return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT ${ROOM_COLUMNS} FROM room
|
||||
WHERE ${SEARCHABLE_WHERE}
|
||||
AND name_lower >= ?1 AND name_lower < ?2 AND name_lower LIKE ?3 ESCAPE '\\'
|
||||
ORDER BY name_lower LIMIT ?4`
|
||||
)
|
||||
.bind(q, prefixUpperBound(q), `${escapeLike(q)}%`, take)
|
||||
.all<RoomRow>()
|
||||
return parseAll(results).filter(isSearchable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search suggestions for the box the player is typing in
|
||||
* (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms.
|
||||
|
||||
@@ -7,7 +7,11 @@ export {
|
||||
generateToken,
|
||||
generateStudioBuildValidationToken,
|
||||
validateStudioBuildValidationToken,
|
||||
studioBuildValidationId,
|
||||
generatePhotonAuthToken,
|
||||
generateIdentityAssertion,
|
||||
validateIdentityAssertion,
|
||||
IDENTITY_ASSERTION_TTL_SECONDS,
|
||||
TOKEN_TTL_SECONDS,
|
||||
} from './jwt'
|
||||
export type { PhotonAuthClaims } from './jwt'
|
||||
export type { IdentityAssertion, PhotonAuthClaims } from './jwt'
|
||||
|
||||
@@ -18,7 +18,7 @@ import { GAME_VERSION, gameBuild } from '@repo/domain'
|
||||
// last a day so the client doesn't have to refresh it.
|
||||
export const TOKEN_TTL_SECONDS = 86400
|
||||
|
||||
/** A build host can check component text, but cannot act as an account. */
|
||||
/** A build host can validate content and its job's grants, but cannot act as an account. */
|
||||
export async function generateStudioBuildValidationToken(
|
||||
buildId: string,
|
||||
secret: string
|
||||
@@ -39,19 +39,27 @@ export async function validateStudioBuildValidationToken(
|
||||
request: Request,
|
||||
secret: string
|
||||
): Promise<boolean> {
|
||||
return (await studioBuildValidationId(request, secret)) !== null
|
||||
}
|
||||
|
||||
/** Signed job identity for read-only, job-scoped validation routes. */
|
||||
export async function studioBuildValidationId(
|
||||
request: Request,
|
||||
secret: string
|
||||
): Promise<string | null> {
|
||||
const header = request.headers.get('Authorization') ?? ''
|
||||
if (!header.startsWith('Bearer ')) return false
|
||||
if (!header.startsWith('Bearer ')) return null
|
||||
try {
|
||||
const payload = await verify(header.slice(7), secret, 'HS256')
|
||||
return (
|
||||
payload.sub === undefined &&
|
||||
return payload.sub === undefined &&
|
||||
payload.aud === 'studio-build-validation' &&
|
||||
payload.scope === 'studio.build.validate' &&
|
||||
typeof payload.buildId === 'string' &&
|
||||
/^[1-9]\d*$/.test(payload.buildId)
|
||||
)
|
||||
? (payload.buildId as string)
|
||||
: null
|
||||
} catch {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,3 +328,82 @@ export async function generateToken(
|
||||
secret
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `aud` of an identity assertion — what tells one apart from every other token this
|
||||
* key signs. An access token's audience is the auth host; a Studio build token's is
|
||||
* `studio-build-validation`. Checked on validation, so neither can be replayed as an
|
||||
* assertion, and an assertion — which carries no `sub` — satisfies none of the
|
||||
* `validateAndGet*` readers either.
|
||||
*/
|
||||
const IDENTITY_ASSERTION_AUDIENCE = 'recflare:identity-assertion'
|
||||
|
||||
/**
|
||||
* How long an assertion is good for. It is minted and redeemed within one request on the
|
||||
* server side (www proves the identity, then posts the assertion to auth), so a minute
|
||||
* covers clock skew between two Workers and nothing else.
|
||||
*/
|
||||
export const IDENTITY_ASSERTION_TTL_SECONDS = 60
|
||||
|
||||
/** A platform identity one worker has verified and is vouching for to `auth`. */
|
||||
export interface IdentityAssertion {
|
||||
/** PlatformType int — Discord (101) today. */
|
||||
platform: number
|
||||
/** The platform's own id for the user — a Discord snowflake, as a string. */
|
||||
platformId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a short-lived, signed statement that the caller has VERIFIED this platform
|
||||
* identity — the credential behind `auth`'s `identity_assertion` grant.
|
||||
*
|
||||
* This exists so a worker other than `auth` can prove an identity `auth` cannot: www
|
||||
* completes a Discord OAuth2 code exchange (which needs the Discord client secret, bound
|
||||
* only to www) and hands `auth` the result, rather than `auth` learning Discord. Signing it
|
||||
* with the shared `JWT_SECRET` adds no trust that didn't exist — a holder of that key can
|
||||
* already mint access tokens outright — and it means the grant works the same over the
|
||||
* service binding and over the public hostname. The assertion names an identity, never an
|
||||
* account: resolving it to one (or minting one) stays `auth`'s decision.
|
||||
*/
|
||||
export async function generateIdentityAssertion(
|
||||
identity: IdentityAssertion,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return sign(
|
||||
{
|
||||
aud: IDENTITY_ASSERTION_AUDIENCE,
|
||||
platform: identity.platform,
|
||||
platform_id: identity.platformId,
|
||||
iat: now,
|
||||
nbf: now,
|
||||
exp: now + IDENTITY_ASSERTION_TTL_SECONDS,
|
||||
jti: crypto.randomUUID(),
|
||||
},
|
||||
secret,
|
||||
'HS256'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an identity assertion and return the identity it vouches for, or null when
|
||||
* the token is malformed, mis-signed, expired, or not an assertion at all. The audience
|
||||
* check is what refuses an access token presented here, and a missing `sub` is required
|
||||
* for the same reason in the other direction: an assertion must never pass as a session.
|
||||
*/
|
||||
export async function validateIdentityAssertion(
|
||||
token: string,
|
||||
secret: string
|
||||
): Promise<IdentityAssertion | null> {
|
||||
try {
|
||||
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
|
||||
if (payload.aud !== IDENTITY_ASSERTION_AUDIENCE || payload.sub !== undefined) return null
|
||||
const platform = payload.platform
|
||||
const platformId = payload.platform_id
|
||||
if (typeof platform !== 'number' || !Number.isInteger(platform) || platform < 0) return null
|
||||
if (typeof platformId !== 'string' || platformId === '') return null
|
||||
return { platform, platformId }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||