Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f34028267 | |||
| b02a646892 | |||
| 8397997234 | |||
| 4e2cdd87eb | |||
| d1e69bfd14 | |||
| 09e40af6b7 | |||
| f9600c1836 | |||
| 3da35a8692 |
@@ -227,9 +227,12 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000
|
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000
|
||||||
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
|
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
|
||||||
#
|
#
|
||||||
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer
|
# Also add https://<your domain>/claim AND https://<your domain>/login/discord to the app's
|
||||||
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT
|
# Redirects in the Discord developer portal, or the exchange is refused. The second is the
|
||||||
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
|
# login page's "Sign in with Discord" (same switch): it signs into the account the Discord is
|
||||||
|
# linked to, or CREATES one — the player then sets a password on the website to sign in to
|
||||||
|
# the game, including on a Quest with no Meta account. Granting Plus takes effect on the
|
||||||
|
# player's NEXT sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
|
||||||
#
|
#
|
||||||
# OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a
|
# OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a
|
||||||
# bot token, so `platform_account.role` tracks the guild instead of the day they claimed.
|
# bot token, so `platform_account.role` tracks the guild instead of the day they claimed.
|
||||||
@@ -276,6 +279,18 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# link Discord at /claim, and come back.
|
# link Discord at /claim, and come back.
|
||||||
# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002
|
# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002
|
||||||
|
|
||||||
|
# OPTIONAL: a staged rollout of Rec Room Studio to Discord roles (`auth`, `studio`, `www`).
|
||||||
|
# The Discord ROLE ids whose holders get Studio upload access — `auth` stamps the
|
||||||
|
# `betastudio` role into their token exactly as it does for an account staff flagged
|
||||||
|
# `hasStudio` on the website, and `studio`'s access lookups and the website's Studio
|
||||||
|
# settings page say "granted" for them too. The flag keeps working beside this, so staff
|
||||||
|
# can still add individuals. Same rules as ROLE_IDS above: snowflakes, not names; commas, no
|
||||||
|
# spaces; ANY one qualifies; roles come from the snapshot the benefits claim and the daily
|
||||||
|
# sweep keep, so a newly granted role counts once the sweep runs (or the player re-claims),
|
||||||
|
# and the player's Studio token picks it up on their next sign-in or refresh. UNSET — or
|
||||||
|
# parsing to no roles — grants nobody: the flag alone decides.
|
||||||
|
# RECFLARE_STUDIO_ROLLOUT=1077000000000000001,1077000000000000002
|
||||||
|
|
||||||
# OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the
|
# OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the
|
||||||
# RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
|
# RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
|
||||||
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
|
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
|
||||||
@@ -289,3 +304,24 @@ RECFLARE_DOMAIN=rec.example.com
|
|||||||
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
|
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
|
||||||
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
|
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
|
||||||
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
|
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
|
||||||
|
|
||||||
|
# Rec Room Studio (`studio`). Cloud builds are configured OUTSIDE this file: the credential
|
||||||
|
# the build host (recflare/cloud-building, its STUDIO_WORKER_KEY) presents to claim jobs
|
||||||
|
# lives in the shared Secrets Store, like JWT_SECRET above — not as a var, since every var
|
||||||
|
# here reaches every worker in plaintext. The binding has to resolve for `studio` to
|
||||||
|
# deploy, so create it even if you run no build host: the literal `off` disables cloud
|
||||||
|
# builds (the editor is told "Cloud building is not configured").
|
||||||
|
#
|
||||||
|
# wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
|
||||||
|
# --scopes workers --remote
|
||||||
|
#
|
||||||
|
# The knobs ARE vars. TARGETS is the Unity build targets a job is built for (0 Windows,
|
||||||
|
# 2 Android), commas, no spaces; the build host needs the matching editor modules.
|
||||||
|
# TEMPLATE_VERSION is the Studio template a job is built against, and the build host
|
||||||
|
# claims ONLY jobs for its own configured version (171 by default there, 169 here), so
|
||||||
|
# a mismatch leaves jobs queued forever with no error on either side — set this to the
|
||||||
|
# template the host has installed. DISTRIBUTION_MANIFEST is the R2 key of the template
|
||||||
|
# catalog the editor downloads from; unset disables downloads (see apps/studio/README.md).
|
||||||
|
# RECFLARE_CLOUD_BUILD_TARGETS=0,2
|
||||||
|
# RECFLARE_CLOUD_BUILD_TEMPLATE_VERSION=169
|
||||||
|
# RECFLARE_STUDIO_DISTRIBUTION_MANIFEST=roomunityasset/studio-distribution/catalog-v171.json
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ bun runx admin grant-moderator --username alice --remote
|
|||||||
Sets the account's `hasStudio` flag, which `auth` stamps into the token as the
|
Sets the account's `hasStudio` flag, which `auth` stamps into the token as the
|
||||||
`betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
|
`betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
|
||||||
normally manage this from the website (`/settings/recroomstudio`); this is the
|
normally manage this from the website (`/settings/recroomstudio`); this is the
|
||||||
operator's way in. Takes effect on the player's next Studio sign-in or refresh.
|
operator's way in. Takes effect on the player's next Studio sign-in or refresh. To open
|
||||||
|
Studio to a whole Discord role instead, name it in `RECFLARE_STUDIO_ROLLOUT` (`.env`).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun runx admin grant-studio --username alice
|
bun runx admin grant-studio --username alice
|
||||||
|
|||||||
@@ -140,6 +140,17 @@ wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes
|
|||||||
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
|
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
|
||||||
> the secret per request.
|
> the secret per request.
|
||||||
|
|
||||||
|
The `studio` worker binds one more, `CLOUD_BUILD_KEY`: the credential the Rec Room
|
||||||
|
Studio cloud-building host ([recflare/cloud-building](https://github.com/recflare/cloud-building),
|
||||||
|
its `STUDIO_WORKER_KEY`) presents to claim queued builds. Same rule — it must exist for
|
||||||
|
`studio` to deploy. Create it as the literal `off` to run without cloud builds; put a
|
||||||
|
long random string in later when you set up a build host, and give that host the same
|
||||||
|
string.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY --scopes workers --remote
|
||||||
|
```
|
||||||
|
|
||||||
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
|
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -347,6 +347,40 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Exact lookup by username — what the site's `/u/<name>` profile pages and the staff
|
||||||
|
// `@username` fields resolve a player through. An indexed equality on `username_lower`;
|
||||||
|
// before this existed they went through the prefix search above and filtered the
|
||||||
|
// exact hit client-side, which cost a 20-row index walk per profile view. Same path
|
||||||
|
// as the official API's `GET /account/username/{username}`.
|
||||||
|
.get(
|
||||||
|
'/account/username/:name',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Lookup'],
|
||||||
|
summary: 'A single public account, by exact username',
|
||||||
|
description:
|
||||||
|
'Case-insensitive exact match; a leading `@` is ignored. 404 when no account has the name.',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
description: 'The username, exactly (case-insensitive)',
|
||||||
|
schema: { type: 'string' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: json(AccountDto, 'The public account'),
|
||||||
|
404: { description: 'No account has that username' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const name = c.req.param('name').trim().replace(/^@/, '')
|
||||||
|
const account = name === '' ? null : await getAccountByUsername(c.env.DB, name)
|
||||||
|
if (!account) return c.json({ error: `There's no player called @${name}.` }, 404)
|
||||||
|
return c.json(toAccountDto(account))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// ---- Bulk / single lookup ------------------------------------------------
|
// ---- Bulk / single lookup ------------------------------------------------
|
||||||
// Register the static `bulk` path before the `/account/:id` param route.
|
// Register the static `bulk` path before the `/account/:id` param route.
|
||||||
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
|
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
|
||||||
|
|||||||
@@ -153,6 +153,24 @@ describe('public endpoints', () => {
|
|||||||
expect(await empty.json()).toEqual([])
|
expect(await empty.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /account/username/:name is an exact, case-insensitive lookup', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/account/username/coach`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const account = (await res.json()) as { accountId: number; username: string }
|
||||||
|
expect(account).toMatchObject({ accountId: 1, username: 'Coach' })
|
||||||
|
// A leading `@` is tolerated — staff fields take names in that form.
|
||||||
|
const at = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/account/username/${encodeURIComponent('@Coach')}`
|
||||||
|
)
|
||||||
|
expect(((await at.json()) as { accountId: number }).accountId).toBe(1)
|
||||||
|
// A prefix is NOT a match: that's what `/account/search` is for.
|
||||||
|
const prefix = await exports.default.fetch(`${ORIGIN}/account/username/coa`)
|
||||||
|
expect(prefix.status).toBe(404)
|
||||||
|
const missing = await exports.default.fetch(`${ORIGIN}/account/username/zzzznope`)
|
||||||
|
expect(missing.status).toBe(404)
|
||||||
|
expect(await missing.json()).toEqual({ error: "There's no player called @zzzznope." })
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /account/:id/bio returns an empty bio', async () => {
|
test('GET /account/:id/bio returns an empty bio', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
|
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
|
||||||
expect(await res.json()).toEqual({ accountId: 7, bio: '' })
|
expect(await res.json()).toEqual({ accountId: 7, bio: '' })
|
||||||
@@ -689,6 +707,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'GET /account/bulk',
|
'GET /account/bulk',
|
||||||
'GET /account/me',
|
'GET /account/me',
|
||||||
'GET /account/search',
|
'GET /account/search',
|
||||||
|
'GET /account/username/{name}',
|
||||||
'GET /account/{id}',
|
'GET /account/{id}',
|
||||||
'GET /account/{id}/bio',
|
'GET /account/{id}/bio',
|
||||||
'GET /accountprivacysettings/{id}',
|
'GET /accountprivacysettings/{id}',
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Index the room-badge lookup that hydrates every room list (`attachBadges` in
|
||||||
|
-- packages/domain/src/rooms-db.ts). Every room page — feeds, search, the owned list, a single
|
||||||
|
-- room read — asks this table for the published badges of the rooms on the page, and without an
|
||||||
|
-- index that was a full scan of every custom avatar item (thousands of first-party rows) per
|
||||||
|
-- page. At the time of writing it was half of the database's row reads.
|
||||||
|
--
|
||||||
|
-- PARTIAL, over only the badge rows (`OutfitType` 1000), keyed by the badge's room. SQLite uses
|
||||||
|
-- a partial index only when the query's WHERE contains its term as written, and an expression
|
||||||
|
-- index only when the query names the same expression, so the query must keep BOTH
|
||||||
|
-- `outfit_type = 1000` and `json_extract(data, '$.CustomBadgeMetadata.RoomId')` exactly —
|
||||||
|
-- change either and the index silently stops being used. The key is an expression rather than
|
||||||
|
-- a new generated column so the query reads the same whether or not this migration has run yet
|
||||||
|
-- (the `rooms` worker can deploy ahead of `api`'s migrations).
|
||||||
|
-- Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room
|
||||||
|
ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId'))
|
||||||
|
WHERE outfit_type = 1000;
|
||||||
@@ -6,6 +6,7 @@ import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo
|
|||||||
|
|
||||||
import { accountRoutes } from './routes/account'
|
import { accountRoutes } from './routes/account'
|
||||||
import { avatarRoutes } from './routes/avatar'
|
import { avatarRoutes } from './routes/avatar'
|
||||||
|
import { avatarStudioRoutes } from './routes/avatar-studio'
|
||||||
import { configRoutes } from './routes/config'
|
import { configRoutes } from './routes/config'
|
||||||
import { eventRoutes } from './routes/events'
|
import { eventRoutes } from './routes/events'
|
||||||
import { gameplayRoutes } from './routes/gameplay'
|
import { gameplayRoutes } from './routes/gameplay'
|
||||||
@@ -56,6 +57,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
.route('/', configRoutes)
|
.route('/', configRoutes)
|
||||||
.route('/', socialRoutes)
|
.route('/', socialRoutes)
|
||||||
.route('/', progressionRoutes)
|
.route('/', progressionRoutes)
|
||||||
|
.route('/', avatarStudioRoutes)
|
||||||
.route('/', avatarRoutes)
|
.route('/', avatarRoutes)
|
||||||
.route('/', gameplayRoutes)
|
.route('/', gameplayRoutes)
|
||||||
.route('/', eventRoutes)
|
.route('/', eventRoutes)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
|
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */
|
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql and 0035_custom_avatar_item_badge_room.sql). */
|
||||||
export const SCHEMA_DDL: string[] = [
|
export const SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
@@ -45,6 +45,9 @@ export const SCHEMA_DDL: string[] = [
|
|||||||
)`,
|
)`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
||||||
|
// The room-badge lookup (`attachBadges`, packages/domain/src/rooms-db.ts) runs on every room
|
||||||
|
// page; partial over the badge rows, keyed by the badge's room (migrations/0035).
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId')) WHERE outfit_type = 1000`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +75,9 @@ export interface CustomAvatarItemSave {
|
|||||||
UnityAssetHash: string
|
UnityAssetHash: string
|
||||||
UnityAsset2: string | null
|
UnityAsset2: string | null
|
||||||
UnityAsset2Hash: string | null
|
UnityAsset2Hash: string | null
|
||||||
|
/** Studio builds persist separate target hashes; legacy imports omit these. */
|
||||||
|
QuestUnityAssetHash?: string | null
|
||||||
|
QuestUnityAsset2Hash?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
|
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
|
||||||
@@ -356,6 +362,8 @@ export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarIte
|
|||||||
...save,
|
...save,
|
||||||
UnityAsset: questAsset(save.UnityAsset),
|
UnityAsset: questAsset(save.UnityAsset),
|
||||||
UnityAsset2: questAsset(save.UnityAsset2),
|
UnityAsset2: questAsset(save.UnityAsset2),
|
||||||
|
UnityAssetHash: save.QuestUnityAssetHash ?? save.UnityAssetHash,
|
||||||
|
UnityAsset2Hash: save.QuestUnityAsset2Hash ?? save.UnityAsset2Hash,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
|
||||||
|
|
||||||
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload,
|
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload,
|
||||||
and developer does not include it. It is stamped from the account's `hasStudio` flag.
|
and developer does not include it. It is stamped from the account's `hasStudio` flag,
|
||||||
Staff add and remove people at `/settings/recroomstudio` on the website (or
|
or from a Discord link holding a role named in `STUDIO_ROLLOUT`
|
||||||
|
(`RECFLARE_STUDIO_ROLLOUT` in the root `.env`) — a staged Studio rollout to a Discord
|
||||||
|
role, read from the same role snapshot as the build rollout gate. Staff add and remove
|
||||||
|
individual people at `/settings/recroomstudio` on the website (or
|
||||||
`runx admin grant-studio`). A player already signed into Studio keeps the old token
|
`runx admin grant-studio`). A player already signed into Studio keeps the old token
|
||||||
until the next sign-in or refresh.
|
until the next sign-in or refresh.
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ import {
|
|||||||
withNotFound,
|
withNotFound,
|
||||||
withOnError,
|
withOnError,
|
||||||
} from '@repo/hono-helpers'
|
} from '@repo/hono-helpers'
|
||||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
import {
|
||||||
|
generateToken,
|
||||||
|
TOKEN_TTL_SECONDS,
|
||||||
|
validateAndGetAccountId,
|
||||||
|
validateIdentityAssertion,
|
||||||
|
} from '@repo/jwt'
|
||||||
|
|
||||||
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
|
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
|
||||||
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
|
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
|
||||||
@@ -65,12 +70,15 @@ import {
|
|||||||
TokenResponse,
|
TokenResponse,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
import {
|
import {
|
||||||
|
accountHoldsDiscordRole,
|
||||||
countAccountsForPlatformIdentity,
|
countAccountsForPlatformIdentity,
|
||||||
getLinksForAccount,
|
getLinksForIdentity,
|
||||||
getLinksForPlatformId,
|
getLinksForPlatformId,
|
||||||
getLinksForPlatformIdentity,
|
getLinksForPlatformIdentity,
|
||||||
|
hasStudioAccess,
|
||||||
isPlatformIdentityLinked,
|
isPlatformIdentityLinked,
|
||||||
linkPlatformIdentity,
|
linkPlatformIdentity,
|
||||||
|
parseRoleIdList,
|
||||||
} from './platform-db'
|
} from './platform-db'
|
||||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||||
import { verifySteamTicket } from './steam-ticket'
|
import { verifySteamTicket } from './steam-ticket'
|
||||||
@@ -108,6 +116,21 @@ const TOKEN_SCOPE =
|
|||||||
*/
|
*/
|
||||||
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The website's "Sign in with Discord". `www` completes the Discord OAuth2 exchange (it
|
||||||
|
* alone holds the client secret), then posts an `assertion` — an HS256 token signed with
|
||||||
|
* the shared JWT_SECRET naming the platform identity it just proved (see `@repo/jwt`'s
|
||||||
|
* `generateIdentityAssertion`). This grant trusts the assertion the way `cached_login`
|
||||||
|
* trusts a Steam ticket, resolves it through the link table, and issues the ordinary
|
||||||
|
* session. No account linked to the identity means a NEW one is made and linked, so a
|
||||||
|
* Discord account is a way to join without Steam or Meta; the player then sets a password
|
||||||
|
* on the website and signs in with it anywhere — a sideloaded Quest included.
|
||||||
|
*/
|
||||||
|
const IDENTITY_ASSERTION_GRANT = 'identity_assertion'
|
||||||
|
|
||||||
|
/** What the grant answers when one identity opens several accounts; never true today. */
|
||||||
|
const AMBIGUOUS_IDENTITY_DESCRIPTION = 'platform identity is linked to more than one account'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
|
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
|
||||||
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
|
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
|
||||||
@@ -209,16 +232,11 @@ const UNSUPPORTED_BUILD_DESCRIPTION =
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or
|
* `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or
|
||||||
* whitespace (the same grammar `www`'s parseRoleIds reads DISCORD_BENEFITS_ROLE_IDS with).
|
* whitespace (`parseRoleIdList`, the grammar `www`'s parseRoleIds reads
|
||||||
* Unset or blank parses to an empty list, which `accountMayUseRolloutBuild` treats as a
|
* DISCORD_BENEFITS_ROLE_IDS with). Unset or blank parses to an empty list, which
|
||||||
* gate closed to everyone but staff.
|
* `accountMayUseRolloutBuild` treats as a gate closed to everyone but staff.
|
||||||
*/
|
*/
|
||||||
function rolloutRoleIds(raw: string | undefined): string[] {
|
const rolloutRoleIds = parseRoleIdList
|
||||||
return (raw ?? '')
|
|
||||||
.split(/[\s,]+/)
|
|
||||||
.map((id) => id.trim())
|
|
||||||
.filter((id) => id !== '')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator
|
* Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator
|
||||||
@@ -239,11 +257,7 @@ async function accountMayUseRolloutBuild(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const account = await getAccount(db, accountId)
|
const account = await getAccount(db, accountId)
|
||||||
if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true
|
if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true
|
||||||
if (roleIds.length === 0) return false
|
return accountHoldsDiscordRole(db, accountId, roleIds)
|
||||||
const links = await getLinksForAccount(db, accountId)
|
|
||||||
return links.some(
|
|
||||||
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
|
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
|
||||||
@@ -373,25 +387,25 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
|
|||||||
* is unconditional (even with no account resolved). The rest are the operator-granted
|
* is unconditional (even with no account resolved). The rest are the operator-granted
|
||||||
* extras, plus `junior` off the account's own `isJunior` flag.
|
* extras, plus `junior` off the account's own `isJunior` flag.
|
||||||
*
|
*
|
||||||
* `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it.
|
* `betastudio` rides on `studio` — the account's Studio access as `hasStudioAccess`
|
||||||
* RecFlare Studio treats that exact claim as Full access, which is what lets the editor
|
* resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding
|
||||||
* upload; staff set the flag from the website. `influencer` rides on `isInfluencer`
|
* a role in the `STUDIO_ROLLOUT` var — and developer does not imply it. RecFlare Studio
|
||||||
* (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup
|
* treats that exact claim as Full access, which is what lets the editor upload. The
|
||||||
* answers it. Order is stable so tokens are deterministic: screenshare, developer,
|
* caller resolves it (it costs a link read) so this stays a pure projection of the
|
||||||
* moderator, junior, betastudio, then influencer.
|
* account. `influencer` rides on `isInfluencer` (`runx admin grant-influencer`) and lives
|
||||||
|
* only in the token — no `/role/*` lookup answers it. Order is stable so tokens are
|
||||||
|
* deterministic: screenshare, developer, moderator, junior, betastudio, then influencer.
|
||||||
*/
|
*/
|
||||||
function accountRoles(
|
function accountRoles(
|
||||||
account: Pick<
|
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer'> | null,
|
||||||
Account,
|
studio: boolean
|
||||||
'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer'
|
|
||||||
> | null
|
|
||||||
): string[] {
|
): string[] {
|
||||||
const roles = ['screenshare']
|
const roles = ['screenshare']
|
||||||
if (!account) return roles
|
if (!account) return roles
|
||||||
if (account.isDeveloper) roles.push('developer')
|
if (account.isDeveloper) roles.push('developer')
|
||||||
if (account.isModerator) roles.push('moderator')
|
if (account.isModerator) roles.push('moderator')
|
||||||
if (account.isJunior) roles.push('junior')
|
if (account.isJunior) roles.push('junior')
|
||||||
if (account.hasStudio) roles.push('betastudio')
|
if (studio) roles.push('betastudio')
|
||||||
if (account.isInfluencer) roles.push('influencer')
|
if (account.isInfluencer) roles.push('influencer')
|
||||||
return roles
|
return roles
|
||||||
}
|
}
|
||||||
@@ -443,7 +457,13 @@ function oauthError(
|
|||||||
* already been authenticated — here, because the player approved a Studio code
|
* already been authenticated — here, because the player approved a Studio code
|
||||||
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
|
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
|
||||||
*/
|
*/
|
||||||
async function issueSession(c: Context<App>, accountId: number, grantType: string) {
|
async function issueSession(
|
||||||
|
c: Context<App>,
|
||||||
|
accountId: number,
|
||||||
|
grantType: string,
|
||||||
|
/** Extra top-level fields on the token body — `created: true` on an identity signup. */
|
||||||
|
extra: Record<string, unknown> = {}
|
||||||
|
) {
|
||||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||||
const ban = await resolveBan(c.env.DB, accountId, {
|
const ban = await resolveBan(c.env.DB, accountId, {
|
||||||
identity: { ip: clientIp },
|
identity: { ip: clientIp },
|
||||||
@@ -473,7 +493,7 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
|
|||||||
roleAccount.platformId ?? '',
|
roleAccount.platformId ?? '',
|
||||||
accountPlatform(roleAccount),
|
accountPlatform(roleAccount),
|
||||||
jwtSecret,
|
jwtSecret,
|
||||||
accountRoles(roleAccount),
|
accountRoles(roleAccount, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)),
|
||||||
accountPrivileges(roleAccount),
|
accountPrivileges(roleAccount),
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
roleAccount.hasPlus === true
|
roleAccount.hasPlus === true
|
||||||
@@ -516,9 +536,109 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
|
|||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope: TOKEN_SCOPE,
|
scope: TOKEN_SCOPE,
|
||||||
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
|
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
|
||||||
|
...extra,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `identity_assertion` grant: sign into — or create — the account behind a platform
|
||||||
|
* identity another worker has verified and signed for. See IDENTITY_ASSERTION_GRANT.
|
||||||
|
*
|
||||||
|
* Handled before the password fallback and outside the version/rollout gates, like the
|
||||||
|
* Studio device grant: nothing here posts a `ver`, and the credential is the assertion
|
||||||
|
* alone. The assertion names an IDENTITY, not an account; which account that is (or that
|
||||||
|
* there is none yet) is decided here, from the same table `cached_login` reads, so the
|
||||||
|
* website can never choose an account id for itself.
|
||||||
|
*
|
||||||
|
* Creating an account when the identity opens none is deliberate — it is the Discord
|
||||||
|
* sign-up — and takes the same guards `create_account` does for a verified platform
|
||||||
|
* identity: the evasion arms (a banned player's next move is a new account), the per-IP
|
||||||
|
* cap (`www` forwards the browser's real address over the service binding) and the
|
||||||
|
* per-identity cap. NOT the PASSWORD_SIGNUP switch: that exists because a password account
|
||||||
|
* has no identity but its address, and this one has a Discord user Discord itself vouched
|
||||||
|
* for, one account per user. The new account carries no `platform`/`platformId` on its
|
||||||
|
* blob — only the link — so its DTO and token read like a password account's and the
|
||||||
|
* Discord snowflake stays off the wire, exactly as the benefits claim keeps it.
|
||||||
|
*/
|
||||||
|
async function identityAssertionGrant(c: Context<App>, body: Record<string, unknown>) {
|
||||||
|
const assertion = typeof body.assertion === 'string' ? body.assertion : ''
|
||||||
|
const jwtSecret = await c.env.JWT_SECRET.get()
|
||||||
|
if (jwtSecret === '') {
|
||||||
|
logger.error('refusing an identity assertion: JWT_SECRET is empty')
|
||||||
|
return oauthError(c, 'server_error', 'token signing is not configured', 500)
|
||||||
|
}
|
||||||
|
const identity = assertion ? await validateIdentityAssertion(assertion, jwtSecret) : null
|
||||||
|
if (identity === null) {
|
||||||
|
return oauthError(c, 'invalid_grant', 'invalid or expired identity assertion')
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = await getLinksForIdentity(c.env.DB, identity.platform, identity.platformId)
|
||||||
|
if (links.length > 1) {
|
||||||
|
// The benefits claim keeps a Discord identity on one account, so this is a data
|
||||||
|
// problem rather than a player's choice to make; refused rather than guessed.
|
||||||
|
logger.error('identity assertion names an identity linked to several accounts', {
|
||||||
|
platform: identity.platform,
|
||||||
|
accountIds: links.map((l) => l.accountId),
|
||||||
|
})
|
||||||
|
return oauthError(c, 'invalid_grant', AMBIGUOUS_IDENTITY_DESCRIPTION)
|
||||||
|
}
|
||||||
|
if (links.length === 1) {
|
||||||
|
return issueSession(c, links[0].accountId, IDENTITY_ASSERTION_GRANT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nobody yet: the sign-up. Everything below mirrors create_account's platform path.
|
||||||
|
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||||
|
const blocked = await resolveBan(c.env.DB, null, {
|
||||||
|
identity: { ip: clientIp, platform: identity.platform, platformId: identity.platformId },
|
||||||
|
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||||
|
})
|
||||||
|
if (blocked) {
|
||||||
|
logger.info('identity signup refused: player banned', {
|
||||||
|
via: blocked.via,
|
||||||
|
bannedAccountId: blocked.bannedAccountId,
|
||||||
|
ip: clientIp,
|
||||||
|
platform: identity.platform,
|
||||||
|
})
|
||||||
|
return oauthError(c, 'invalid_grant', BLOCKED_DESCRIPTION)
|
||||||
|
}
|
||||||
|
const maxPerPlatformId = intVar(
|
||||||
|
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
|
||||||
|
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
maxPerPlatformId > 0 &&
|
||||||
|
(await countAccountsForPlatformIdentity(c.env.DB, identity.platform, identity.platformId)) >=
|
||||||
|
maxPerPlatformId
|
||||||
|
) {
|
||||||
|
logger.info('identity signup rejected: platform account limit', { platform: identity.platform })
|
||||||
|
return oauthError(c, 'invalid_grant', 'account limit reached for this platform account')
|
||||||
|
}
|
||||||
|
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||||
|
if (
|
||||||
|
maxPerIp > 0 &&
|
||||||
|
clientIp !== '' &&
|
||||||
|
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
|
||||||
|
) {
|
||||||
|
logger.info('identity signup rejected: per-IP account limit', { ip: clientIp })
|
||||||
|
return oauthError(c, 'invalid_grant', 'too many accounts created from this network')
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await createAccount(c.env.DB, {
|
||||||
|
platforms: 0,
|
||||||
|
lastLoginTime: new Date().toISOString(),
|
||||||
|
signupIp: clientIp || undefined,
|
||||||
|
lastLoginIp: clientIp || undefined,
|
||||||
|
})
|
||||||
|
await linkPlatformIdentity(c.env.DB, account.accountId, identity.platform, identity.platformId)
|
||||||
|
await placeNewPlayerInOrientation(c.env, account.accountId, 0)
|
||||||
|
logger.info('created an account from an identity assertion', {
|
||||||
|
accountId: account.accountId,
|
||||||
|
platform: identity.platform,
|
||||||
|
ip: clientIp,
|
||||||
|
})
|
||||||
|
return issueSession(c, account.accountId, IDENTITY_ASSERTION_GRANT, { created: true })
|
||||||
|
}
|
||||||
|
|
||||||
async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
|
async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return c.body(null, 401)
|
if (id === null) return c.body(null, 401)
|
||||||
@@ -922,7 +1042,7 @@ const app = new Hono<App>()
|
|||||||
tags: ['Token'],
|
tags: ['Token'],
|
||||||
summary: 'OAuth token endpoint — issues a JWT',
|
summary: 'OAuth token endpoint — issues a JWT',
|
||||||
description: [
|
description: [
|
||||||
'Issues an access token (plus a single-use refresh token) for one of four grants,',
|
'Issues an access token (plus a single-use refresh token) for one of six grants,',
|
||||||
'selected by `grant_type`. Every grant returns the same body on success.',
|
'selected by `grant_type`. Every grant returns the same body on success.',
|
||||||
'',
|
'',
|
||||||
'**`create_account`** — mints a new account with an auto-assigned random username',
|
'**`create_account`** — mints a new account with an auto-assigned random username',
|
||||||
@@ -945,6 +1065,17 @@ const app = new Hono<App>()
|
|||||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
||||||
'platform and platform id come from what was stored at issue time, not the body.',
|
'platform and platform id come from what was stored at issue time, not the body.',
|
||||||
'',
|
'',
|
||||||
|
'**`identity_assertion`** — the website’s "Sign in with Discord". `www` completes the',
|
||||||
|
'Discord OAuth2 code exchange (it alone holds the client secret) and posts an',
|
||||||
|
'`assertion`: an HS256 token signed with the shared JWT_SECRET naming the platform',
|
||||||
|
'(`101`) and platform id it proved, good for a minute. The identity is resolved through',
|
||||||
|
'the same link table as `cached_login`: one linked account is signed into; none means a',
|
||||||
|
'NEW account is created and linked (the body then carries `created: true`), under the',
|
||||||
|
'evasion check and the per-IP and per-identity signup caps but NOT the PASSWORD_SIGNUP',
|
||||||
|
'switch — the Discord user is the identity a password account lacks. The new account',
|
||||||
|
'has no password until its owner sets one on the website; with one, it signs in',
|
||||||
|
'anywhere the `password` grant does. Several linked accounts is refused.',
|
||||||
|
'',
|
||||||
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
||||||
'identifies the account by `username` or numeric `account_id` and requires the',
|
'identifies the account by `username` or numeric `account_id` and requires the',
|
||||||
'matching `password`. An account with no stored hash cannot be logged into at all,',
|
'matching `password`. An account with no stored hash cannot be logged into at all,',
|
||||||
@@ -974,7 +1105,8 @@ const app = new Hono<App>()
|
|||||||
'powers refresh on every login and every refresh grant. `junior` rides along for an',
|
'powers refresh on every login and every refresh grant. `junior` rides along for an',
|
||||||
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
|
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
|
||||||
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
|
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
|
||||||
'from the account’s `hasStudio` flag (staff set it on the website). RecFlare Studio',
|
'from the account’s `hasStudio` flag (staff set it on the website) or from a Discord',
|
||||||
|
'link holding a role in the `STUDIO_ROLLOUT` var. RecFlare Studio',
|
||||||
'treats that claim as permission to upload; `developer` does not confer it. A junior',
|
'treats that claim as permission to upload; `developer` does not confer it. A junior',
|
||||||
'also carries',
|
'also carries',
|
||||||
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
|
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
|
||||||
@@ -1072,6 +1204,9 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT)
|
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT)
|
||||||
}
|
}
|
||||||
|
// The website's Discord sign-in, likewise before the fallback — and before the
|
||||||
|
// version gates, which it never posts a `ver` for. See identityAssertionGrant.
|
||||||
|
if (grantType === IDENTITY_ASSERTION_GRANT) return identityAssertionGrant(c, body)
|
||||||
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
||||||
// grant overrides them below with what was stored when the token was issued.
|
// grant overrides them below with what was stored when the token was issued.
|
||||||
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
||||||
@@ -1565,7 +1700,10 @@ const app = new Hono<App>()
|
|||||||
platformId,
|
platformId,
|
||||||
platform,
|
platform,
|
||||||
jwtSecret,
|
jwtSecret,
|
||||||
accountRoles(roleAccount),
|
accountRoles(
|
||||||
|
roleAccount,
|
||||||
|
await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)
|
||||||
|
),
|
||||||
accountPrivileges(roleAccount),
|
accountPrivileges(roleAccount),
|
||||||
version,
|
version,
|
||||||
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
|
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ export type Env = SharedHonoEnv & {
|
|||||||
* Logins on every other build are never touched by this.
|
* Logins on every other build are never touched by this.
|
||||||
*/
|
*/
|
||||||
DISCORD_ROLLOUT_ROLE_IDS?: string
|
DISCORD_ROLLOUT_ROLE_IDS?: string
|
||||||
|
/**
|
||||||
|
* The Discord role ids whose holders get Rec Room Studio upload access — stamped into the
|
||||||
|
* token as `betastudio` exactly as the account's `hasStudio` flag is, so a whole Discord
|
||||||
|
* role can be let into Studio without touching anyone's account. Same grammar as
|
||||||
|
* DISCORD_ROLLOUT_ROLE_IDS above (snowflakes, commas and/or whitespace; ANY one qualifies),
|
||||||
|
* read from the same Discord link snapshot. Set for every worker from the root `.env`
|
||||||
|
* (`RECFLARE_STUDIO_ROLLOUT`): `studio` and `www` read it through the same
|
||||||
|
* `hasStudioAccess`, so what the token says, what the editor's access lookup says and
|
||||||
|
* what the website's `/api/studio-access` says agree. Unset, or naming no role, grants
|
||||||
|
* nobody — the flag alone decides. The flag still works beside it.
|
||||||
|
*/
|
||||||
|
STUDIO_ROLLOUT?: string
|
||||||
/**
|
/**
|
||||||
* The client builds a `/connect/token` grant may post as `ver` and still get a token:
|
* The client builds a `/connect/token` grant may post as `ver` and still get a token:
|
||||||
* a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means
|
* a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means
|
||||||
|
|||||||
@@ -143,10 +143,12 @@ export const TokenRequest = z.object({
|
|||||||
'refresh_token',
|
'refresh_token',
|
||||||
'password',
|
'password',
|
||||||
'urn:ietf:params:oauth:grant-type:device_code',
|
'urn:ietf:params:oauth:grant-type:device_code',
|
||||||
|
'identity_assertion',
|
||||||
])
|
])
|
||||||
.describe(
|
.describe(
|
||||||
'Anything unrecognised (including absent) is treated as a password grant, except the ' +
|
'Anything unrecognised (including absent) is treated as a password grant, except the ' +
|
||||||
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization'
|
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization, ' +
|
||||||
|
'and identity_assertion, the website’s Discord sign-in'
|
||||||
),
|
),
|
||||||
account_id: z.string().optional().describe('Numeric account id, as a string'),
|
account_id: z.string().optional().describe('Numeric account id, as a string'),
|
||||||
username: z
|
username: z
|
||||||
@@ -183,6 +185,13 @@ export const TokenRequest = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
|
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
|
||||||
|
assertion: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'identity_assertion grant: the HS256 assertion `www` minted with the shared JWT_SECRET ' +
|
||||||
|
'after completing a Discord OAuth2 exchange, naming the platform and platform_id it proved'
|
||||||
|
),
|
||||||
device_id: z
|
device_id: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -314,6 +314,147 @@ export async function getLinksForPlatformId(
|
|||||||
return results.map(toLink)
|
return results.map(toLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every account a platform identity is linked to, on ANY platform — including the ones
|
||||||
|
* {@link CACHED_LOGIN_PLATFORMS} keeps out of the picker.
|
||||||
|
*
|
||||||
|
* This is the unfiltered twin of {@link getLinksForPlatformIdentity}, and it must stay off
|
||||||
|
* every public route: the filter there exists so nobody can learn which account a Discord
|
||||||
|
* user owns by naming their snowflake. The one caller is `auth`'s `identity_assertion`
|
||||||
|
* grant, where the identity being asked about has just been PROVEN by the worker that
|
||||||
|
* minted the assertion — the website's Discord sign-in — so the answer goes to the person
|
||||||
|
* it is about and nobody else. Add a caller only under the same condition.
|
||||||
|
*/
|
||||||
|
export async function getLinksForIdentity(
|
||||||
|
db: D1Database,
|
||||||
|
platform: number,
|
||||||
|
platformId: string
|
||||||
|
): Promise<PlatformLink[]> {
|
||||||
|
if (platformId === '') return []
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
||||||
|
)
|
||||||
|
.bind(platform, platformId)
|
||||||
|
.all<LinkRow>()
|
||||||
|
return results.map(toLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any of the account's links on `platform` has roles RECORDED — a non-null `role`
|
||||||
|
* column, which only the website's benefits claim (and the sweep behind it) ever writes.
|
||||||
|
*
|
||||||
|
* This is what "has this account claimed its Discord benefits" means now that a Discord
|
||||||
|
* link can exist WITHOUT a claim: signing in with Discord (`auth`'s `identity_assertion`
|
||||||
|
* grant) links the identity on a brand-new account with `role` NULL. The claim's one-time
|
||||||
|
* gift and its page both ask this rather than "is there a link", so a player who made
|
||||||
|
* their account from Discord is still owed, and still shown, their first claim.
|
||||||
|
* An EMPTY role list counts as recorded: that is a claim that found no roles.
|
||||||
|
*/
|
||||||
|
export async function hasRecordedRoles(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
platform: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 AS ok FROM platform_account
|
||||||
|
WHERE account_id = ?1 AND platform = ?2 AND role IS NOT NULL LIMIT 1`
|
||||||
|
)
|
||||||
|
.bind(accountId, platform)
|
||||||
|
.first<{ ok: number }>()
|
||||||
|
return row !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record roles on an existing link that has NONE yet (`role` NULL), atomically. True when
|
||||||
|
* this call was the one that did it; false when the link is missing or already carries a
|
||||||
|
* role list — in which case nothing is written, and {@link linkPlatformIdentity}'s refresh
|
||||||
|
* is the caller's next step.
|
||||||
|
*
|
||||||
|
* The one-shot flip is the point: the benefits claim pays its first-claim gift on the
|
||||||
|
* INSERT of a fresh link, which two racing claims cannot both win. A link made by a Discord
|
||||||
|
* sign-in is already inserted by the time the claim comes, so this gives the claim the
|
||||||
|
* same single-winner write on the roles column instead.
|
||||||
|
*/
|
||||||
|
export async function recordFirstRoles(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
platform: number,
|
||||||
|
platformId: string,
|
||||||
|
roles: readonly string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (platformId === '') return false
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE platform_account SET role = ?4
|
||||||
|
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3 AND role IS NULL`
|
||||||
|
)
|
||||||
|
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
|
||||||
|
.run()
|
||||||
|
return res.meta.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of Discord role ids out of an operator var — numeric snowflakes separated by
|
||||||
|
* commas and/or whitespace, the grammar every `*_ROLE_IDS`-shaped var here shares
|
||||||
|
* (`auth`'s DISCORD_ROLLOUT_ROLE_IDS, `STUDIO_ROLLOUT`; `www` reads
|
||||||
|
* DISCORD_BENEFITS_ROLE_IDS the same way). Unset or blank parses to an empty list.
|
||||||
|
*/
|
||||||
|
export function parseRoleIdList(raw: string | undefined): string[] {
|
||||||
|
return (raw ?? '')
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter((id) => id !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an account's Discord link records ANY of `roleIds`. False with none configured —
|
||||||
|
* the link read is skipped, so a var that names no role grants nobody anything. Roles are
|
||||||
|
* the SNAPSHOT the website's benefits claim and `www`'s daily sweep keep (see
|
||||||
|
* {@link PlatformLink.roles}), so a role granted in Discord today counts once the player
|
||||||
|
* re-claims or the sweep runs — not instantly.
|
||||||
|
*/
|
||||||
|
export async function accountHoldsDiscordRole(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
roleIds: readonly string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (roleIds.length === 0) return false
|
||||||
|
const links = await getLinksForAccount(db, accountId)
|
||||||
|
return links.some(
|
||||||
|
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an account has Rec Room Studio upload access — what `auth` stamps into the
|
||||||
|
* token as the `betastudio` role, `studio` answers on its access lookups and `www`'s
|
||||||
|
* `/api/studio-access` reports. Two grants, either suffices:
|
||||||
|
*
|
||||||
|
* - the account's own `hasStudio` flag, set by staff on the website or
|
||||||
|
* `runx admin grant-studio`;
|
||||||
|
* - the STUDIO ROLLOUT: a Discord link holding a role named in the `STUDIO_ROLLOUT` var
|
||||||
|
* (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`, reaching every worker), which hands a
|
||||||
|
* whole Discord role the same access without touching anyone's account. Unset, or
|
||||||
|
* naming no role, it grants nobody — the flag alone decides, as before.
|
||||||
|
*
|
||||||
|
* Every reader goes through this so the three surfaces cannot disagree: a role holder
|
||||||
|
* whom `auth` stamps `betastudio` for must also be told "granted" by the website, and
|
||||||
|
* `studio`'s lookups (which rebuild the role from the account rather than a token) must
|
||||||
|
* see the same answer. The flag is checked first so an account that has it costs no
|
||||||
|
* link read.
|
||||||
|
*/
|
||||||
|
export async function hasStudioAccess(
|
||||||
|
db: D1Database,
|
||||||
|
account: { accountId: number; hasStudio?: boolean } | null | undefined,
|
||||||
|
studioRolloutRoleIds: string | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!account) return false
|
||||||
|
if (account.hasStudio === true) return true
|
||||||
|
return accountHoldsDiscordRole(db, account.accountId, parseRoleIdList(studioRolloutRoleIds))
|
||||||
|
}
|
||||||
|
|
||||||
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
||||||
export async function getLinksForAccount(
|
export async function getLinksForAccount(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../auth.app'
|
|||||||
import {
|
import {
|
||||||
AUDIT_LOG_SCHEMA_DDL,
|
AUDIT_LOG_SCHEMA_DDL,
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
|
getAccount,
|
||||||
getAccountsByDeviceId,
|
getAccountsByDeviceId,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
@@ -16,8 +17,14 @@ import {
|
|||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
updateAccount,
|
updateAccount,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
|
import {
|
||||||
|
generateIdentityAssertion,
|
||||||
|
generateToken,
|
||||||
|
TOKEN_TTL_SECONDS,
|
||||||
|
validateIdentityAssertion,
|
||||||
|
} from '@repo/jwt'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||||
import {
|
import {
|
||||||
banFromReport,
|
banFromReport,
|
||||||
createReport,
|
createReport,
|
||||||
@@ -102,6 +109,9 @@ beforeAll(async () => {
|
|||||||
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
|
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
|
||||||
// the token grant reads it for the evasion arms.
|
// the token grant reads it for the evasion arms.
|
||||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
// Custom avatar items (owned by the api worker) — every room read joins the published
|
||||||
|
// room badges from this table.
|
||||||
|
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -818,6 +828,53 @@ describe('auth worker routes', () => {
|
|||||||
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
|
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// STUDIO_ROLLOUT opens Studio to a Discord ROLE: a holder is stamped `betastudio` exactly
|
||||||
|
// as a flagged account is, with no account flag involved, from the same Discord link
|
||||||
|
// snapshot the build rollout gate reads. Unset, the flag alone decides.
|
||||||
|
test('POST /connect/token stamps betastudio for a Discord role in STUDIO_ROLLOUT', async () => {
|
||||||
|
const studioRole = '1077000000000000031'
|
||||||
|
const otherRole = '1077000000000000032'
|
||||||
|
await seedAccount(9813, 'StudioRoleHolder')
|
||||||
|
await linkPlatformIdentity(env.DB, 9813, PlatformType.Discord, '500000000000009813', [
|
||||||
|
studioRole,
|
||||||
|
])
|
||||||
|
await seedAccount(9814, 'StudioRoleOutsider')
|
||||||
|
await linkPlatformIdentity(env.DB, 9814, PlatformType.Discord, '500000000000009814', [
|
||||||
|
otherRole,
|
||||||
|
])
|
||||||
|
const login = (id: number) => tokenFor(`account_id=${id}&password=${LOGIN_PASSWORD}`)
|
||||||
|
|
||||||
|
const original = env.STUDIO_ROLLOUT
|
||||||
|
try {
|
||||||
|
// Unset, or naming no role: the link grants nothing.
|
||||||
|
delete env.STUDIO_ROLLOUT
|
||||||
|
expect((await login(9813)).role).not.toContain('betastudio')
|
||||||
|
env.STUDIO_ROLLOUT = ' , '
|
||||||
|
expect((await login(9813)).role).not.toContain('betastudio')
|
||||||
|
|
||||||
|
// Same list grammar as DISCORD_ROLLOUT_ROLE_IDS; any one named role qualifies.
|
||||||
|
env.STUDIO_ROLLOUT = ` ${otherRole}x ,\n${studioRole} `
|
||||||
|
expect((await login(9813)).role).toEqual(['gameClient', 'screenshare', 'betastudio'])
|
||||||
|
// A Discord link holding only other roles is not in the rollout.
|
||||||
|
expect((await login(9814)).role).not.toContain('betastudio')
|
||||||
|
// The account flag never moved: this is the var, not a grant written anywhere.
|
||||||
|
expect((await getAccount(env.DB, 9813))?.hasStudio).toBeUndefined()
|
||||||
|
|
||||||
|
// The refresh grant re-resolves it, so ending the rollout drops the claim on the
|
||||||
|
// next refresh — the same path a revoked flag takes.
|
||||||
|
const session = await postToken(`account_id=9813&password=${LOGIN_PASSWORD}`)
|
||||||
|
delete env.STUDIO_ROLLOUT
|
||||||
|
const refreshed = await postToken(
|
||||||
|
`grant_type=refresh_token&refresh_token=${encodeURIComponent(session.json.refresh_token as string)}`
|
||||||
|
)
|
||||||
|
expect(refreshed.status).toBe(200)
|
||||||
|
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
|
||||||
|
} finally {
|
||||||
|
if (original === undefined) delete env.STUDIO_ROLLOUT
|
||||||
|
else env.STUDIO_ROLLOUT = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
|
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
|
||||||
// the website's Discord benefits claim sets. `econ` decides the CampusCard and the
|
// the website's Discord benefits claim sets. `econ` decides the CampusCard and the
|
||||||
// subscriber discount from this claim ALONE and never reads the account, so if this
|
// subscriber discount from this claim ALONE and never reads the account, so if this
|
||||||
@@ -2579,3 +2636,145 @@ describe('staff login audit', () => {
|
|||||||
expect(await staffLogins(9605)).toHaveLength(1)
|
expect(await staffLogins(9605)).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The website's "Sign in with Discord". `www` proves the Discord user (it holds the client
|
||||||
|
// secret) and posts an identity ASSERTION signed with the shared JWT_SECRET; this grant
|
||||||
|
// resolves it through the link table like a cached_login, or makes the account when there
|
||||||
|
// is none. Everything here mints the assertion the way www does, with the test key.
|
||||||
|
describe('identity_assertion grant', () => {
|
||||||
|
const SIGNING_KEY = 'test-signing-key'
|
||||||
|
const assertionFor = (platformId: string, platform = PlatformType.Discord) =>
|
||||||
|
generateIdentityAssertion({ platform, platformId }, SIGNING_KEY)
|
||||||
|
const redeem = async (assertion: string, ip?: string) =>
|
||||||
|
postToken(`grant_type=identity_assertion&assertion=${encodeURIComponent(assertion)}`, ip)
|
||||||
|
|
||||||
|
test('an assertion round-trips, and nothing else passes as one', async () => {
|
||||||
|
const assertion = await assertionFor('700000000000000001')
|
||||||
|
await expect(validateIdentityAssertion(assertion, SIGNING_KEY)).resolves.toEqual({
|
||||||
|
platform: PlatformType.Discord,
|
||||||
|
platformId: '700000000000000001',
|
||||||
|
})
|
||||||
|
// A different key, and an access token signed with the RIGHT key: both refused. The
|
||||||
|
// second is the one that matters — the same secret signs sessions, so the audience is
|
||||||
|
// what keeps a stolen token from being replayed as proof of a platform identity.
|
||||||
|
await expect(validateIdentityAssertion(assertion, 'another-key')).resolves.toBeNull()
|
||||||
|
const session = await generateToken('42', '', 4, SIGNING_KEY)
|
||||||
|
await expect(validateIdentityAssertion(session, SIGNING_KEY)).resolves.toBeNull()
|
||||||
|
// And the other direction: an assertion carries no `sub`, so it is no session.
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Authorization: `Bearer ${assertion}`,
|
||||||
|
},
|
||||||
|
body: 'user_code=ABCDEFGH',
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('signs into the one account the identity is linked to', async () => {
|
||||||
|
await seedAccount(9400, 'DiscordLinked')
|
||||||
|
// Linked the way the benefits claim links: with roles recorded.
|
||||||
|
await linkPlatformIdentity(env.DB, 9400, PlatformType.Discord, '700000000000009400', [])
|
||||||
|
const res = await redeem(await assertionFor('700000000000009400'))
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(decodePayload(res.json.access_token as string).sub).toBe('9400')
|
||||||
|
expect(res.json).not.toHaveProperty('created')
|
||||||
|
expect(typeof res.json.refresh_token).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses a missing, forged or replayed-session assertion', async () => {
|
||||||
|
expect((await postToken('grant_type=identity_assertion')).status).toBe(400)
|
||||||
|
const forged = await generateIdentityAssertion(
|
||||||
|
{ platform: PlatformType.Discord, platformId: '700000000000009400' },
|
||||||
|
'not-the-key'
|
||||||
|
)
|
||||||
|
const res = await redeem(forged)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.json.error_description).toBe('invalid or expired identity assertion')
|
||||||
|
// An access token for the linked account, presented as the assertion.
|
||||||
|
const session = await generateToken('9400', '', 4, SIGNING_KEY)
|
||||||
|
expect((await redeem(session)).status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates and links an account when the identity opens none, once', async () => {
|
||||||
|
const discordId = '700000000000009401'
|
||||||
|
const first = await redeem(await assertionFor(discordId))
|
||||||
|
expect(first.status).toBe(200)
|
||||||
|
expect(first.json.created).toBe(true)
|
||||||
|
const accountId = Number(decodePayload(first.json.access_token as string).sub)
|
||||||
|
expect(accountId).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Linked on the Discord platform, and ONLY there: the blob carries no platform
|
||||||
|
// identity (the snowflake stays off the DTO and the token), like a password account.
|
||||||
|
const links = await getLinksForAccount(env.DB, accountId)
|
||||||
|
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
|
||||||
|
[PlatformType.Discord, discordId],
|
||||||
|
])
|
||||||
|
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
|
||||||
|
.bind(accountId)
|
||||||
|
.first<{ data: string }>()
|
||||||
|
const blob = JSON.parse(row!.data) as Record<string, unknown>
|
||||||
|
expect(blob.platformId).toBeUndefined()
|
||||||
|
expect(blob.passwordHash).toBeUndefined()
|
||||||
|
// Placed in Orientation like any signup.
|
||||||
|
const presence = await env.DB.prepare('SELECT 1 AS ok FROM presence WHERE account_id = ?1')
|
||||||
|
.bind(accountId)
|
||||||
|
.first()
|
||||||
|
expect(presence).not.toBeNull()
|
||||||
|
|
||||||
|
// The same Discord again is a sign-IN to that account, not a second account.
|
||||||
|
const again = await redeem(await assertionFor(discordId))
|
||||||
|
expect(again.status).toBe(200)
|
||||||
|
expect(again.json).not.toHaveProperty('created')
|
||||||
|
expect(decodePayload(again.json.access_token as string).sub).toBe(String(accountId))
|
||||||
|
await expect(
|
||||||
|
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
|
||||||
|
).resolves.toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The stretch of the feature: a Discord-made account has no password, and a password is
|
||||||
|
// what signs it into the game (a sideloaded Quest has no Meta nonce to offer). The
|
||||||
|
// website's password form posts with an EMPTY old password the first time.
|
||||||
|
test('a Discord-made account can set a password and then sign in with it', async () => {
|
||||||
|
const created = await redeem(await assertionFor('700000000000009402'))
|
||||||
|
const accountId = decodePayload(created.json.access_token as string).sub as string
|
||||||
|
expect((await postToken(`account_id=${accountId}&password=hunter22`)).status).toBe(400)
|
||||||
|
|
||||||
|
const set = await changePassword(
|
||||||
|
'oldPassword=&newPassword=hunter22',
|
||||||
|
created.json.access_token as string
|
||||||
|
)
|
||||||
|
expect(set.status).toBe(200)
|
||||||
|
const login = await postToken(`grant_type=password&account_id=${accountId}&password=hunter22`)
|
||||||
|
expect(login.status).toBe(200)
|
||||||
|
expect(decodePayload(login.json.access_token as string).sub).toBe(accountId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses an identity that opens several accounts rather than guessing', async () => {
|
||||||
|
await seedAccount(9403, 'TwoLinksA')
|
||||||
|
await seedAccount(9404, 'TwoLinksB')
|
||||||
|
await linkPlatformIdentity(env.DB, 9403, PlatformType.Discord, '700000000000009403', [])
|
||||||
|
await linkPlatformIdentity(env.DB, 9404, PlatformType.Discord, '700000000000009403', [])
|
||||||
|
const res = await redeem(await assertionFor('700000000000009403'))
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.json.error_description).toBe('platform identity is linked to more than one account')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The signup caps reach this path as they reach create_account — www forwards the
|
||||||
|
// browser's real address over the service binding, so the per-IP cap counts browsers.
|
||||||
|
test('the per-IP signup cap applies to accounts made this way', async () => {
|
||||||
|
const ip = '198.51.100.77'
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const res = await redeem(await assertionFor(`70000000000000950${i}`), ip)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.json.created).toBe(true)
|
||||||
|
}
|
||||||
|
const fourth = await redeem(await assertionFor('700000000000009509'), ip)
|
||||||
|
expect(fourth.status).toBe(400)
|
||||||
|
expect(fourth.json.error_description).toBe('too many accounts created from this network')
|
||||||
|
// Signing IN from that network is untouched: the cap is on creation.
|
||||||
|
const existing = await redeem(await assertionFor('700000000000009500'), ip)
|
||||||
|
expect(existing.status).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -362,6 +362,13 @@ const app = new Hono<App>()
|
|||||||
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
|
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SDK source downloads use an opaque upload capability (no bearer on this
|
||||||
|
// request). Only creator-authenticated save history exposes these filenames.
|
||||||
|
.get('/avatarunityasset/:filename{.+}', (c) => {
|
||||||
|
const name = c.req.param('filename')
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}$/.test(name)) return c.notFound()
|
||||||
|
return serveAsset(c, `avatarunityasset/${name}`)
|
||||||
|
})
|
||||||
// Editable Unity source manifests. Baked bundles are served separately.
|
// Editable Unity source manifests. Baked bundles are served separately.
|
||||||
.get(
|
.get(
|
||||||
'/roomunityasset/:filename{.+}',
|
'/roomunityasset/:filename{.+}',
|
||||||
@@ -430,15 +437,22 @@ const app = new Hono<App>()
|
|||||||
* a build is stored under it. Null when no build is, or the table has not been migrated
|
* a build is stored under it. Null when no build is, or the table has not been migrated
|
||||||
* yet (the rooms worker owns it).
|
* yet (the rooms worker owns it).
|
||||||
*/
|
*/
|
||||||
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
|
async function unityAssetR2Key(env: StudioAssetEnv, filename: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// Inlined rather than imported so this worker does not take a dependency on the
|
// Inlined rather than imported so this worker does not take a dependency on the
|
||||||
// domain package.
|
// domain package.
|
||||||
const row = await db
|
const row = await env.DB.prepare(
|
||||||
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
|
`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`
|
||||||
|
)
|
||||||
.bind(filename)
|
.bind(filename)
|
||||||
.first<{ stored: number }>()
|
.first<{ stored: number }>()
|
||||||
return row ? filename : null
|
if (!row) return null
|
||||||
|
if (/^(quest\/)?studio-avatar\//.test(filename)) return `avatar/${filename}`
|
||||||
|
// Room builds and consumable exports use the game's /room namespace.
|
||||||
|
// Retain older installations that stored the same filename at the bucket root.
|
||||||
|
if (filename.startsWith('studio/') && (await env.CDN_ASSETS.head(`room/${filename}`)))
|
||||||
|
return `room/${filename}`
|
||||||
|
return filename
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
if (message.toLowerCase().includes('no such table')) return null
|
if (message.toLowerCase().includes('no such table')) return null
|
||||||
@@ -450,14 +464,14 @@ async function unityAssetR2Key(db: D1Database, filename: string): Promise<string
|
|||||||
async function serveUnityAsset(c: Context<App>) {
|
async function serveUnityAsset(c: Context<App>) {
|
||||||
const name = c.req.param('filename') ?? ''
|
const name = c.req.param('filename') ?? ''
|
||||||
if (name.includes('..')) return c.body(null, 400)
|
if (name.includes('..')) return c.body(null, 400)
|
||||||
const key = await unityAssetR2Key(c.env.DB, name)
|
const key = await unityAssetR2Key(c.env, name)
|
||||||
return serveAsset(c, key ?? `unityasset/${name}`)
|
return serveAsset(c, key ?? `unityasset/${name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
|
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
|
||||||
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
|
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
|
||||||
if (name.includes('..')) return new Response(null, { status: 400 })
|
if (name.includes('..')) return new Response(null, { status: 400 })
|
||||||
const key = await unityAssetR2Key(env.DB, name)
|
const key = await unityAssetR2Key(env, name)
|
||||||
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
|
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
|
||||||
if (!head) return new Response(null, { status: 404 })
|
if (!head) return new Response(null, { status: 404 })
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,32 @@ declare module 'cloudflare:test' {
|
|||||||
|
|
||||||
const ORIGIN = 'https://example.com'
|
const ORIGIN = 'https://example.com'
|
||||||
|
|
||||||
|
test('serves opaque avatar source manifests and SDK avatar bundle GET/HEAD/ranges', async () => {
|
||||||
|
const source = '2026-10-18/11111111-1111-1111-1111-111111111111'
|
||||||
|
await env.CDN_ASSETS.put('avatarunityasset/' + source, '{"Version":3}')
|
||||||
|
expect(await (await exports.default.fetch(ORIGIN + '/avatarunityasset/' + source)).text()).toBe(
|
||||||
|
'{"Version":3}'
|
||||||
|
)
|
||||||
|
await env.DB.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS unity_asset(unity_asset_id TEXT NOT NULL,target INTEGER NOT NULL,version INTEGER NOT NULL,kind TEXT NOT NULL,filename TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(unity_asset_id,target,version,kind))'
|
||||||
|
).run()
|
||||||
|
const name = 'quest/studio-avatar/test/item/lease.material.assetbundle'
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT INTO unity_asset(unity_asset_id,target,version,kind,filename,hash) VALUES (?,2,6,?,?,?)'
|
||||||
|
)
|
||||||
|
.bind('avatar-fixture', 'material', name, 'hash')
|
||||||
|
.run()
|
||||||
|
await env.CDN_ASSETS.put('avatar/' + name, 'UnityFS\0avatar')
|
||||||
|
expect(
|
||||||
|
(await exports.default.fetch(ORIGIN + '/unityasset/' + name, { method: 'HEAD' })).status
|
||||||
|
).toBe(200)
|
||||||
|
const response = await exports.default.fetch(ORIGIN + '/unityasset/' + name, {
|
||||||
|
headers: { Range: 'bytes=0-7' },
|
||||||
|
})
|
||||||
|
expect(response.status).toBe(206)
|
||||||
|
expect(await response.text()).toBe('UnityFS\0')
|
||||||
|
})
|
||||||
|
|
||||||
describe('cdn endpoints', () => {
|
describe('cdn endpoints', () => {
|
||||||
test('GET / reports service status', async () => {
|
test('GET / reports service status', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/`)
|
const res = await exports.default.fetch(`${ORIGIN}/`)
|
||||||
@@ -357,6 +383,18 @@ describe('cdn endpoints', () => {
|
|||||||
expect(head.status).toBe(200)
|
expect(head.status).toBe(200)
|
||||||
expect(head.headers.get('content-length')).toBe('4')
|
expect(head.headers.get('content-length')).toBe('4')
|
||||||
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
|
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
|
||||||
|
// Current room/consumable builds live in the game's room namespace.
|
||||||
|
await env.CDN_ASSETS.put('room/' + filename, new Uint8Array([5, 6, 7, 8, 9]))
|
||||||
|
const current = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
|
||||||
|
headers: { Range: 'bytes=1-2' },
|
||||||
|
})
|
||||||
|
expect(current.status).toBe(206)
|
||||||
|
expect(new Uint8Array(await current.arrayBuffer())).toEqual(new Uint8Array([6, 7]))
|
||||||
|
const currentHead = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
|
||||||
|
method: 'HEAD',
|
||||||
|
})
|
||||||
|
expect(currentHead.status).toBe(200)
|
||||||
|
expect(currentHead.headers.get('content-length')).toBe('5')
|
||||||
|
|
||||||
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
|
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||||
import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
|
import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
|
||||||
import {
|
import {
|
||||||
banFromReport,
|
banFromReport,
|
||||||
@@ -289,6 +290,9 @@ beforeAll(async () => {
|
|||||||
// Platform identity links (owned by the auth worker) — a ban also reaches the
|
// Platform identity links (owned by the auth worker) — a ban also reaches the
|
||||||
// accounts sharing a proven identity with the banned one.
|
// accounts sharing a proven identity with the banned one.
|
||||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
// Custom avatar items (owned by the api worker) — every room read joins the published
|
||||||
|
// room badges from this table.
|
||||||
|
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -962,9 +962,15 @@ function pickBakedBundle(
|
|||||||
target: number | null
|
target: number | null
|
||||||
): { filename: string; hash: string | null } | null {
|
): { filename: string; hash: string | null } | null {
|
||||||
if (!Array.isArray(save.UnitySubAssets)) return null
|
if (!Array.isArray(save.UnitySubAssets)) return null
|
||||||
|
const rootId = typeof save.UnityAssetId === 'string' ? save.UnityAssetId.toLowerCase() : null
|
||||||
const rows = save.UnitySubAssets.filter(
|
const rows = save.UnitySubAssets.filter(
|
||||||
(item): item is { Target?: unknown; Filename?: unknown; Hash?: unknown } =>
|
(item): item is { Target?: unknown; Filename?: unknown; Hash?: unknown } =>
|
||||||
typeof item === 'object' && item !== null
|
typeof item === 'object' &&
|
||||||
|
item !== null &&
|
||||||
|
(rootId === null ||
|
||||||
|
('UnityAssetId' in item &&
|
||||||
|
typeof item.UnityAssetId === 'string' &&
|
||||||
|
item.UnityAssetId.toLowerCase() === rootId))
|
||||||
)
|
)
|
||||||
const chosen =
|
const chosen =
|
||||||
target === null
|
target === null
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
UNITY_ASSET_SCHEMA_DDL,
|
UNITY_ASSET_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||||
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
||||||
import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db'
|
import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db'
|
||||||
import { NotificationType } from '../../../../notify/src/notification-types'
|
import { NotificationType } from '../../../../notify/src/notification-types'
|
||||||
@@ -134,6 +135,7 @@ beforeAll(async () => {
|
|||||||
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// Inventions (owned by the api worker) — a room save writes each one's room count.
|
// Inventions (owned by the api worker) — a room save writes each one's room count.
|
||||||
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves.
|
// Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves.
|
||||||
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
@@ -6329,6 +6331,84 @@ describe('studio room bundles', () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('serves partitioned prefab and shared-material exports as save dependencies', async () => {
|
||||||
|
await reset()
|
||||||
|
await storeFiles()
|
||||||
|
const prefab = '00111111-2222-4333-8444-555555555555'
|
||||||
|
const shared = '00222222-2222-4333-8444-555555555555'
|
||||||
|
await env.DB.prepare(
|
||||||
|
"UPDATE subroom_save SET data=json_set(data,'$.ServerData',json(?1)) WHERE sub_room_id=99011"
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
Exports: [
|
||||||
|
{ e: 'prefab', u: prefab.toUpperCase() },
|
||||||
|
{ e: 'shared', u: shared },
|
||||||
|
{ e: 'duplicate', u: prefab },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
for (const id of [prefab, shared]) {
|
||||||
|
for (const target of [0, 2]) {
|
||||||
|
for (const kind of ['main', 'stripped'] as const) {
|
||||||
|
await putUnityAsset(
|
||||||
|
env.DB,
|
||||||
|
{
|
||||||
|
UnityAssetId: id,
|
||||||
|
Target: target,
|
||||||
|
Version: 5,
|
||||||
|
Filename: publicStudioBundleFilename(
|
||||||
|
9901,
|
||||||
|
id,
|
||||||
|
target === 0 ? 'windows' : 'android',
|
||||||
|
kind
|
||||||
|
),
|
||||||
|
Hash: winHash,
|
||||||
|
},
|
||||||
|
kind
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const target of [0, 2]) {
|
||||||
|
const response = await SELF.fetch(`${ORIGIN}/rooms/9901?unityAssetTarget=${target}`)
|
||||||
|
const room = (await response.json()) as {
|
||||||
|
SubRooms: Array<{
|
||||||
|
CurrentSave: {
|
||||||
|
SubRoomDataSaveId: number
|
||||||
|
UnitySubAssets: Array<{ UnityAssetId: string; Target: number }>
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
const current = room.SubRooms[0]!.CurrentSave
|
||||||
|
expect(current.UnitySubAssets.map((asset) => asset.UnityAssetId).sort()).toEqual(
|
||||||
|
[ASSET, prefab, shared].sort()
|
||||||
|
)
|
||||||
|
expect(current.UnitySubAssets.every((asset) => asset.Target === target)).toBe(true)
|
||||||
|
const detailResponse = await SELF.fetch(
|
||||||
|
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${current.SubRoomDataSaveId}?unityAssetTarget=${target}`,
|
||||||
|
{ headers: await bearer('1') }
|
||||||
|
)
|
||||||
|
expect(await detailResponse.json()).toMatchObject({
|
||||||
|
UnityAsset: target === 0 ? winName : androidName,
|
||||||
|
UnitySubAssets: [prefab, shared].map((id) => ({
|
||||||
|
UnityAssetId: id,
|
||||||
|
UnityAsset: publicStudioBundleFilename(
|
||||||
|
9901,
|
||||||
|
id,
|
||||||
|
target === 0 ? 'windows' : 'android',
|
||||||
|
'main'
|
||||||
|
),
|
||||||
|
UnityAssetHash: winHash,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await env.DB.prepare('DELETE FROM unity_asset WHERE unity_asset_id IN (?1,?2)')
|
||||||
|
.bind(prefab, shared)
|
||||||
|
.run()
|
||||||
|
})
|
||||||
|
|
||||||
it('loads baked bundles onto the room and serves the unity asset', async () => {
|
it('loads baked bundles onto the room and serves the unity asset', async () => {
|
||||||
await reset()
|
await reset()
|
||||||
|
|
||||||
@@ -7185,3 +7265,63 @@ describe('Studio source revision permissions', () => {
|
|||||||
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' })
|
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('omits hydrated Badges when persisting a room settings edit', async () => {
|
||||||
|
await seedRoomWithSubRooms(env.DB, {
|
||||||
|
RoomId: 987670,
|
||||||
|
Name: 'BadgeSerialization',
|
||||||
|
CreatorAccountId: 987670,
|
||||||
|
SubRooms: [],
|
||||||
|
})
|
||||||
|
await env.DB.prepare(
|
||||||
|
"UPDATE room SET data=json_set(data,'$.Badges',json(?)) WHERE room_id=987670"
|
||||||
|
)
|
||||||
|
.bind(JSON.stringify([{ Name: 'derived badge' }]))
|
||||||
|
.run()
|
||||||
|
const response = await SELF.fetch(`${ORIGIN}/rooms/987670/warning`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: await bearer('987670'),
|
||||||
|
body: new URLSearchParams({ warningMask: '1' }),
|
||||||
|
})
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const stored = await env.DB.prepare('SELECT data FROM room WHERE room_id=987670').first<{
|
||||||
|
data: string
|
||||||
|
}>()
|
||||||
|
expect(JSON.parse(stored!.data)).not.toHaveProperty('Badges')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The badge lookup runs on every room page, served by a partial index over the badge rows
|
||||||
|
// (api migrations/0035, mirrored in SCHEMA_DDL above). Only a PUBLISHED badge with a built save
|
||||||
|
// hydrates; a draft stays on its creator's Studio shelf.
|
||||||
|
it('GET /rooms/:id hydrates the room\'s published badges', async () => {
|
||||||
|
await seedRoomWithSubRooms(env.DB, {
|
||||||
|
RoomId: 987671,
|
||||||
|
Name: 'BadgeHydration',
|
||||||
|
CreatorAccountId: 987671,
|
||||||
|
SubRooms: [],
|
||||||
|
})
|
||||||
|
const badge = (id: string, roomId: number, accessibility: number, saves: unknown[]) =>
|
||||||
|
JSON.stringify({
|
||||||
|
CustomAvatarItemId: id,
|
||||||
|
CreatorAccountId: 987671,
|
||||||
|
Name: id,
|
||||||
|
OutfitType: 1000,
|
||||||
|
Accessibility: accessibility,
|
||||||
|
CurrentSaves: saves,
|
||||||
|
CustomBadgeMetadata: { RoomId: roomId },
|
||||||
|
})
|
||||||
|
const insert = env.DB.prepare('INSERT INTO custom_avatar_item (data) VALUES (?)')
|
||||||
|
await env.DB.batch([
|
||||||
|
insert.bind(badge('badge-published', 987671, 1, [{ BodyType: 0 }])),
|
||||||
|
insert.bind(badge('badge-draft', 987671, 0, [{ BodyType: 0 }])),
|
||||||
|
insert.bind(badge('badge-unbuilt', 987671, 1, [])),
|
||||||
|
insert.bind(badge('badge-other-room', 987670, 1, [{ BodyType: 0 }])),
|
||||||
|
])
|
||||||
|
// Twice: the second read takes the path that remembers the table exists.
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/rooms/987671`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const room = (await res.json()) as { Badges: Array<{ CustomAvatarItemId: string }> }
|
||||||
|
expect(room.Badges.map((b) => b.CustomAvatarItemId)).toEqual(['badge-published'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const UPLOAD_SUBFOLDER: Record<number, string> = {
|
|||||||
8: 'room',
|
8: 'room',
|
||||||
9: 'roomunityasset',
|
9: 'roomunityasset',
|
||||||
10: 'unityasset',
|
10: 'unityasset',
|
||||||
|
13: 'avatar',
|
||||||
|
14: 'avatarunityasset',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
|
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
|
||||||
@@ -380,6 +382,7 @@ const app = new Hono<App>()
|
|||||||
contentType = image.contentType
|
contentType = image.contentType
|
||||||
}
|
}
|
||||||
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
|
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
|
||||||
|
customMetadata: { ownerId: String(id) },
|
||||||
httpMetadata: { contentType },
|
httpMetadata: { contentType },
|
||||||
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
|
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
|
||||||
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
|
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
|
||||||
|
|||||||
@@ -21,6 +21,27 @@ beforeAll(async () => {
|
|||||||
// storage worker's validation accepts it.
|
// storage worker's validation accepts it.
|
||||||
const TEST_SECRET = 'test-signing-key'
|
const TEST_SECRET = 'test-signing-key'
|
||||||
|
|
||||||
|
it('stores avatar source and baked uploads with immutable account ownership metadata', async () => {
|
||||||
|
for (const [type, prefix] of [
|
||||||
|
[13, 'avatar'],
|
||||||
|
[14, 'avatarunityasset'],
|
||||||
|
] as const) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.set('fileType', String(type))
|
||||||
|
form.set('file', new File(['avatar source'], 'source.bin'))
|
||||||
|
const r = await SELF.fetch(ORIGIN + '/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer()), 'User-Agent': 'RRStudio' },
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
expect(r.status).toBe(200)
|
||||||
|
const body = await r.json<{ Filename: string; Hash: string }>()
|
||||||
|
expect(body.Hash).toBeTruthy()
|
||||||
|
const object = await env.CDN_ASSETS.head(prefix + '/' + body.Filename)
|
||||||
|
expect(object?.customMetadata?.ownerId).toBe('42')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function b64url(input: ArrayBuffer | string): string {
|
function b64url(input: ArrayBuffer | string): string {
|
||||||
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
||||||
let binary = ''
|
let binary = ''
|
||||||
|
|||||||
@@ -83,12 +83,16 @@ build ID with a null completion time. Repeated requests for an in-progress save
|
|||||||
reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status;
|
reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status;
|
||||||
`PUT /cloud-builds/cancel` cancels a queued or running job.
|
`PUT /cloud-builds/cancel` cancels a queued or running job.
|
||||||
|
|
||||||
Apply migration `0003_studio_cloud_build_job.sql`. Configure a private
|
Apply migration `0003_studio_cloud_build_job.sql`. Create the private
|
||||||
`CLOUD_BUILD_KEY` and run the outbound worker from `recflare/cloud-building` with
|
`CLOUD_BUILD_KEY` in the shared Secrets Store (the one `JWT_SECRET` lives in — see
|
||||||
the matching credential. Without a key, creation returns an explicit unavailable
|
`.env.example`) and run the outbound worker from `recflare/cloud-building` with the
|
||||||
response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and Android), and
|
same value as its `STUDIO_WORKER_KEY`. The binding must exist for the worker to
|
||||||
`CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169. Match installed editor modules to
|
deploy; a value of `off` disables cloud building, and creation then returns an
|
||||||
the enabled targets.
|
explicit unavailable response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and
|
||||||
|
Android), and `CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169 — the cloud-building
|
||||||
|
worker claims only jobs for its own configured template version (171 by default),
|
||||||
|
so set this to match or jobs queue and are never claimed. Match installed editor
|
||||||
|
modules to the enabled targets.
|
||||||
|
|
||||||
The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish`
|
The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish`
|
||||||
routes lease jobs to that worker. Only validated UnityFS artifacts for the
|
routes lease jobs to that worker. Only validated UnityFS artifacts for the
|
||||||
@@ -96,5 +100,20 @@ allocated asset IDs and requested platforms are accepted. SDK bundle version 5
|
|||||||
is retained. Completion links the submitted save; auto-publish does not replace
|
is retained. Completion links the submitted save; auto-publish does not replace
|
||||||
a newer draft. Failed, cancelled, or expired jobs never become successful builds.
|
a newer draft. Failed, cancelled, or expired jobs never become successful builds.
|
||||||
Claims include a signed, short-lived SDK validation token with no account subject
|
Claims include a signed, short-lived SDK validation token with no account subject
|
||||||
or roles. Deploy the paired API support: only bulk component-text validation
|
or roles. Bulk component-text validation and job-scoped grant reads accept this
|
||||||
accepts this credential. It cannot authenticate as an account or publish a room.
|
credential. It cannot authenticate as an account or publish a room.
|
||||||
|
|
||||||
|
## Avatar Studio and consumable validation
|
||||||
|
|
||||||
|
Avatar authoring uses per-account Studio grants, source revisions, and the managed
|
||||||
|
cloud queue. Apply `migrations/0004_avatar_studio.sql` to the shared database before
|
||||||
|
enabling these routes. The API owns creator saves and explicit publication; the
|
||||||
|
Studio service queues prefab builds and returns per-platform main/stripped bundles
|
||||||
|
at version 5 and material bundles at version 6. Image badges publish directly.
|
||||||
|
Changed body/configuration inputs receive distinct output IDs, and automatic
|
||||||
|
publication cannot replace a newer revision. Deploy the paired cloud-building
|
||||||
|
worker for prefab support; restricted validation credentials grant no account role.
|
||||||
|
|
||||||
|
The SDK's access override reads the target account's entitlement. Build-validation
|
||||||
|
credentials can inspect only the current owners of their active job's room; peers
|
||||||
|
can inspect grants only while they currently manage the same room.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id);
|
||||||
|
CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL);
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AVATAR_STUDIO_SLOTS,
|
||||||
|
avatarItemAccessBit,
|
||||||
|
avatarStudioFlags,
|
||||||
|
avatarStudioItem,
|
||||||
|
avatarStudioSave,
|
||||||
|
canManageRoom,
|
||||||
canManageRoomById,
|
canManageRoomById,
|
||||||
getRoomById,
|
getRoomById,
|
||||||
getSubRoom,
|
getSubRoom,
|
||||||
getSubRoomSaveById,
|
getSubRoomSaveById,
|
||||||
publicStudioBundleFilename,
|
publicStudioBundleFilename,
|
||||||
|
publishAvatarSave,
|
||||||
studioBundleR2Key,
|
studioBundleR2Key,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import {
|
import {
|
||||||
@@ -16,13 +23,28 @@ import {
|
|||||||
|
|
||||||
import { parseMultipart } from './multipart'
|
import { parseMultipart } from './multipart'
|
||||||
|
|
||||||
import type { App } from './context'
|
import type { Context } from 'hono'
|
||||||
|
import type { App, Env } from './context'
|
||||||
|
|
||||||
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
|
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
|
||||||
cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
|
cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
|
||||||
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
|
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
|
||||||
)`
|
)`
|
||||||
interface Job {
|
interface Job {
|
||||||
|
kind?: 'avatar'
|
||||||
|
avatarItemId?: string
|
||||||
|
avatar?: {
|
||||||
|
itemId: string
|
||||||
|
outfitType: number
|
||||||
|
bodyType: number
|
||||||
|
configuration: string
|
||||||
|
name: string
|
||||||
|
createdAt: string
|
||||||
|
price: number
|
||||||
|
creatorAccountId: number
|
||||||
|
canCreateRoomieEquipment: boolean
|
||||||
|
permittedItemTypes: number[]
|
||||||
|
}
|
||||||
buildId: string
|
buildId: string
|
||||||
roomId: number
|
roomId: number
|
||||||
subRoomId: number
|
subRoomId: number
|
||||||
@@ -34,7 +56,8 @@ interface Job {
|
|||||||
targets: number[]
|
targets: number[]
|
||||||
manifest: {
|
manifest: {
|
||||||
Version: number
|
Version: number
|
||||||
MainSceneProjectRelativePath: string
|
MainSceneProjectRelativePath?: string
|
||||||
|
MainPrefabProjectRelativePath?: string
|
||||||
ExportPartitionVersion: number
|
ExportPartitionVersion: number
|
||||||
Files: Array<{ n: string; p: string; g?: string }>
|
Files: Array<{ n: string; p: string; g?: string }>
|
||||||
Exports?: Array<{ e: string; d?: string[] }>
|
Exports?: Array<{ e: string; d?: string[] }>
|
||||||
@@ -51,6 +74,7 @@ const ok = (value: unknown) => ({ success: true, error: null, value })
|
|||||||
const fail = (error: string) => ({ success: false, error, value: null })
|
const fail = (error: string) => ({ success: false, error, value: null })
|
||||||
const ID = /^[1-9]\d*$/
|
const ID = /^[1-9]\d*$/
|
||||||
const guid = () => crypto.randomUUID()
|
const guid = () => crypto.randomUUID()
|
||||||
|
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
|
||||||
interface Artifact {
|
interface Artifact {
|
||||||
field: string
|
field: string
|
||||||
unityAssetId: string
|
unityAssetId: string
|
||||||
@@ -84,7 +108,6 @@ interface CloudBuildRecord {
|
|||||||
unity_asset_id: string
|
unity_asset_id: string
|
||||||
created_by_account_id: number
|
created_by_account_id: number
|
||||||
}
|
}
|
||||||
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
|
|
||||||
function wire(row: CloudBuildRecord) {
|
function wire(row: CloudBuildRecord) {
|
||||||
return {
|
return {
|
||||||
cloudBuildId: row.cloud_build_id,
|
cloudBuildId: row.cloud_build_id,
|
||||||
@@ -99,7 +122,21 @@ function wire(row: CloudBuildRecord) {
|
|||||||
: null,
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function privateAuth(request: Request, key: string | undefined) {
|
/**
|
||||||
|
* The pull worker's credential, resolved per request from the Secrets Store. Returns ''
|
||||||
|
* when cloud building is off: the secret holds the literal `off` (the binding has to
|
||||||
|
* resolve for the worker to deploy, so that is how an operator opts out) or can't be read.
|
||||||
|
* Every gate on "is cloud building configured" goes through here.
|
||||||
|
*/
|
||||||
|
export async function cloudBuildKey(env: Env): Promise<string> {
|
||||||
|
try {
|
||||||
|
const key = (await env.CLOUD_BUILD_KEY.get()) ?? ''
|
||||||
|
return key === 'off' ? '' : key
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function privateAuth(request: Request, key: string) {
|
||||||
if (!key) return false
|
if (!key) return false
|
||||||
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
|
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
|
||||||
const got = await crypto.subtle.digest(
|
const got = await crypto.subtle.digest(
|
||||||
@@ -116,7 +153,48 @@ async function pendingBuild(db: D1Database, roomId: number, subRoomId: number, s
|
|||||||
.bind(roomId, subRoomId, saveId)
|
.bind(roomId, subRoomId, saveId)
|
||||||
.first<CloudBuildRecord>()
|
.first<CloudBuildRecord>()
|
||||||
}
|
}
|
||||||
|
async function pendingAvatarBuild(db: D1Database, itemId: string, saveId: number) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
"SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) JOIN studio_cloud_build_job j USING(cloud_build_id) WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing') LIMIT 1"
|
||||||
|
)
|
||||||
|
.bind(itemId, saveId)
|
||||||
|
.first<CloudBuildRecord>()
|
||||||
|
}
|
||||||
const app = new Hono<App>()
|
const app = new Hono<App>()
|
||||||
|
.get('/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}', async (c) => {
|
||||||
|
const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
|
if (id === null) return c.body(null, 401)
|
||||||
|
const item = await avatarStudioItem(c.env.DB, c.req.param('itemId'))
|
||||||
|
if (!item || item.CreatorAccountId !== id) return c.body(null, 403)
|
||||||
|
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
|
||||||
|
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
|
||||||
|
const { results } = await c.env.DB.prepare(
|
||||||
|
'SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) WHERE a.item_id=? ORDER BY b.started_at DESC'
|
||||||
|
)
|
||||||
|
.bind(item.CustomAvatarItemId)
|
||||||
|
.all<CloudBuildRecord>()
|
||||||
|
return c.json({
|
||||||
|
results: results.slice(skip, skip + take).map(wire),
|
||||||
|
totalResults: results.length,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post(
|
||||||
|
'/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}/save/:saveId{[1-9][0-9]*}',
|
||||||
|
(c) => enqueueAvatar(c, c.req.param('itemId'), Number(c.req.param('saveId')))
|
||||||
|
)
|
||||||
|
.post('/cloud-builds/custom-avatar-item/create-for-new-build-version', async (c) => {
|
||||||
|
const form = await c.req.parseBody()
|
||||||
|
const itemId = String(form.customAvatarItemId ?? ''),
|
||||||
|
assetId = String(form.unityAssetId ?? '')
|
||||||
|
const row = await c.env.DB.prepare(
|
||||||
|
'SELECT id FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
|
||||||
|
)
|
||||||
|
.bind(itemId, assetId)
|
||||||
|
.first<{ id: number }>()
|
||||||
|
if (!row) return c.notFound()
|
||||||
|
return enqueueAvatar(c, itemId, row.id, form)
|
||||||
|
})
|
||||||
.post('/cloud-builds/create', async (c) => {
|
.post('/cloud-builds/create', async (c) => {
|
||||||
const secret = await c.env.JWT_SECRET.get()
|
const secret = await c.env.JWT_SECRET.get()
|
||||||
const accountId = await validateAndGetAccountId(c.req.raw, secret)
|
const accountId = await validateAndGetAccountId(c.req.raw, secret)
|
||||||
@@ -124,7 +202,7 @@ const app = new Hono<App>()
|
|||||||
if (accountId === null) return c.body(null, 401)
|
if (accountId === null) return c.body(null, 401)
|
||||||
if (!roles?.includes('betastudio'))
|
if (!roles?.includes('betastudio'))
|
||||||
return c.json(fail('Studio upload authorization is required.'), 403)
|
return c.json(fail('Studio upload authorization is required.'), 403)
|
||||||
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503)
|
if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
|
||||||
const form = await c.req.parseBody()
|
const form = await c.req.parseBody()
|
||||||
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
|
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
|
||||||
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id))))
|
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id))))
|
||||||
@@ -272,11 +350,16 @@ const app = new Hono<App>()
|
|||||||
const form = await c.req.parseBody()
|
const form = await c.req.parseBody()
|
||||||
const id = textField(form.cloudBuildId)
|
const id = textField(form.cloudBuildId)
|
||||||
const row = await c.env.DB.prepare(
|
const row = await c.env.DB.prepare(
|
||||||
'SELECT room_id FROM studio_cloud_build WHERE cloud_build_id = ?'
|
'SELECT room_id,created_by_account_id FROM studio_cloud_build WHERE cloud_build_id = ?'
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.first<{ room_id: number }>()
|
.first<{ room_id: number; created_by_account_id: number }>()
|
||||||
if (!row || !(await canManageRoomById(c.env.DB, row.room_id, accountId)))
|
if (
|
||||||
|
!row ||
|
||||||
|
!(row.room_id === 0
|
||||||
|
? row.created_by_account_id === accountId
|
||||||
|
: await canManageRoomById(c.env.DB, row.room_id, accountId))
|
||||||
|
)
|
||||||
return c.json(fail('No accessible build.'), 404)
|
return c.json(fail('No accessible build.'), 404)
|
||||||
await c.env.DB.batch([
|
await c.env.DB.batch([
|
||||||
c.env.DB.prepare(
|
c.env.DB.prepare(
|
||||||
@@ -289,7 +372,7 @@ const app = new Hono<App>()
|
|||||||
return c.json(ok(null))
|
return c.json(ok(null))
|
||||||
})
|
})
|
||||||
.use('/internal/cloud-builds/*', async (c, next) => {
|
.use('/internal/cloud-builds/*', async (c, next) => {
|
||||||
if (!(await privateAuth(c.req.raw, c.env.CLOUD_BUILD_KEY))) return c.body(null, 401)
|
if (!(await privateAuth(c.req.raw, await cloudBuildKey(c.env)))) return c.body(null, 401)
|
||||||
return next()
|
return next()
|
||||||
})
|
})
|
||||||
.post('/internal/cloud-builds/claim', async (c) => {
|
.post('/internal/cloud-builds/claim', async (c) => {
|
||||||
@@ -325,17 +408,13 @@ const app = new Hono<App>()
|
|||||||
})
|
})
|
||||||
.post('/internal/cloud-builds/:id/heartbeat', async (c) => {
|
.post('/internal/cloud-builds/:id/heartbeat', async (c) => {
|
||||||
const { leaseToken } = await c.req.json<{ leaseToken: string }>()
|
const { leaseToken } = await c.req.json<{ leaseToken: string }>()
|
||||||
|
const leaseUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString()
|
||||||
const result = await c.env.DB.prepare(
|
const result = await c.env.DB.prepare(
|
||||||
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
|
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(leaseUntil, c.req.param('id'), leaseToken, new Date().toISOString())
|
||||||
new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
||||||
c.req.param('id'),
|
|
||||||
leaseToken,
|
|
||||||
new Date().toISOString()
|
|
||||||
)
|
|
||||||
.run()
|
.run()
|
||||||
return c.json({ active: result.meta.changes > 0 })
|
return c.json(result.meta.changes > 0 ? { active: true, leaseUntil } : { active: false })
|
||||||
})
|
})
|
||||||
.post('/internal/cloud-builds/:id/finish', async (c) => {
|
.post('/internal/cloud-builds/:id/finish', async (c) => {
|
||||||
const form = await parseMultipart(c.req.raw)
|
const form = await parseMultipart(c.req.raw)
|
||||||
@@ -359,6 +438,26 @@ const app = new Hono<App>()
|
|||||||
])
|
])
|
||||||
return c.json(ok(null))
|
return c.json(ok(null))
|
||||||
}
|
}
|
||||||
|
if (job.kind === 'avatar') {
|
||||||
|
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
|
||||||
|
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
|
||||||
|
if (
|
||||||
|
!item ||
|
||||||
|
!save ||
|
||||||
|
item.CreatorAccountId !== job.accountId ||
|
||||||
|
!((await avatarStudioFlags(c.env.DB, job.accountId)) & avatarItemAccessBit(item.OutfitType))
|
||||||
|
) {
|
||||||
|
await c.env.DB.batch([
|
||||||
|
c.env.DB.prepare(
|
||||||
|
"UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='building'"
|
||||||
|
).bind(id, lease.lease_token),
|
||||||
|
c.env.DB.prepare(
|
||||||
|
"UPDATE studio_cloud_build SET completed_at=?,error='Avatar source or authorization was removed.' WHERE cloud_build_id=? AND completed_at=''"
|
||||||
|
).bind(new Date().toISOString(), id),
|
||||||
|
])
|
||||||
|
return c.json(fail('Avatar source or authorization was removed.'), 409)
|
||||||
|
}
|
||||||
|
}
|
||||||
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
|
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
|
||||||
let entries: unknown
|
let entries: unknown
|
||||||
try {
|
try {
|
||||||
@@ -368,7 +467,10 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
if (!Array.isArray(entries) || !entries.every(isArtifact))
|
if (!Array.isArray(entries) || !entries.every(isArtifact))
|
||||||
return c.json(fail('Invalid build artifact metadata.'), 400)
|
return c.json(fail('Invalid build artifact metadata.'), 400)
|
||||||
if (!entries.length || entries.length > assets.size * job.targets.length * 2)
|
if (
|
||||||
|
!entries.length ||
|
||||||
|
entries.length > assets.size * job.targets.length * (job.kind === 'avatar' ? 3 : 2)
|
||||||
|
)
|
||||||
return c.json(fail('Missing build artifacts.'), 400)
|
return c.json(fail('Missing build artifacts.'), 400)
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -377,8 +479,10 @@ const app = new Hono<App>()
|
|||||||
if (
|
if (
|
||||||
!assets.has(entry.unityAssetId) ||
|
!assets.has(entry.unityAssetId) ||
|
||||||
!job.targets.includes(entry.target) ||
|
!job.targets.includes(entry.target) ||
|
||||||
!['main', 'stripped'].includes(entry.kind) ||
|
!(job.kind === 'avatar' ? ['main', 'stripped', 'material'] : ['main', 'stripped']).includes(
|
||||||
entry.version !== 5 ||
|
entry.kind
|
||||||
|
) ||
|
||||||
|
entry.version !== (job.kind === 'avatar' && entry.kind === 'material' ? 6 : 5) ||
|
||||||
!(file instanceof File) ||
|
!(file instanceof File) ||
|
||||||
file.size === 0 ||
|
file.size === 0 ||
|
||||||
seen.has(key)
|
seen.has(key)
|
||||||
@@ -390,6 +494,11 @@ const app = new Hono<App>()
|
|||||||
for (const target of job.targets)
|
for (const target of job.targets)
|
||||||
if (!seen.has(`${asset}:${target}:main`))
|
if (!seen.has(`${asset}:${target}:main`))
|
||||||
return c.json(fail('A required asset bundle is missing.'), 400)
|
return c.json(fail('A required asset bundle is missing.'), 400)
|
||||||
|
if (
|
||||||
|
job.kind === 'avatar' &&
|
||||||
|
job.targets.some((target) => !seen.has(`${job.unityAssetId}:${target}:material`))
|
||||||
|
)
|
||||||
|
return c.json(fail('An avatar material bundle is missing.'), 400)
|
||||||
const writes: D1PreparedStatement[] = []
|
const writes: D1PreparedStatement[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const file = form[entry.field] as File
|
const file = form[entry.field] as File
|
||||||
@@ -416,8 +525,11 @@ const app = new Hono<App>()
|
|||||||
entry.target === 2 ? 'android' : 'windows',
|
entry.target === 2 ? 'android' : 'windows',
|
||||||
entry.kind
|
entry.kind
|
||||||
)
|
)
|
||||||
const filename = baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
|
const filename =
|
||||||
const key = studioBundleR2Key(filename)
|
job.kind === 'avatar'
|
||||||
|
? `${entry.target === 2 ? 'quest/' : ''}studio-avatar/${job.avatarItemId}/${entry.unityAssetId}/${lease.lease_token}.${entry.kind}.assetbundle`
|
||||||
|
: baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
|
||||||
|
const key = job.kind === 'avatar' ? `avatar/${filename}` : studioBundleR2Key(filename)
|
||||||
await c.env.CDN_ASSETS.put(key, bytes)
|
await c.env.CDN_ASSETS.put(key, bytes)
|
||||||
keys.push(key)
|
keys.push(key)
|
||||||
writes.push(
|
writes.push(
|
||||||
@@ -439,48 +551,52 @@ const app = new Hono<App>()
|
|||||||
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
|
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
|
||||||
keys.push(serverDataKey)
|
keys.push(serverDataKey)
|
||||||
// The exact submitted save is linked; a newer draft must never be replaced.
|
// The exact submitted save is linked; a newer draft must never be replaced.
|
||||||
writes.push(
|
if (job.kind !== 'avatar')
|
||||||
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.
|
|
||||||
writes.push(
|
writes.push(
|
||||||
c.env.DB.prepare(
|
c.env.DB.prepare(
|
||||||
`UPDATE subroom SET current_save_id=?,
|
"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')"
|
||||||
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(
|
).bind(
|
||||||
job.saveId,
|
job.unityAssetId,
|
||||||
job.saveId,
|
JSON.stringify({ Exports: job.exports }),
|
||||||
job.roomId,
|
|
||||||
job.subRoomId,
|
|
||||||
job.saveId,
|
|
||||||
job.saveId,
|
job.saveId,
|
||||||
job.subRoomId,
|
job.subRoomId,
|
||||||
id,
|
id,
|
||||||
lease.lease_token
|
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(
|
writes.push(
|
||||||
c.env.DB.prepare(
|
c.env.DB.prepare(
|
||||||
"UPDATE studio_cloud_build SET completed_at=?,error=NULL WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
"UPDATE studio_cloud_build SET completed_at=?,error=NULL WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
||||||
@@ -491,20 +607,42 @@ const app = new Hono<App>()
|
|||||||
"UPDATE studio_cloud_build_job SET status='completed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
|
"UPDATE studio_cloud_build_job SET status='completed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
|
||||||
).bind(id, lease.lease_token)
|
).bind(id, lease.lease_token)
|
||||||
)
|
)
|
||||||
// Recheck the lease and target inside the publication transaction. R2
|
// Recheck authority, lease and target inside the final publication transaction.
|
||||||
// uploads can outlast a lease, and a room can be deleted while compiling.
|
|
||||||
const publicationTime = new Date().toISOString()
|
const publicationTime = new Date().toISOString()
|
||||||
|
const roomRow =
|
||||||
|
job.kind !== 'avatar'
|
||||||
|
? await c.env.DB.prepare('SELECT data FROM room WHERE room_id=?')
|
||||||
|
.bind(job.roomId)
|
||||||
|
.first<{ data: string }>()
|
||||||
|
: null
|
||||||
|
const authorizedRoom =
|
||||||
|
roomRow && canManageRoom(JSON.parse(roomRow.data), job.accountId) ? roomRow.data : null
|
||||||
|
const targetExists =
|
||||||
|
job.kind === 'avatar'
|
||||||
|
? `SELECT 1 FROM avatar_studio_save s JOIN custom_avatar_item i ON i.custom_avatar_item_id=s.item_id
|
||||||
|
JOIN avatar_studio_access a ON a.account_id=json_extract(i.data,'$.CreatorAccountId')
|
||||||
|
WHERE s.id=? AND s.item_id=? AND s.unity_asset_id=? AND a.account_id=? AND (a.flags & ?)<>0`
|
||||||
|
: `SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
|
||||||
|
JOIN room ON room.room_id=r.room_id
|
||||||
|
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=? AND room.data=?`
|
||||||
|
const targetBindings =
|
||||||
|
job.kind === 'avatar'
|
||||||
|
? [
|
||||||
|
job.saveId,
|
||||||
|
job.avatarItemId!,
|
||||||
|
job.unityAssetId,
|
||||||
|
job.accountId,
|
||||||
|
avatarItemAccessBit(job.avatar!.outfitType),
|
||||||
|
]
|
||||||
|
: [job.saveId, job.subRoomId, job.roomId, authorizedRoom]
|
||||||
const results = await c.env.DB.batch([
|
const results = await c.env.DB.batch([
|
||||||
c.env.DB.prepare(
|
c.env.DB.prepare(
|
||||||
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
|
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
|
||||||
WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
|
WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
|
||||||
AND (lease_until<=? OR NOT EXISTS (
|
AND (lease_until<=? OR NOT EXISTS (${targetExists}))`
|
||||||
SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
|
).bind(id, lease.lease_token, publicationTime, ...targetBindings),
|
||||||
JOIN room ON room.room_id=r.room_id
|
|
||||||
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=?))`
|
|
||||||
).bind(id, lease.lease_token, publicationTime, job.saveId, job.subRoomId, job.roomId),
|
|
||||||
c.env.DB.prepare(
|
c.env.DB.prepare(
|
||||||
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target save removed.'
|
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target authorization/save removed.'
|
||||||
WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
|
WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
|
||||||
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
|
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
|
||||||
).bind(publicationTime, id, id),
|
).bind(publicationTime, id, id),
|
||||||
@@ -512,7 +650,7 @@ const app = new Hono<App>()
|
|||||||
])
|
])
|
||||||
if (!results[results.length - 1].meta.changes) {
|
if (!results[results.length - 1].meta.changes) {
|
||||||
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
||||||
return c.json(fail('Build lease expired or target save removed.'), 409)
|
return c.json(fail('Build lease expired or target authorization/save removed.'), 409)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
||||||
@@ -523,6 +661,156 @@ const app = new Hono<App>()
|
|||||||
.run()
|
.run()
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
if (job.kind === 'avatar') {
|
||||||
|
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
|
||||||
|
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
save &&
|
||||||
|
(job.autoPublish || (item.Accessibility === 0 && save.update_current === 1))
|
||||||
|
) {
|
||||||
|
await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id, true)
|
||||||
|
}
|
||||||
|
return c.json(ok(null))
|
||||||
|
}
|
||||||
|
|
||||||
return c.json(ok(null))
|
return c.json(ok(null))
|
||||||
})
|
})
|
||||||
export default app
|
export default app
|
||||||
|
|
||||||
|
async function enqueueAvatar(
|
||||||
|
c: Context<App>,
|
||||||
|
itemId: string,
|
||||||
|
saveId: number,
|
||||||
|
supplied?: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
|
if (accountId === null) return c.body(null, 401)
|
||||||
|
const item = await avatarStudioItem(c.env.DB, itemId),
|
||||||
|
save = await avatarStudioSave(c.env.DB, itemId, saveId)
|
||||||
|
if (!item || !save) return c.notFound()
|
||||||
|
if (item.OutfitType === 1000)
|
||||||
|
return c.json(fail('Badge images publish directly and do not require a Unity build.'), 400)
|
||||||
|
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
|
if (
|
||||||
|
item.CreatorAccountId !== accountId ||
|
||||||
|
!roles?.includes('betastudio') ||
|
||||||
|
!((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(item.OutfitType))
|
||||||
|
)
|
||||||
|
return c.body(null, 403)
|
||||||
|
if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
|
||||||
|
const pending = await pendingAvatarBuild(c.env.DB, itemId, saveId)
|
||||||
|
if (pending) return c.json(ok(wire(pending)))
|
||||||
|
const object = await c.env.CDN_ASSETS.get('avatarunityasset/' + save.source_filename)
|
||||||
|
if (!object || object.size > 8 * 1024 * 1024)
|
||||||
|
return c.json(fail('Missing or oversized avatar source manifest.'), 400)
|
||||||
|
let manifest: Job['manifest']
|
||||||
|
try {
|
||||||
|
manifest = await object.json<Job['manifest']>()
|
||||||
|
} catch {
|
||||||
|
return c.json(fail('Invalid source manifest JSON.'), 400)
|
||||||
|
}
|
||||||
|
const main = manifest?.MainPrefabProjectRelativePath
|
||||||
|
if (
|
||||||
|
!manifest ||
|
||||||
|
manifest.Version !== 3 ||
|
||||||
|
typeof main !== 'string' ||
|
||||||
|
!main.startsWith('Assets/') ||
|
||||||
|
!main.endsWith('.prefab') ||
|
||||||
|
!Array.isArray(manifest.Files) ||
|
||||||
|
!manifest.Files.length ||
|
||||||
|
manifest.Files.length > 100000 ||
|
||||||
|
!manifest.Files.some((f) => f?.p === main) ||
|
||||||
|
manifest.Files.some(
|
||||||
|
(f) =>
|
||||||
|
!f ||
|
||||||
|
typeof f.p !== 'string' ||
|
||||||
|
!/^(Assets|Packages)\//.test(f.p) ||
|
||||||
|
f.p.includes('\\') ||
|
||||||
|
f.p.includes(':') ||
|
||||||
|
f.p
|
||||||
|
.split('/')
|
||||||
|
.some(
|
||||||
|
(p) => p === '..' || p === '.' || p === '' || p.toLowerCase().endsWith('.bundle')
|
||||||
|
) ||
|
||||||
|
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
|
||||||
|
!/^[a-z0-9]{40}$/.test(f.n)
|
||||||
|
) ||
|
||||||
|
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
|
||||||
|
)
|
||||||
|
return c.json(fail('Invalid avatar source file manifest.'), 400)
|
||||||
|
const form = supplied ?? (await c.req.parseBody())
|
||||||
|
if (form.pipelineVersion !== undefined && String(form.pipelineVersion) !== '2')
|
||||||
|
return c.json(fail('Unsupported avatar build pipeline.'), 400)
|
||||||
|
const targets = (c.env.CLOUD_BUILD_TARGETS ?? '0,2').split(',').map(Number)
|
||||||
|
if (!targets.length || targets.some((t) => t !== 0 && t !== 2))
|
||||||
|
return c.json(fail('Invalid build targets.'), 503)
|
||||||
|
const access = await avatarStudioFlags(c.env.DB, accountId)
|
||||||
|
const permittedItemTypes = AVATAR_STUDIO_SLOTS.filter(
|
||||||
|
(slot) =>
|
||||||
|
(access & avatarItemAccessBit(slot)) !== 0 &&
|
||||||
|
(![104, 203].includes(slot) || roles.includes('developer'))
|
||||||
|
)
|
||||||
|
const job: Job = {
|
||||||
|
kind: 'avatar',
|
||||||
|
avatarItemId: itemId,
|
||||||
|
avatar: {
|
||||||
|
itemId,
|
||||||
|
outfitType: item.OutfitType,
|
||||||
|
bodyType: save.body_type,
|
||||||
|
configuration: save.configuration,
|
||||||
|
name: String(item.Name ?? ''),
|
||||||
|
createdAt: save.created_at,
|
||||||
|
price: Number(item.Price ?? 0),
|
||||||
|
creatorAccountId: accountId,
|
||||||
|
canCreateRoomieEquipment: roles.includes('developer'),
|
||||||
|
permittedItemTypes,
|
||||||
|
},
|
||||||
|
buildId: numericId(),
|
||||||
|
roomId: 0,
|
||||||
|
subRoomId: 0,
|
||||||
|
saveId,
|
||||||
|
accountId,
|
||||||
|
unityAssetId: save.unity_asset_id,
|
||||||
|
autoPublish: textField(form.autoPublish).toLowerCase() === 'true',
|
||||||
|
templateVersion: Number(c.env.CLOUD_BUILD_TEMPLATE_VERSION ?? 169),
|
||||||
|
targets,
|
||||||
|
manifest,
|
||||||
|
room: null,
|
||||||
|
exports: [],
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
// As for rooms, reserve the build and its queue/link rows in one transaction.
|
||||||
|
const created = await c.env.DB.batch([
|
||||||
|
c.env.DB.prepare(
|
||||||
|
`INSERT INTO studio_cloud_build(cloud_build_id,room_id,sub_room_id,sub_room_data_save_id,unity_asset_id,created_by_account_id,started_at,completed_at,error)
|
||||||
|
SELECT ?,0,0,?,?,?,?,'',NULL WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM studio_avatar_build a JOIN studio_cloud_build_job j USING(cloud_build_id)
|
||||||
|
WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing'))`
|
||||||
|
).bind(job.buildId, saveId, job.unityAssetId, accountId, now, itemId, saveId),
|
||||||
|
c.env.DB.prepare(
|
||||||
|
'INSERT INTO studio_avatar_build(cloud_build_id,item_id,save_id) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
|
||||||
|
).bind(job.buildId, itemId, saveId, job.buildId),
|
||||||
|
c.env.DB.prepare(
|
||||||
|
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,created_at) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
|
||||||
|
).bind(job.buildId, JSON.stringify(job), now, job.buildId),
|
||||||
|
])
|
||||||
|
if (!created[0].meta.changes) {
|
||||||
|
const existing = await pendingAvatarBuild(c.env.DB, itemId, saveId)
|
||||||
|
return existing
|
||||||
|
? c.json(ok(wire(existing)))
|
||||||
|
: c.json(fail('Build state changed; retry creation.'), 409)
|
||||||
|
}
|
||||||
|
return c.json(
|
||||||
|
ok(
|
||||||
|
wire({
|
||||||
|
cloud_build_id: job.buildId,
|
||||||
|
started_at: now,
|
||||||
|
completed_at: null,
|
||||||
|
unity_asset_id: job.unityAssetId,
|
||||||
|
created_by_account_id: accountId,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,12 +17,23 @@ export type Env = SharedHonoEnv & {
|
|||||||
// Shared `recflare-cdn` bucket. Local Studio builds land under
|
// Shared `recflare-cdn` bucket. Local Studio builds land under
|
||||||
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
|
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
|
||||||
CDN_ASSETS: R2Bucket
|
CDN_ASSETS: R2Bucket
|
||||||
/** Private pull-worker credential; leave unset to disable cloud build dispatch. */
|
/**
|
||||||
CLOUD_BUILD_KEY?: string
|
* Private pull-worker credential, out of the shared Secrets Store (same store as
|
||||||
|
* JWT_SECRET). Read it through `cloudBuildKey()` in cloud-builds.ts, never `.get()`
|
||||||
|
* directly: the value `off` (or an unreadable secret) means cloud building is disabled.
|
||||||
|
*/
|
||||||
|
CLOUD_BUILD_KEY: SecretsStoreSecret
|
||||||
CLOUD_BUILD_TARGETS?: string
|
CLOUD_BUILD_TARGETS?: string
|
||||||
CLOUD_BUILD_TEMPLATE_VERSION?: string
|
CLOUD_BUILD_TEMPLATE_VERSION?: string
|
||||||
/** R2 key of an operator-published template catalog; unset disables downloads. */
|
/** R2 key of an operator-published template catalog; unset disables downloads. */
|
||||||
STUDIO_DISTRIBUTION_MANIFEST?: string
|
STUDIO_DISTRIBUTION_MANIFEST?: string
|
||||||
|
/**
|
||||||
|
* Discord role ids whose holders have Studio upload access without the account's
|
||||||
|
* `hasStudio` flag (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`; the same var `auth`
|
||||||
|
* stamps `betastudio` from). The access lookups here rebuild the role from the account
|
||||||
|
* rather than a token, so they read it through the same `hasStudioAccess`.
|
||||||
|
*/
|
||||||
|
STUDIO_ROLLOUT?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -109,22 +109,29 @@ export async function getUserBuildCount(
|
|||||||
const end = new Date(start.getTime() + 60 * 60 * 1000)
|
const end = new Date(start.getTime() + 60 * 60 * 1000)
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT COUNT(*) AS total,
|
`SELECT SUM(CASE WHEN room_id!=0 THEN 1 ELSE 0 END) AS total,
|
||||||
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS failed
|
SUM(CASE WHEN room_id!=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS failed,
|
||||||
|
SUM(CASE WHEN room_id=0 THEN 1 ELSE 0 END) AS avatar_total,
|
||||||
|
SUM(CASE WHEN room_id=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS avatar_failed
|
||||||
FROM studio_cloud_build
|
FROM studio_cloud_build
|
||||||
WHERE created_by_account_id = ?1
|
WHERE created_by_account_id = ?1
|
||||||
AND started_at >= ?2 AND started_at < ?3`
|
AND started_at >= ?2 AND started_at < ?3`
|
||||||
)
|
)
|
||||||
.bind(accountId, start.toISOString(), end.toISOString())
|
.bind(accountId, start.toISOString(), end.toISOString())
|
||||||
.first<{ total: number | string; failed: number | string | null }>()
|
.first<{
|
||||||
|
total: number | string
|
||||||
|
failed: number | string | null
|
||||||
|
avatar_total: number | null
|
||||||
|
avatar_failed: number | null
|
||||||
|
}>()
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
startTime: start.toISOString(),
|
startTime: start.toISOString(),
|
||||||
endTime: end.toISOString(),
|
endTime: end.toISOString(),
|
||||||
subRoomTotalBuildCount: Number(row?.total ?? 0),
|
subRoomTotalBuildCount: Number(row?.total ?? 0),
|
||||||
subRoomFailedBuildCount: Number(row?.failed ?? 0),
|
subRoomFailedBuildCount: Number(row?.failed ?? 0),
|
||||||
avatarItemTotalBuildCount: 0,
|
avatarItemTotalBuildCount: Number(row?.avatar_total ?? 0),
|
||||||
avatarItemFailedBuildCount: 0,
|
avatarItemFailedBuildCount: Number(row?.avatar_failed ?? 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
canManageRoomById,
|
canManageRoomById,
|
||||||
|
getAccount,
|
||||||
getPlayerIdsInRoomSubRoom,
|
getPlayerIdsInRoomSubRoom,
|
||||||
getRoomCoOwnerIds,
|
getRoomCoOwnerIds,
|
||||||
getSubRoom,
|
getSubRoom,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
import { studioBuildValidationId, validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||||
|
|
||||||
|
import { hasStudioAccess } from '../../auth/src/platform-db'
|
||||||
import cloudBuilds from './cloud-builds'
|
import cloudBuilds from './cloud-builds'
|
||||||
import distribution from './distribution'
|
import distribution from './distribution'
|
||||||
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
|
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
|
||||||
@@ -30,6 +32,13 @@ function positiveId(raw: string | undefined): number | null {
|
|||||||
return Number.isSafeInteger(id) ? id : null
|
return Number.isSafeInteger(id) ? id : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SQLite datetime() is UTC, but its space-separated output is not a .NET JSON DateTime. */
|
||||||
|
function accessTimestamp(value: string | undefined, fallback: string): string {
|
||||||
|
const normalized = value?.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/, '$1T$2Z')
|
||||||
|
const time = normalized ? Date.parse(normalized) : NaN
|
||||||
|
return Number.isFinite(time) ? new Date(time).toISOString() : fallback
|
||||||
|
}
|
||||||
|
|
||||||
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
|
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
|
||||||
const UPLOAD_FIRST =
|
const UPLOAD_FIRST =
|
||||||
'Upload the room before building locally. The latest save is where this build is recorded.'
|
'Upload the room before building locally. The latest save is where this build is recorded.'
|
||||||
@@ -168,6 +177,75 @@ const app = new Hono<App>()
|
|||||||
.get('/', async (c) => {
|
.get('/', async (c) => {
|
||||||
return c.text('hello, world!')
|
return c.text('hello, world!')
|
||||||
})
|
})
|
||||||
|
.get('/access/override/:accountId{[1-9][0-9]*}', async (c) => {
|
||||||
|
const secret = await c.env.JWT_SECRET.get()
|
||||||
|
const caller = await validateAndGetAccountId(c.req.raw, secret)
|
||||||
|
const id = Number(c.req.param('accountId'))
|
||||||
|
if (!Number.isSafeInteger(id)) return c.body(null, 400)
|
||||||
|
let roles = await validateAndGetRoles(c.req.raw, secret)
|
||||||
|
if (caller === null) {
|
||||||
|
const buildId = await studioBuildValidationId(c.req.raw, secret)
|
||||||
|
if (!buildId) return c.body(null, 401)
|
||||||
|
const job = await c.env.DB.prepare(
|
||||||
|
"SELECT payload FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='building' AND lease_until>?"
|
||||||
|
)
|
||||||
|
.bind(buildId, new Date().toISOString())
|
||||||
|
.first<{ payload: string }>()
|
||||||
|
if (!job) return c.body(null, 403)
|
||||||
|
const payload = JSON.parse(job.payload) as {
|
||||||
|
roomId: number
|
||||||
|
room?: { Roles?: Array<{ AccountId: number; Role: number }> }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!payload.roomId ||
|
||||||
|
!payload.room?.Roles?.some((r) => r.AccountId === id && [30, 255].includes(r.Role))
|
||||||
|
)
|
||||||
|
return c.body(null, 403)
|
||||||
|
if (!(await getRoomCoOwnerIds(c.env.DB, payload.roomId))?.includes(id))
|
||||||
|
return c.body(null, 403)
|
||||||
|
const account = await getAccount(c.env.DB, id)
|
||||||
|
roles = (await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT)) ? ['betastudio'] : []
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
caller !== null &&
|
||||||
|
caller !== id &&
|
||||||
|
!roles?.some((r) => r === 'developer' || r === 'moderator')
|
||||||
|
) {
|
||||||
|
// The SDK validates a registry against every owner of its room. Allow
|
||||||
|
// peers to inspect grants only when both currently manage the same room.
|
||||||
|
const shared = await c.env.DB.prepare(
|
||||||
|
`SELECT 1 FROM room r WHERE
|
||||||
|
(r.creator_account_id=?1 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?1 AND json_extract(role.value,'$.Role') IN (30,255))) AND
|
||||||
|
(r.creator_account_id=?2 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?2 AND json_extract(role.value,'$.Role') IN (30,255))) LIMIT 1`
|
||||||
|
)
|
||||||
|
.bind(caller, id)
|
||||||
|
.first()
|
||||||
|
if (!shared) return c.body(null, 403)
|
||||||
|
}
|
||||||
|
// Inspection authorization above belongs to the caller; this grant belongs to the target.
|
||||||
|
if (caller !== null && caller !== id)
|
||||||
|
roles = (await hasStudioAccess(
|
||||||
|
c.env.DB,
|
||||||
|
await getAccount(c.env.DB, id),
|
||||||
|
c.env.STUDIO_ROLLOUT
|
||||||
|
))
|
||||||
|
? ['betastudio']
|
||||||
|
: []
|
||||||
|
const grant = await c.env.DB.prepare(
|
||||||
|
'SELECT flags,created_at,modified_at FROM avatar_studio_access WHERE account_id=?'
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.first<{ flags: number; created_at: string; modified_at: string }>()
|
||||||
|
const flags = roles?.includes('betastudio') ? (grant?.flags ?? 0) : 0
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
return c.json({
|
||||||
|
AccountId: id,
|
||||||
|
StudioAccess: roles?.includes('betastudio') ? 1 : 2,
|
||||||
|
AvatarStudioAccess: flags,
|
||||||
|
CreatedAt: accessTimestamp(grant?.created_at, now),
|
||||||
|
ModifiedAt: accessTimestamp(grant?.modified_at, now),
|
||||||
|
})
|
||||||
|
})
|
||||||
// There is no circuit recommendation corpus on a fresh deployment. An empty
|
// There is no circuit recommendation corpus on a fresh deployment. An empty
|
||||||
// list is the SDK's supported no-recommendations state, not a failed lookup.
|
// list is the SDK's supported no-recommendations state, not a failed lookup.
|
||||||
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
|
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||||
|
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
AVATAR_STUDIO_SCHEMA,
|
||||||
|
avatarSaveDto,
|
||||||
|
avatarStudioSave,
|
||||||
|
publishAvatarSave,
|
||||||
|
ROOM_SCHEMA_DDL,
|
||||||
|
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
|
||||||
|
UNITY_ASSET_SCHEMA_DDL,
|
||||||
|
} from '@repo/domain'
|
||||||
|
import { generateToken } from '@repo/jwt'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL, toQuestCustomAvatarItem } from '../../../../api/src/custom-avatar-items-db'
|
||||||
|
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||||
|
|
||||||
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
|
declare module 'cloudflare:test' {
|
||||||
|
interface ProvidedEnv extends Env {}
|
||||||
|
}
|
||||||
|
const origin = 'https://example.com',
|
||||||
|
itemId = '11111111-1111-1111-1111-111111111111',
|
||||||
|
assetId = '22222222-2222-2222-2222-222222222222'
|
||||||
|
const worker = { Authorization: 'Bearer private-avatar-worker-key' }
|
||||||
|
async function auth(id = 42, roles = ['betastudio']) {
|
||||||
|
return {
|
||||||
|
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const manifest = {
|
||||||
|
Version: 3,
|
||||||
|
MainPrefabProjectRelativePath: 'Assets/Hat.prefab',
|
||||||
|
Files: [{ n: 'a'.repeat(40), p: 'Assets/Hat.prefab' }],
|
||||||
|
}
|
||||||
|
beforeAll(async () => {
|
||||||
|
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||||
|
await adminSecretsStore(env.CLOUD_BUILD_KEY).create('private-avatar-worker-key')
|
||||||
|
env.CLOUD_BUILD_TARGETS = '0,2'
|
||||||
|
for (const sql of [
|
||||||
|
...SCHEMA_DDL,
|
||||||
|
...ROOM_SCHEMA_DDL,
|
||||||
|
...AVATAR_STUDIO_SCHEMA,
|
||||||
|
...STUDIO_CLOUD_BUILD_SCHEMA_DDL,
|
||||||
|
...UNITY_ASSET_SCHEMA_DDL,
|
||||||
|
CLOUD_JOB_SCHEMA,
|
||||||
|
])
|
||||||
|
await env.DB.prepare(sql).run()
|
||||||
|
})
|
||||||
|
beforeEach(async () => {
|
||||||
|
for (const table of [
|
||||||
|
'studio_cloud_build_job',
|
||||||
|
'studio_cloud_build',
|
||||||
|
'studio_avatar_build',
|
||||||
|
'unity_asset',
|
||||||
|
'avatar_studio_save',
|
||||||
|
'avatar_studio_access',
|
||||||
|
'custom_avatar_item',
|
||||||
|
])
|
||||||
|
await env.DB.prepare('DELETE FROM ' + table).run()
|
||||||
|
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
|
||||||
|
await env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
CustomAvatarItemId: itemId,
|
||||||
|
CreatorAccountId: 42,
|
||||||
|
OutfitType: 0,
|
||||||
|
Accessibility: 0,
|
||||||
|
Name: 'Hat',
|
||||||
|
CurrentSaves: [],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
await env.DB.prepare(
|
||||||
|
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,update_current,created_at) VALUES (1,?,1,?,'source','hash','thumb','{}',1,'2026-10-18')"
|
||||||
|
)
|
||||||
|
.bind(itemId, assetId)
|
||||||
|
.run()
|
||||||
|
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
|
||||||
|
})
|
||||||
|
async function create(id = 42) {
|
||||||
|
return SELF.fetch(origin + '/cloud-builds/custom-avatar-item/' + itemId + '/save/1', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await auth(id),
|
||||||
|
body: new URLSearchParams({ pipelineVersion: '2', autoPublish: 'true' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function claim() {
|
||||||
|
const r = await SELF.fetch(origin + '/internal/cloud-builds/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: worker,
|
||||||
|
})
|
||||||
|
return r.json<{ job: { buildId: string; kind: string; manifest: unknown }; leaseToken: string }>()
|
||||||
|
}
|
||||||
|
function artifacts(lease: { leaseToken: string }, materialVersion = 6) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.set('leaseToken', lease.leaseToken)
|
||||||
|
const entries = []
|
||||||
|
for (const target of [0, 2])
|
||||||
|
for (const kind of ['main', 'material', 'stripped']) {
|
||||||
|
const field = `bundle${target}${kind}`
|
||||||
|
form.set(
|
||||||
|
field,
|
||||||
|
new File(['UnityFS\0avatar fixture ' + target + kind], field + '.assetbundle')
|
||||||
|
)
|
||||||
|
entries.push({
|
||||||
|
field,
|
||||||
|
unityAssetId: assetId,
|
||||||
|
target,
|
||||||
|
kind,
|
||||||
|
version: kind === 'material' ? materialVersion : 5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
form.set('artifacts', JSON.stringify(entries))
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
it('authorizes per-account access and rejects builds from other owners', async () => {
|
||||||
|
expect((await SELF.fetch(origin + '/access/override/42')).status).toBe(401)
|
||||||
|
expect(
|
||||||
|
(await SELF.fetch(origin + '/access/override/42', { headers: await auth(43) })).status
|
||||||
|
).toBe(403)
|
||||||
|
expect(
|
||||||
|
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
|
||||||
|
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 1 })
|
||||||
|
expect(
|
||||||
|
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth(42, []) })).json()
|
||||||
|
).toMatchObject({ AvatarStudioAccess: 0 })
|
||||||
|
expect((await create(43)).status).toBe(403)
|
||||||
|
})
|
||||||
|
it('serializes SQLite and ISO access timestamps as SDK-compatible UTC DateTimes', async () => {
|
||||||
|
await env.DB.prepare(
|
||||||
|
'UPDATE avatar_studio_access SET flags=15,created_at=?,modified_at=? WHERE account_id=42'
|
||||||
|
)
|
||||||
|
.bind('2026-10-19T00:45:00.372Z', '2026-10-19 16:44:56')
|
||||||
|
.run()
|
||||||
|
expect(
|
||||||
|
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
|
||||||
|
).toMatchObject({
|
||||||
|
AvatarStudioAccess: 15,
|
||||||
|
CreatedAt: '2026-10-19T00:45:00.372Z',
|
||||||
|
ModifiedAt: '2026-10-19T16:44:56.000Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('builds and publishes prefab revisions with real SDK main/material version distinctions', async () => {
|
||||||
|
expect((await create()).status).toBe(200)
|
||||||
|
const lease = await claim()
|
||||||
|
expect(lease.job.kind).toBe('avatar')
|
||||||
|
expect(lease.job.manifest).toMatchObject(manifest)
|
||||||
|
const finish = () => origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish'
|
||||||
|
expect(
|
||||||
|
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease, 5) }))
|
||||||
|
.status
|
||||||
|
).toBe(400)
|
||||||
|
expect(
|
||||||
|
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
|
||||||
|
).toBe(200)
|
||||||
|
const row = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
|
||||||
|
const item = JSON.parse(row!.data)
|
||||||
|
expect(item.CurrentSaves).toHaveLength(1)
|
||||||
|
expect(item.CurrentSaves[0]).toMatchObject({
|
||||||
|
CustomAvatarItemSaveId: 1,
|
||||||
|
BodyType: 1,
|
||||||
|
OutfitType: 0,
|
||||||
|
})
|
||||||
|
expect(item.CurrentSaves[0].UnityAsset).toMatch(/^studio-avatar\//)
|
||||||
|
expect(item.CurrentSaves[0].UnityAsset2).toContain('.material.assetbundle')
|
||||||
|
expect(item.CurrentSaves[0]).not.toHaveProperty('SourceUnityAssetFilename')
|
||||||
|
const saved = await avatarStudioSave(env.DB, itemId, 1),
|
||||||
|
dto = await avatarSaveDto(env.DB, saved!)
|
||||||
|
expect(dto.BakedAssets).toHaveLength(4)
|
||||||
|
expect(dto.BakedAssets.map((a) => a.Version).sort()).toEqual([5, 5, 6, 6])
|
||||||
|
const quest = toQuestCustomAvatarItem(item).CurrentSaves[0]
|
||||||
|
expect(quest.UnityAssetHash).toBe(
|
||||||
|
dto.BakedAssets.find((a) => a.Target === 2 && a.Version === 5)?.Hash
|
||||||
|
)
|
||||||
|
expect(quest.UnityAssetHash).not.toBe(item.CurrentSaves[0].UnityAssetHash)
|
||||||
|
expect((await env.CDN_ASSETS.get('avatar/' + dto.UnityAsset))?.size).toBeGreaterThan(8)
|
||||||
|
expect(
|
||||||
|
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
|
||||||
|
).toBe(409)
|
||||||
|
const count = await SELF.fetch(origin + '/cloud-builds/get-build-count-for-user', {
|
||||||
|
headers: await auth(),
|
||||||
|
})
|
||||||
|
expect(await count.json()).toMatchObject({
|
||||||
|
value: { subRoomTotalBuildCount: 0, avatarItemTotalBuildCount: 1 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('rejects source code paths and cannot cancel another account’s build', async () => {
|
||||||
|
await env.CDN_ASSETS.put(
|
||||||
|
'avatarunityasset/source',
|
||||||
|
JSON.stringify({ ...manifest, Files: [null] })
|
||||||
|
)
|
||||||
|
expect((await create()).status).toBe(400)
|
||||||
|
await env.CDN_ASSETS.put(
|
||||||
|
'avatarunityasset/source',
|
||||||
|
JSON.stringify({
|
||||||
|
...manifest,
|
||||||
|
Files: [...manifest.Files, { n: 'a'.repeat(40), p: 'Assets/Code.cs' }],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect((await create()).status).toBe(400)
|
||||||
|
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
|
||||||
|
await create()
|
||||||
|
const lease = await claim()
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(401)
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: await auth(43),
|
||||||
|
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(404)
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: await auth(),
|
||||||
|
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(200)
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: worker,
|
||||||
|
body: artifacts(lease),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(409)
|
||||||
|
})
|
||||||
|
it('does not publish a deleted item or an older revision after a newer save', async () => {
|
||||||
|
await create()
|
||||||
|
const lease = await claim()
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1'
|
||||||
|
).run()
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: worker,
|
||||||
|
body: artifacts(lease),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(200)
|
||||||
|
const item = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
|
||||||
|
expect(JSON.parse(item!.data).CurrentSaves).toEqual([])
|
||||||
|
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
|
||||||
|
await env.DB.prepare('DELETE FROM studio_cloud_build').run()
|
||||||
|
await env.DB.prepare('DELETE FROM studio_avatar_build').run()
|
||||||
|
await create()
|
||||||
|
const deleted = await claim()
|
||||||
|
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await SELF.fetch(origin + '/internal/cloud-builds/' + deleted.job.buildId + '/finish', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: worker,
|
||||||
|
body: artifacts(deleted),
|
||||||
|
})
|
||||||
|
).status
|
||||||
|
).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates concurrent avatar requests and allows a new build after failure', async () => {
|
||||||
|
const responses = await Promise.all([create(), create(), create()])
|
||||||
|
const ids = await Promise.all(
|
||||||
|
responses.map(async (response) => {
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
return (await response.json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(new Set(ids).size).toBe(1)
|
||||||
|
for (const table of ['studio_cloud_build', 'studio_avatar_build', 'studio_cloud_build_job'])
|
||||||
|
expect(await env.DB.prepare('SELECT COUNT(*) AS n FROM ' + table).first()).toEqual({ n: 1 })
|
||||||
|
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='failed'").run()
|
||||||
|
expect(
|
||||||
|
(await (await create()).json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
|
||||||
|
).not.toBe(ids[0])
|
||||||
|
})
|
||||||
|
it.each([false, true])(
|
||||||
|
'checks automatic avatar revision ordering in the write, including CAS retry=%s, preserving explicit restore',
|
||||||
|
async (retry) => {
|
||||||
|
await env.DB.prepare("INSERT INTO unity_asset VALUES (?,0,5,'main','old.bundle','hash')")
|
||||||
|
.bind(assetId)
|
||||||
|
.run()
|
||||||
|
let intercepted = false
|
||||||
|
const DB = new Proxy(env.DB, {
|
||||||
|
get(target, key) {
|
||||||
|
if (key === 'prepare')
|
||||||
|
return (sql: string) => {
|
||||||
|
const wrap = (statement: D1PreparedStatement): D1PreparedStatement =>
|
||||||
|
new Proxy(statement, {
|
||||||
|
get(stmt, method) {
|
||||||
|
if (method === 'bind') return (...values: unknown[]) => wrap(stmt.bind(...values))
|
||||||
|
if (method === 'run' && sql.startsWith('UPDATE custom_avatar_item'))
|
||||||
|
return async () => {
|
||||||
|
if (!intercepted) {
|
||||||
|
intercepted = true
|
||||||
|
await target
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT 2,item_id,body_type,'new-identity',source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1"
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
if (retry)
|
||||||
|
await target
|
||||||
|
.prepare(
|
||||||
|
"UPDATE custom_avatar_item SET data=json_set(data,'$.Name','Concurrent metadata')"
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
return stmt.run()
|
||||||
|
}
|
||||||
|
const value = Reflect.get(stmt, method)
|
||||||
|
return typeof value === 'function' ? value.bind(stmt) : value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return wrap(target.prepare(sql))
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, key)
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(await publishAvatarSave(DB, itemId, 1, true)).toBe(false)
|
||||||
|
expect(intercepted).toBe(true)
|
||||||
|
let item = JSON.parse(
|
||||||
|
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
|
||||||
|
)
|
||||||
|
expect(item.CurrentSaves).toEqual([])
|
||||||
|
expect(await publishAvatarSave(env.DB, itemId, 1)).toBe(true)
|
||||||
|
item = JSON.parse(
|
||||||
|
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
|
||||||
|
)
|
||||||
|
expect(item.CurrentSaves[0].CustomAvatarItemSaveId).toBe(1)
|
||||||
|
if (retry) expect(item.Name).toBe('Concurrent metadata')
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
UNITY_ASSET_SCHEMA_DDL,
|
UNITY_ASSET_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
import cloudBuilds, { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ async function auth(id = 42, studio = true) {
|
|||||||
}
|
}
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||||
env.CLOUD_BUILD_KEY = key
|
await adminSecretsStore(env.CLOUD_BUILD_KEY).create(key)
|
||||||
env.CLOUD_BUILD_TARGETS = '0'
|
env.CLOUD_BUILD_TARGETS = '0'
|
||||||
for (const sql of [
|
for (const sql of [
|
||||||
...ROOM_SCHEMA_DDL,
|
...ROOM_SCHEMA_DDL,
|
||||||
@@ -468,3 +468,138 @@ it('does not complete a build whose submitted save was deleted during compilatio
|
|||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function builtArtifacts(lease: Awaited<ReturnType<typeof claim>>) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.set('leaseToken', lease.leaseToken)
|
||||||
|
form.set('bundle', new File(['UnityFS\0regression'], 'bundle.assetbundle'))
|
||||||
|
form.set(
|
||||||
|
'artifacts',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
field: 'bundle',
|
||||||
|
unityAssetId: lease.job.unityAssetId,
|
||||||
|
target: 0,
|
||||||
|
kind: 'main',
|
||||||
|
version: 5,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
function finishBuild(
|
||||||
|
lease: Awaited<ReturnType<typeof claim>>,
|
||||||
|
form = builtArtifacts(lease),
|
||||||
|
bindings = env
|
||||||
|
) {
|
||||||
|
return cloudBuilds.request(
|
||||||
|
origin + `/internal/cloud-builds/${lease.job.buildId}/finish`,
|
||||||
|
{ method: 'POST', headers: worker, body: form },
|
||||||
|
bindings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
it.each(['before finish', 'before transaction'])(
|
||||||
|
'rejects removed room co-owner authority %s without linking or publishing',
|
||||||
|
async (timing) => {
|
||||||
|
const prior = await env.DB.prepare('SELECT data FROM room WHERE room_id=3000').first<{
|
||||||
|
data: string
|
||||||
|
}>()
|
||||||
|
const originalSave = await env.DB.prepare(
|
||||||
|
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||||
|
).first<{ data: string }>()
|
||||||
|
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
|
||||||
|
.bind(JSON.stringify([{ AccountId: 43, Role: 30 }]))
|
||||||
|
.run()
|
||||||
|
await create(43, true, { autoPublish: 'true' })
|
||||||
|
const lease = await claim()
|
||||||
|
const revoke = () =>
|
||||||
|
env.DB.prepare(
|
||||||
|
"UPDATE room SET data=json_set(data,'$.Roles',json('[]')) WHERE room_id=3000"
|
||||||
|
).run()
|
||||||
|
if (timing === 'before finish') await revoke()
|
||||||
|
const DB = new Proxy(env.DB, {
|
||||||
|
get(target, key) {
|
||||||
|
if (key === 'batch')
|
||||||
|
return async (statements: D1PreparedStatement[]) => {
|
||||||
|
await revoke()
|
||||||
|
return target.batch(statements)
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, key)
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare(
|
||||||
|
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||||
|
).first()
|
||||||
|
).toEqual(originalSave)
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
|
||||||
|
.bind(lease.job.unityAssetId)
|
||||||
|
.first()
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
|
||||||
|
.bind(lease.job.buildId)
|
||||||
|
.first()
|
||||||
|
).toEqual({ status: 'failed' })
|
||||||
|
expect(
|
||||||
|
await env.CDN_ASSETS.get(
|
||||||
|
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
|
||||||
|
)
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
(await env.CDN_ASSETS.list({ prefix: `room/studio/3000/${lease.job.unityAssetId}` }))
|
||||||
|
.objects
|
||||||
|
).toEqual([])
|
||||||
|
} finally {
|
||||||
|
await env.DB.prepare('UPDATE room SET data=? WHERE room_id=3000').bind(prior!.data).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
it.each(['expired lease', 'deleted save'])(
|
||||||
|
'cleans uploaded artifacts when final publication finds %s',
|
||||||
|
async (condition) => {
|
||||||
|
await create()
|
||||||
|
const lease = await claim()
|
||||||
|
const save = await env.DB.prepare(
|
||||||
|
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||||
|
).first<{ data: string }>()
|
||||||
|
const DB = new Proxy(env.DB, {
|
||||||
|
get(target, key) {
|
||||||
|
if (key === 'batch')
|
||||||
|
return async (statements: D1PreparedStatement[]) => {
|
||||||
|
if (condition === 'expired lease')
|
||||||
|
await target
|
||||||
|
.prepare("UPDATE studio_cloud_build_job SET lease_until='2000-01-01'")
|
||||||
|
.run()
|
||||||
|
else
|
||||||
|
await target
|
||||||
|
.prepare('DELETE FROM subroom_save WHERE sub_room_data_save_id=3002')
|
||||||
|
.run()
|
||||||
|
return target.batch(statements)
|
||||||
|
}
|
||||||
|
const value = Reflect.get(target, key)
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
|
||||||
|
.bind(lease.job.unityAssetId)
|
||||||
|
.first()
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
await env.CDN_ASSETS.get(
|
||||||
|
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
|
||||||
|
)
|
||||||
|
).toBeNull()
|
||||||
|
} finally {
|
||||||
|
if (condition === 'deleted save')
|
||||||
|
await env.DB.prepare('INSERT INTO subroom_save VALUES (3002,3001,?)').bind(save!.data).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||||
|
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL as ACCOUNT_SCHEMA, AVATAR_STUDIO_SCHEMA, ROOM_SCHEMA_DDL } from '@repo/domain'
|
||||||
|
import { generateStudioBuildValidationToken, generateToken } from '@repo/jwt'
|
||||||
|
|
||||||
|
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||||
|
|
||||||
|
import type { Env } from '../../context'
|
||||||
|
|
||||||
|
declare module 'cloudflare:test' {
|
||||||
|
interface ProvidedEnv extends Env {}
|
||||||
|
}
|
||||||
|
const base = 'https://example.com/access/override/'
|
||||||
|
beforeAll(async () => {
|
||||||
|
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||||
|
for (const sql of [
|
||||||
|
...ACCOUNT_SCHEMA,
|
||||||
|
...ROOM_SCHEMA_DDL,
|
||||||
|
...AVATAR_STUDIO_SCHEMA,
|
||||||
|
CLOUD_JOB_SCHEMA,
|
||||||
|
])
|
||||||
|
await env.DB.prepare(sql).run()
|
||||||
|
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
|
||||||
|
.bind(JSON.stringify({ accountId: 42, username: 'Fixture', hasStudio: true }))
|
||||||
|
.run()
|
||||||
|
await env.DB.prepare('INSERT INTO room(data) VALUES (?)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({ RoomId: 3000, CreatorAccountId: 42, Roles: [{ AccountId: 42, Role: 255 }] })
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,8,'now','now')").run()
|
||||||
|
})
|
||||||
|
beforeEach(async () => {
|
||||||
|
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,status,lease_until,created_at) VALUES (?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
'123',
|
||||||
|
JSON.stringify({ roomId: 3000, room: { Roles: [{ AccountId: 42, Role: 255 }] } }),
|
||||||
|
'building',
|
||||||
|
new Date(Date.now() + 60000).toISOString(),
|
||||||
|
'now'
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
})
|
||||||
|
async function request(id: number, build = '123') {
|
||||||
|
return SELF.fetch(base + id, {
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer ' + (await generateStudioBuildValidationToken(build, 'test-key')),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
it('allows read-only consumable validation only for an active job’s room owners', async () => {
|
||||||
|
expect(await (await request(42)).json()).toMatchObject({
|
||||||
|
AccountId: 42,
|
||||||
|
StudioAccess: 1,
|
||||||
|
AvatarStudioAccess: 8,
|
||||||
|
})
|
||||||
|
expect((await request(43)).status).toBe(403)
|
||||||
|
expect((await request(42, '124')).status).toBe(403)
|
||||||
|
expect((await SELF.fetch(base + '42')).status).toBe(401)
|
||||||
|
})
|
||||||
|
it('rejects cancelled and expired jobs', async () => {
|
||||||
|
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='cancelled'").run()
|
||||||
|
expect((await request(42)).status).toBe(403)
|
||||||
|
await env.DB.prepare(
|
||||||
|
"UPDATE studio_cloud_build_job SET status='building',lease_until='2000-01-01'"
|
||||||
|
).run()
|
||||||
|
expect((await request(42)).status).toBe(403)
|
||||||
|
})
|
||||||
|
it('lets co-owners validate each other’s grants without exposing unrelated accounts', async () => {
|
||||||
|
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
|
||||||
|
.bind(
|
||||||
|
JSON.stringify([
|
||||||
|
{ AccountId: 42, Role: 255 },
|
||||||
|
{ AccountId: 43, Role: 30 },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
const headers = {
|
||||||
|
Authorization:
|
||||||
|
'Bearer ' + (await generateToken('43', 'fixture', 0, 'test-key', ['betastudio'])),
|
||||||
|
}
|
||||||
|
expect(await (await SELF.fetch(base + '42', { headers })).json()).toMatchObject({
|
||||||
|
AvatarStudioAccess: 8,
|
||||||
|
})
|
||||||
|
expect((await SELF.fetch(base + '44', { headers })).status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses target entitlement for staff inspections in both directions', async () => {
|
||||||
|
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
|
||||||
|
.bind(JSON.stringify({ accountId: 44, username: 'Disabled', hasStudio: false }))
|
||||||
|
.run()
|
||||||
|
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (44,15,'now','now')").run()
|
||||||
|
const staff = async (roles: string[]) => ({
|
||||||
|
Authorization: 'Bearer ' + (await generateToken('99', 'staff', 0, 'test-key', roles)),
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
await (
|
||||||
|
await SELF.fetch(base + '44', { headers: await staff(['developer', 'betastudio']) })
|
||||||
|
).json()
|
||||||
|
).toMatchObject({ AccountId: 44, StudioAccess: 2, AvatarStudioAccess: 0 })
|
||||||
|
expect(
|
||||||
|
await (await SELF.fetch(base + '42', { headers: await staff(['moderator']) })).json()
|
||||||
|
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 8 })
|
||||||
|
})
|
||||||
@@ -37,6 +37,20 @@
|
|||||||
"binding": "JWT_SECRET",
|
"binding": "JWT_SECRET",
|
||||||
"store_id": "local",
|
"store_id": "local",
|
||||||
"secret_name": "JWT_SECRET"
|
"secret_name": "JWT_SECRET"
|
||||||
|
},
|
||||||
|
// The credential the cloud-building pull worker (recflare/cloud-building, its
|
||||||
|
// STUDIO_WORKER_KEY) presents as a bearer token on /internal/cloud-builds/*. A real
|
||||||
|
// secret, so it lives in the same store rather than riding along as a `--var` from
|
||||||
|
// .env, which would put it in plaintext on every worker. The binding must resolve
|
||||||
|
// for this worker to deploy: create it with the value `off` to run without cloud
|
||||||
|
// builds (creation then answers 503 and the private routes refuse everyone).
|
||||||
|
//
|
||||||
|
// wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
|
||||||
|
// --scopes workers --remote
|
||||||
|
{
|
||||||
|
"binding": "CLOUD_BUILD_KEY",
|
||||||
|
"store_id": "local",
|
||||||
|
"secret_name": "CLOUD_BUILD_KEY"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"upload_source_maps": true,
|
"upload_source_maps": true,
|
||||||
|
|||||||
@@ -220,11 +220,40 @@ won't pick up newly created secrets until it restarts.
|
|||||||
which one is missing. `wrangler tail www` shows it.
|
which one is missing. `wrangler tail www` shows it.
|
||||||
|
|
||||||
In the [Discord developer portal](https://discord.com/developers/applications),
|
In the [Discord developer portal](https://discord.com/developers/applications),
|
||||||
add `https://<your domain>/claim` to the app's **Redirects**. It has to match byte
|
add `https://<your domain>/claim` **and** `https://<your domain>/login/discord` to the
|
||||||
for byte: `www` derives the redirect URI from the incoming request's own origin
|
app's **Redirects**. Each has to match byte for byte: `www` derives the redirect URI
|
||||||
(never from the request body, which would turn the client secret into a redemption
|
from the incoming request's own origin (never from the request body, which would turn
|
||||||
oracle for someone else's app), so add `http://localhost:5173/claim` too if you
|
the client secret into a redemption oracle for someone else's app), so add the
|
||||||
want the flow to work under `pnpm turbo dev`.
|
`http://localhost:5173/…` pair too if you want the flows to work under `pnpm turbo dev`.
|
||||||
|
|
||||||
|
### Sign in with Discord
|
||||||
|
|
||||||
|
The same app also serves the login page's **Sign in with Discord** button, on the same
|
||||||
|
switch (`/api/config` serves `discordLoginUrl` beside `discordAuthorizeUrl`, both null
|
||||||
|
while the claim is closed). It asks Discord for the `identify` scope only and returns to
|
||||||
|
`/login/discord`, where the SPA posts the code to `POST /api/login/discord`. `www`
|
||||||
|
exchanges it, reads `GET /users/@me` for the Discord user id, revokes the token, and
|
||||||
|
mints an **identity assertion** — a one-minute HS256 token signed with the shared
|
||||||
|
`JWT_SECRET` naming platform `101` and that id — which it posts to `auth`'s
|
||||||
|
`identity_assertion` grant over the service binding. `auth` resolves the id through
|
||||||
|
`platform_account`:
|
||||||
|
|
||||||
|
- **One linked account** (a player who claimed benefits on it) — signed in; the ordinary
|
||||||
|
token body comes back and the SPA stores it like a password sign-in's.
|
||||||
|
- **No linked account** — `auth` **creates** one and links the Discord to it, under the
|
||||||
|
ban-evasion check and the per-IP / per-identity signup caps (but not `PASSWORD_SIGNUP`:
|
||||||
|
that switch exists because a password account has no identity but its address). The
|
||||||
|
body carries `created: true`, and the SPA stops to say so: the account has **no
|
||||||
|
password**, and setting one on the account page is what lets it sign in to the game —
|
||||||
|
on a sideloaded Quest without a Meta account, which is the case this is for. Its first
|
||||||
|
benefits claim still pays the welcome gift: the claim keys "first" on roles being
|
||||||
|
recorded on a Discord link, not on the link existing.
|
||||||
|
- **Several linked accounts** — refused; the claim never produces this.
|
||||||
|
|
||||||
|
`www` never chooses the account id: the assertion names an identity, and which account
|
||||||
|
that is (or that there is none) is `auth`'s decision from the same table `cached_login`
|
||||||
|
reads. The Discord snowflake stays off the new account's blob and token, as it does
|
||||||
|
after a claim.
|
||||||
|
|
||||||
### Discord role refresh (cron)
|
### Discord role refresh (cron)
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ const AUTH_MESSAGES: Record<string, string> = {
|
|||||||
'That platform cannot be verified — only Steam and Meta are supported.',
|
'That platform cannot be verified — only Steam and Meta are supported.',
|
||||||
'no linked account for this platform identity':
|
'no linked account for this platform identity':
|
||||||
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
||||||
|
// The website's Discord sign-in found the Discord user on several accounts — a state the
|
||||||
|
// benefits claim never produces, so this is for an operator to untangle, not the player.
|
||||||
|
'platform identity is linked to more than one account':
|
||||||
|
'That Discord account is linked to more than one RecFlare account. Sign in with your username and password instead.',
|
||||||
|
// www minted the assertion a moment ago, so this is a key mismatch between www and auth
|
||||||
|
// (or a clock a minute out), never something the player did.
|
||||||
|
'invalid or expired identity assertion':
|
||||||
|
'Sign-in is unavailable right now. This is a problem on our end — please try again later.',
|
||||||
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
||||||
// An account that shares a device or network with a BANNED one. Phrased for BOTH the
|
// An account that shares a device or network with a BANNED one. Phrased for BOTH the
|
||||||
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
|
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { downgradeRoom, needsDowngrade } from '../room-converter'
|
|||||||
// The session token, the worker hostnames and the `call` every request goes through —
|
// The session token, the worker hostnames and the `call` every request goes through —
|
||||||
// see api.ts for why they're a module of their own rather than defined here.
|
// see api.ts for why they're a module of their own rather than defined here.
|
||||||
import {
|
import {
|
||||||
|
accountByUsername,
|
||||||
accountIdForUsername,
|
accountIdForUsername,
|
||||||
call,
|
call,
|
||||||
hasToken,
|
hasToken,
|
||||||
@@ -30,11 +31,12 @@ import {
|
|||||||
import { DeviceLinkPage } from './DeviceLink'
|
import { DeviceLinkPage } from './DeviceLink'
|
||||||
import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item'
|
import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item'
|
||||||
import { ModerationPage, PlayerBanStatus } from './Moderation'
|
import { ModerationPage, PlayerBanStatus } from './Moderation'
|
||||||
|
import { PumpleBossFight } from './PumpleBossFight'
|
||||||
import { StatsPage } from './Stats'
|
import { StatsPage } from './Stats'
|
||||||
import { StudioAccessPage } from './StudioAccess'
|
import { StudioAccessPage } from './StudioAccess'
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { Hosts } from './api'
|
import type { Hosts, PublicAccount } from './api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site config from `www`. `signupEnabled` is false when the operator has no Turnstile
|
* Site config from `www`. `signupEnabled` is false when the operator has no Turnstile
|
||||||
@@ -56,6 +58,12 @@ interface SiteConfig {
|
|||||||
* benefits are off. The `state` nonce is appended here — see `startDiscordAuth`.
|
* benefits are off. The `state` nonce is appended here — see `startDiscordAuth`.
|
||||||
*/
|
*/
|
||||||
discordAuthorizeUrl: string | null
|
discordAuthorizeUrl: string | null
|
||||||
|
/**
|
||||||
|
* The consent URL for "Sign in with Discord" — same app, `identify` scope only, and its own
|
||||||
|
* return leg (`/login/discord`). On the same switch as the claim, so null whenever
|
||||||
|
* `benefitsEnabled` is false. See `DiscordLoginPage`.
|
||||||
|
*/
|
||||||
|
discordLoginUrl: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The private self DTO from `accounts` (`GET /account/me`). */
|
/** The private self DTO from `accounts` (`GET /account/me`). */
|
||||||
@@ -592,21 +600,52 @@ const claimBenefits = (code: string): Promise<ClaimResult> =>
|
|||||||
*/
|
*/
|
||||||
const OAUTH_STATE_KEY = 'rf_discord_state'
|
const OAUTH_STATE_KEY = 'rf_discord_state'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where to land after a Discord SIGN-IN — the `?next=` the login page was opened with.
|
||||||
|
* Stashed beside the nonce because the round trip through Discord loses the query string,
|
||||||
|
* and read back by `DiscordLoginPage` exactly once.
|
||||||
|
*/
|
||||||
|
const OAUTH_NEXT_KEY = 'rf_discord_next'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send the browser to Discord's consent screen.
|
* Send the browser to Discord's consent screen.
|
||||||
*
|
*
|
||||||
* A real navigation, not a client-side route — Discord is another origin. The `state` is
|
* A real navigation, not a client-side route — Discord is another origin. The `state` is
|
||||||
* minted here and stashed for the return leg; `www` built everything else about the URL
|
* minted here and stashed for the return leg; `www` built everything else about the URL
|
||||||
* (see `/api/config`), so this only ever appends the one parameter it owns.
|
* (see `/api/config`), so this only ever appends the one parameter it owns. `next` is the
|
||||||
|
* sign-in's post-login destination; the claim passes none.
|
||||||
*/
|
*/
|
||||||
function startDiscordAuth(authorizeUrl: string) {
|
function startDiscordAuth(authorizeUrl: string, next?: string) {
|
||||||
const state = crypto.randomUUID()
|
const state = crypto.randomUUID()
|
||||||
sessionStorage.setItem(OAUTH_STATE_KEY, state)
|
sessionStorage.setItem(OAUTH_STATE_KEY, state)
|
||||||
|
if (next) sessionStorage.setItem(OAUTH_NEXT_KEY, next)
|
||||||
|
else sessionStorage.removeItem(OAUTH_NEXT_KEY)
|
||||||
const url = new URL(authorizeUrl)
|
const url = new URL(authorizeUrl)
|
||||||
url.searchParams.set('state', state)
|
url.searchParams.set('state', state)
|
||||||
window.location.assign(url.toString())
|
window.location.assign(url.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What `/api/login/discord` answers: auth's token body, plus who signed in and whether it made them. */
|
||||||
|
interface DiscordSignIn {
|
||||||
|
access_token?: string
|
||||||
|
/** True when no account was linked to this Discord and `auth` created one just now. */
|
||||||
|
created?: boolean
|
||||||
|
/** The Discord username, for the confirmation line only. */
|
||||||
|
discordUsername?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem the sign-in's authorization code. Goes through `www`, like signup and the claim,
|
||||||
|
* because the exchange needs the Discord client secret; `www` then has `auth` issue the
|
||||||
|
* session and relays the token body, which is stored exactly as a password sign-in's is.
|
||||||
|
*/
|
||||||
|
async function signInWithDiscord(code: string): Promise<DiscordSignIn> {
|
||||||
|
const data = await call<DiscordSignIn>('/api/login/discord', { json: { code } })
|
||||||
|
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login'))
|
||||||
|
setToken(data.access_token)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
|
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
|
||||||
* — so a session without the role is rejected there (403) even though the UI shows no
|
* — so a session without the role is rejected there (403) even though the UI shows no
|
||||||
@@ -952,16 +991,6 @@ function roomIdFromPath(path: string): number | null {
|
|||||||
return match ? Number.parseInt(match[1], 10) : null
|
return match ? Number.parseInt(match[1], 10) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public account shape, from `GET /account/:id` or `/account/search`. */
|
|
||||||
interface PublicAccount {
|
|
||||||
accountId: number
|
|
||||||
username: string
|
|
||||||
displayName: string
|
|
||||||
profileImage: string
|
|
||||||
bannerImage: string
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A player's photo, from the `ImagesPlayer` projection. Public unless the list was the
|
* A player's photo, from the `ImagesPlayer` projection. Public unless the list was the
|
||||||
* viewer's own (see fetchPlayerPhotos), which is the one way a private one gets here —
|
* viewer's own (see fetchPlayerPhotos), which is the one way a private one gets here —
|
||||||
@@ -991,10 +1020,9 @@ async function searchPlayers(query: string): Promise<PublicAccount[]> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The profile page's account: an exact lookup, not the search bar's prefix query. */
|
||||||
const fetchPublicAccount = (username: string): Promise<PublicAccount | null> =>
|
const fetchPublicAccount = (username: string): Promise<PublicAccount | null> =>
|
||||||
searchPlayers(username).then(
|
accountByUsername(username)
|
||||||
(matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The photos a player has taken. Sent WITH the session token when there is one: `api`
|
* The photos a player has taken. Sent WITH the session token when there is one: `api`
|
||||||
@@ -1777,11 +1805,18 @@ function halloweenActive(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */
|
/** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */
|
||||||
const PUMPKIN_SRC =
|
const PUMPKIN_FRAMES = [
|
||||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAZCAYAAADaILXQAAAFD0lEQVR42pWVX2xcxRXGf2fu3Lu7jh0bO3FsnMQ2idP8gZoIGkootZsIGrUlVEVCTVW1qVREUPtSiTckYgQPqEIg1FZU6gtP8EJVAaZpIqE4UtrQOCSIkj+YVgRsp3E2mzisnb27OzOnD+uuE2yJZJ7u6J755jvf+eYc4SbX7qHBbNyjpe5VS5k4Nw3VZj4evcrFiWn+9fZxuTbW3Cw43WcxBMBCNYtYRYwiujDU3ijmwK++rWv7G7BSpLdrGf8Zv8TY+54kCuQ/v4IsckZuBPg7Twzq2ntq1IwXjvzlCz4cPv6VZxcN2LttTfXh9phKLPxtMmVnW7BqAiPnU2aipTzUFnjzonMiFhFn9747vihO9P+PoW29Dz7Yt3LD/sdl3dRl/9NmLRu1GJPzpqfFk2SV6ZIhGwlrVoBP1MQO0xQrQ/1N76/qXL7u4KeXxxbV3Jjwuveu1USGH98XOH44EIopu+7PECJBXGBmVpFKhr71JfqAD0cTVMGY5G1MukAJC/DM9l597IEmVsRTAJz4u2XrG+cxQGnPHuyOP6L797AxOYh1KdGmnYAhHPsrrjrLlq05dNTCtm71Jd82dGTiUh3cKDV7ac2ZAUP60moEB6ffQvfvQeucQt3Bd+3eAZlb+OCV1+n/Zpl3hpsAP8/86QdW6VM/tEjlCmWf8sizGcaiHB9thYQZgg+sfngYMPxuR46VpkS/exMRRQWE5bWL1fD9LsuhcV/Yu707febdz3I29gapBMg0467maG5pI3JlitaSAZaEIk1tHbXqNyxBowTT4IjK0zW72Qoyl7GmJdTPu8SC4dg/Krw6GrhcbeUDrYIY7v+th4uTnHxuaT3NR157j0CVvoN3cvo303PyBPRLjz3MSWiDwMvvrQACEpRg5v6oQVs3sfHFgEpg7NzxufRhbPIE0ZNw+NG1xDbHvnxA/+mR+kVz4Af9p/G31vjq5NkGyppjszpOYFFVQBERfmkNfvVmHIqo4e5cBBiy4RIVyhiRmhnUAG6+cY2M4FzSMNyQc9XWlgoiwi0mkAhkgO4AagUTZVkeWZZloSnyeHzNTdfIMeWgHMmBGbtkX12o5w+ceWj5ra7Y1j4LQK8qwZfRNGVbDgKWINCei+kVT1+Yt6XB4VGEwNEZoZKaXS8cOPWjBV1RCKAWpMLPlrYg1at4CWzf/DkXTt7K16qeFhNxVCI6br/Avrwl+Dw/6Grl5zMrWNbXQFquwpGJhf1cTJYnt16ou8BVpxm8q7bvvCPPW50T/L6rQMcd+TmPQ5rt4RezLdz7aCNrtwi5ZlMYeGKgdF0v2NR/W8FFcatUS/ykI8POzhasVf787/N8rzNi31TKRyu7KSyJicXw30/ykFiCEZbd1k6aFvn6fRmSOEsZ5ZPDbl4Wn2QxCmQa0VCiWrlMKjE+00g5MlQyEaXY4GyEw+EaDZFP8FJG1WGTLGhMpeJAPS5yNeYb7t543ZA6fexUPaP139ioEULcnqGtu5mKKOu3eFDDn84eihiiXt7BXw/o4HdXYnIlxicK12uuaHotMMCZ0VMSVClPpRTOzlL4rMjYUaGnq5nHewb8Y68O1ImpKucu5BmfrNXkpgb0lUK+oz3X1IRa1MiCoSwnTay2dEmI+fio1jRXleFIwYupLgZqggwDJMXkKhtwEEhLji+uKEhdFUZGRtztu3YeMBI1Znzxxgb0tWtw92BWGyitu8dzZlQxCof+cHhRnP8B5aopRAfi2ZcAAAAASUVORK5CYII='
|
new URL('./pumple-frames/frame-00.png', import.meta.url).href,
|
||||||
const PUMPKIN_SCALE = 3
|
new URL('./pumple-frames/frame-01.png', import.meta.url).href,
|
||||||
const PUMPKIN_W = 23 * PUMPKIN_SCALE
|
new URL('./pumple-frames/frame-02.png', import.meta.url).href,
|
||||||
const PUMPKIN_H = 25 * PUMPKIN_SCALE
|
new URL('./pumple-frames/frame-03.png', import.meta.url).href,
|
||||||
|
new URL('./pumple-frames/frame-04.png', import.meta.url).href,
|
||||||
|
new URL('./pumple-frames/frame-05.png', import.meta.url).href,
|
||||||
|
new URL('./pumple-frames/frame-06.png', import.meta.url).href,
|
||||||
|
new URL('./pumple-frames/frame-07.png', import.meta.url).href,
|
||||||
|
]
|
||||||
|
const PUMPKIN_W = 150
|
||||||
|
const PUMPKIN_H = 150
|
||||||
const PUMPKIN_LINES = ['boo!', 'hehe', 'hey!', 'spooky!', 'happy halloween!', 'ow!', '🎃']
|
const PUMPKIN_LINES = ['boo!', 'hehe', 'hey!', 'spooky!', 'happy halloween!', 'ow!', '🎃']
|
||||||
|
|
||||||
const PUMPKIN_CSS = `
|
const PUMPKIN_CSS = `
|
||||||
@@ -1793,16 +1828,7 @@ const PUMPKIN_CSS = `
|
|||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
transform-origin: 50% 100%;
|
transform-origin: 50% 100%;
|
||||||
}
|
}
|
||||||
.rf-pumpkin.is-walking img {
|
/* The animated GIF provides its own walking frames. */
|
||||||
animation: rf-pumpkin-step 0.24s steps(2, end) infinite;
|
|
||||||
}
|
|
||||||
@keyframes rf-pumpkin-step {
|
|
||||||
0%, 100% { transform: translateY(0) scaleX(1); }
|
|
||||||
50% { transform: translateY(-1px) scaleX(0.96); }
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.rf-pumpkin.is-walking img { animation: none; }
|
|
||||||
}
|
|
||||||
.rf-pumpkin-bubble { position: absolute; left: 50%; transform: translateX(-50%); margin: 6px 0; padding: 3px 8px;
|
.rf-pumpkin-bubble { position: absolute; left: 50%; transform: translateX(-50%); margin: 6px 0; padding: 3px 8px;
|
||||||
border-radius: 10px; background: #fff3dc; color: #2a1608; font-size: 12px; font-weight: 600;
|
border-radius: 10px; background: #fff3dc; color: #2a1608; font-size: 12px; font-weight: 600;
|
||||||
white-space: nowrap; opacity: 0; transition: opacity 0.15s; }
|
white-space: nowrap; opacity: 0; transition: opacity 0.15s; }
|
||||||
@@ -1816,13 +1842,15 @@ const PUMPKIN_CSS = `
|
|||||||
function LilPumpkin() {
|
function LilPumpkin() {
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
const flipRef = useRef<HTMLDivElement>(null)
|
const flipRef = useRef<HTMLDivElement>(null)
|
||||||
|
const spriteRef = useRef<HTMLImageElement>(null)
|
||||||
const bubbleRef = useRef<HTMLDivElement>(null)
|
const bubbleRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = rootRef.current
|
const root = rootRef.current
|
||||||
const flip = flipRef.current
|
const flip = flipRef.current
|
||||||
|
const sprite = spriteRef.current
|
||||||
const bubble = bubbleRef.current
|
const bubble = bubbleRef.current
|
||||||
if (!root || !flip || !bubble) return
|
if (!root || !flip || !sprite || !bubble) return
|
||||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
root.style.display = 'none'
|
root.style.display = 'none'
|
||||||
return
|
return
|
||||||
@@ -1841,12 +1869,73 @@ function LilPumpkin() {
|
|||||||
let jy = 0
|
let jy = 0
|
||||||
let vy = 0
|
let vy = 0
|
||||||
let walkTime = 0
|
let walkTime = 0
|
||||||
|
let walkDistance = 0
|
||||||
|
let walkFrame = 0
|
||||||
let mouseX = -1
|
let mouseX = -1
|
||||||
let mouseY = -1
|
let mouseY = -1
|
||||||
let mouseAt = -Infinity
|
let mouseAt = -Infinity
|
||||||
let speechTimer = 0
|
let speechTimer = 0
|
||||||
let last = performance.now()
|
let last = performance.now()
|
||||||
let raf = 0
|
let raf = 0
|
||||||
|
const effectNodes: HTMLElement[] = []
|
||||||
|
|
||||||
|
const spawnBatSwarm = () => {
|
||||||
|
const flash = document.createElement('div')
|
||||||
|
Object.assign(flash.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
inset: '0',
|
||||||
|
background: 'rgba(210, 0, 28, 0.32)',
|
||||||
|
opacity: '1',
|
||||||
|
transition: 'opacity 480ms ease-out',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: '100000',
|
||||||
|
})
|
||||||
|
document.body.appendChild(flash)
|
||||||
|
effectNodes.push(flash)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
flash.style.opacity = '0'
|
||||||
|
})
|
||||||
|
window.setTimeout(() => {
|
||||||
|
flash.remove()
|
||||||
|
const i = effectNodes.indexOf(flash)
|
||||||
|
if (i !== -1) effectNodes.splice(i, 1)
|
||||||
|
}, 520)
|
||||||
|
|
||||||
|
const cx = x + PUMPKIN_W / 2
|
||||||
|
const cy = y + jy + PUMPKIN_H / 2
|
||||||
|
for (let i = 0; i < 22; i++) {
|
||||||
|
const bat = document.createElement('span')
|
||||||
|
const angle = Math.random() * Math.PI * 2
|
||||||
|
const distance = 100 + Math.random() * 260
|
||||||
|
const dx = Math.cos(angle) * distance
|
||||||
|
const dy = Math.sin(angle) * distance
|
||||||
|
Object.assign(bat.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${cx}px`,
|
||||||
|
top: `${cy}px`,
|
||||||
|
fontSize: `${20 + Math.random() * 20}px`,
|
||||||
|
lineHeight: '1',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: '100001',
|
||||||
|
opacity: '1',
|
||||||
|
transform: 'translate(-50%, -50%) scale(0.4)',
|
||||||
|
transition: 'transform 850ms ease-out, opacity 850ms ease-out',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
})
|
||||||
|
bat.textContent = '🦇'
|
||||||
|
document.body.appendChild(bat)
|
||||||
|
effectNodes.push(bat)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
bat.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${Math.random() * 100 - 50}deg) scale(1.2)`
|
||||||
|
bat.style.opacity = '0'
|
||||||
|
})
|
||||||
|
window.setTimeout(() => {
|
||||||
|
bat.remove()
|
||||||
|
const i = effectNodes.indexOf(bat)
|
||||||
|
if (i !== -1) effectNodes.splice(i, 1)
|
||||||
|
}, 900)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
root.style.transform = `translate(${x}px, ${y}px)`
|
root.style.transform = `translate(${x}px, ${y}px)`
|
||||||
|
|
||||||
@@ -1870,14 +1959,30 @@ function LilPumpkin() {
|
|||||||
const onLeave = () => {
|
const onLeave = () => {
|
||||||
mouseAt = -Infinity
|
mouseAt = -Infinity
|
||||||
}
|
}
|
||||||
|
let rapidClickCount = 0
|
||||||
|
let rapidClickStart = 0
|
||||||
const onClick = (e: MouseEvent) => {
|
const onClick = (e: MouseEvent) => {
|
||||||
const top = y + jy
|
const top = y + jy
|
||||||
|
const bob = 0
|
||||||
const hit =
|
const hit =
|
||||||
e.clientX >= x - 6 &&
|
e.clientX >= x - 45 &&
|
||||||
e.clientX <= x + PUMPKIN_W + 6 &&
|
e.clientX <= x + PUMPKIN_W + 45 &&
|
||||||
e.clientY >= top - 6 &&
|
e.clientY >= top - bob - 45 &&
|
||||||
e.clientY <= top + PUMPKIN_H + 6
|
e.clientY <= top - bob + PUMPKIN_H + 45
|
||||||
if (!hit) return
|
if (!hit) return
|
||||||
|
|
||||||
|
const now = performance.now()
|
||||||
|
if (now - rapidClickStart > 5000) {
|
||||||
|
rapidClickStart = now
|
||||||
|
rapidClickCount = 0
|
||||||
|
}
|
||||||
|
rapidClickCount++
|
||||||
|
if (rapidClickCount >= 10) {
|
||||||
|
rapidClickCount = 0
|
||||||
|
rapidClickStart = 0
|
||||||
|
window.dispatchEvent(new CustomEvent('rf-pumple-boss'))
|
||||||
|
}
|
||||||
|
spawnBatSwarm()
|
||||||
if (jy === 0) vy = -430
|
if (jy === 0) vy = -430
|
||||||
say(PUMPKIN_LINES[Math.floor(Math.random() * PUMPKIN_LINES.length)])
|
say(PUMPKIN_LINES[Math.floor(Math.random() * PUMPKIN_LINES.length)])
|
||||||
}
|
}
|
||||||
@@ -1903,11 +2008,11 @@ function LilPumpkin() {
|
|||||||
const dy = mouseY - (y + PUMPKIN_H / 2)
|
const dy = mouseY - (y + PUMPKIN_H / 2)
|
||||||
const dist = Math.hypot(dx, dy)
|
const dist = Math.hypot(dx, dy)
|
||||||
if (Math.abs(dx) > 4) facing = dx > 0 ? 1 : -1
|
if (Math.abs(dx) > 4) facing = dx > 0 ? 1 : -1
|
||||||
if (dist > 110) {
|
if (dist > 85) {
|
||||||
// Walk toward the cursor but stop short, so it never ends up sitting under the pointer.
|
// Walk toward the cursor but stop short, so it never ends up sitting under the pointer.
|
||||||
tx = clamp(mouseX - (dx / dist) * 80 - PUMPKIN_W / 2, 0, maxX())
|
tx = clamp(mouseX - (dx / dist) * 80 - PUMPKIN_W / 2, 0, maxX())
|
||||||
ty = clamp(mouseY - (dy / dist) * 80 - PUMPKIN_H / 2, 0, maxY())
|
ty = clamp(mouseY - (dy / dist) * 80 - PUMPKIN_H / 2, 0, maxY())
|
||||||
speed = 150
|
speed = 310
|
||||||
} else {
|
} else {
|
||||||
tx = x
|
tx = x
|
||||||
ty = y
|
ty = y
|
||||||
@@ -1920,7 +2025,7 @@ function LilPumpkin() {
|
|||||||
ty = Math.random() * maxY()
|
ty = Math.random() * maxY()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
speed = 60
|
speed = 145
|
||||||
}
|
}
|
||||||
|
|
||||||
const mx = tx - x
|
const mx = tx - x
|
||||||
@@ -1932,6 +2037,15 @@ function LilPumpkin() {
|
|||||||
x += (mx / md) * step
|
x += (mx / md) * step
|
||||||
y += (my / md) * step
|
y += (my / md) * step
|
||||||
moving = true
|
moving = true
|
||||||
|
|
||||||
|
// Advance the walk cycle according to distance actually traveled.
|
||||||
|
walkDistance += step
|
||||||
|
const nextFrame = Math.floor(walkDistance / 30) % PUMPKIN_FRAMES.length
|
||||||
|
if (nextFrame !== walkFrame) {
|
||||||
|
walkFrame = nextFrame
|
||||||
|
sprite.src = PUMPKIN_FRAMES[walkFrame]
|
||||||
|
}
|
||||||
|
|
||||||
if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1
|
if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1
|
||||||
} else if (speed > 0 && !following) {
|
} else if (speed > 0 && !following) {
|
||||||
pause = 1 + Math.random() * 2.5
|
pause = 1 + Math.random() * 2.5
|
||||||
@@ -1980,7 +2094,7 @@ function LilPumpkin() {
|
|||||||
<div className="rf-pumpkin" ref={rootRef}>
|
<div className="rf-pumpkin" ref={rootRef}>
|
||||||
<div className="rf-pumpkin-bubble" ref={bubbleRef} />
|
<div className="rf-pumpkin-bubble" ref={bubbleRef} />
|
||||||
<div ref={flipRef} style={{ transformOrigin: '50% 100%' }}>
|
<div ref={flipRef} style={{ transformOrigin: '50% 100%' }}>
|
||||||
<img src={PUMPKIN_SRC} alt="" draggable={false} />
|
<img ref={spriteRef} src={PUMPKIN_FRAMES[0]} alt="" draggable={false} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -2085,6 +2199,7 @@ export function App() {
|
|||||||
turnstileSiteKey: null,
|
turnstileSiteKey: null,
|
||||||
benefitsEnabled: false,
|
benefitsEnabled: false,
|
||||||
discordAuthorizeUrl: null,
|
discordAuthorizeUrl: null,
|
||||||
|
discordLoginUrl: null,
|
||||||
})
|
})
|
||||||
setAccount(null)
|
setAccount(null)
|
||||||
})
|
})
|
||||||
@@ -2102,7 +2217,12 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
|
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
|
||||||
{path === '/login' || path === '/signup' ? (
|
{path === '/login/discord' ? (
|
||||||
|
// Discord's registered redirect for a SIGN-IN (the claim's is `/claim`). Matched
|
||||||
|
// before `/login`, which it would otherwise never reach; a cold load falls through
|
||||||
|
// to the SPA shell like every other client-side route.
|
||||||
|
<DiscordLoginPage config={config} navigate={navigate} onAuthed={setAccount} />
|
||||||
|
) : path === '/login' || path === '/signup' ? (
|
||||||
// One page, two doors. `/signup` exists so the homepage's create-account link
|
// One page, two doors. `/signup` exists so the homepage's create-account link
|
||||||
// lands on that tab instead of dropping people on sign-in to find it — and so
|
// lands on that tab instead of dropping people on sign-in to find it — and so
|
||||||
// the URL is linkable. Unknown paths fall back to index.html (see the assets
|
// the URL is linkable. Unknown paths fall back to index.html (see the assets
|
||||||
@@ -2175,6 +2295,7 @@ export function App() {
|
|||||||
)}
|
)}
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
<HalloweenOverlay />
|
<HalloweenOverlay />
|
||||||
|
<PumpleBossFight />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2563,6 +2684,7 @@ function LoginPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
|
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
|
||||||
|
const discordLoginUrl = config?.discordLoginUrl ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
@@ -2609,6 +2731,25 @@ function LoginPage({
|
|||||||
your Steam ID — set a password on it and it signs in here too.
|
your Steam ID — set a password on it and it signs in here too.
|
||||||
</p>
|
</p>
|
||||||
<LoginForm onAuthed={authed} />
|
<LoginForm onAuthed={authed} />
|
||||||
|
{/* The other door. Shown on the server's say-so like the signup tab: the URL is
|
||||||
|
null until the Discord app is configured. `next` rides along through Discord in
|
||||||
|
sessionStorage, since the round trip drops the query. */}
|
||||||
|
{discordLoginUrl && (
|
||||||
|
<div className="alt-signin">
|
||||||
|
<p className="muted">or</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cta discord"
|
||||||
|
onClick={() => startDiscordAuth(discordLoginUrl, next)}
|
||||||
|
>
|
||||||
|
Sign in with Discord
|
||||||
|
</button>
|
||||||
|
<p className="muted">
|
||||||
|
Signs you into the account your Discord is linked to. No account yet? This makes
|
||||||
|
one — set a password on it afterwards and it signs in to the game too.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* The tabs above already offer this; the line under the button is where
|
{/* The tabs above already offer this; the line under the button is where
|
||||||
someone who just found out they have no account is actually looking.
|
someone who just found out they have no account is actually looking.
|
||||||
Gated on the same key, so it can't point at a door that isn't there. */}
|
Gated on the same key, so it can't point at a door that isn't there. */}
|
||||||
@@ -2627,6 +2768,109 @@ function LoginPage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/login/discord` — the page Discord redirects a SIGN-IN back to. Its own route rather than
|
||||||
|
* `/claim`, because that page wants a session before it does anything and this is how one
|
||||||
|
* gets made; it is the app's second registered `redirect_uri`, so like `/claim` it has to
|
||||||
|
* work on a cold load.
|
||||||
|
*
|
||||||
|
* The return leg mirrors the claim's: check the nonce this tab minted, strip the code off
|
||||||
|
* the URL before anything else, redeem it exactly once. What differs is the outcome. A
|
||||||
|
* sign-in to an existing account just goes where the login page was headed. A sign-in that
|
||||||
|
* CREATED an account (`created`) stops here first: that account has no password, and
|
||||||
|
* without one it can't sign in to the game — on a Quest without a Meta account, which is
|
||||||
|
* the whole point of making it this way — so the one thing to tell a brand-new player is
|
||||||
|
* where to set one.
|
||||||
|
*/
|
||||||
|
function DiscordLoginPage({
|
||||||
|
config,
|
||||||
|
navigate,
|
||||||
|
onAuthed,
|
||||||
|
}: {
|
||||||
|
config: SiteConfig | undefined
|
||||||
|
navigate: Navigate
|
||||||
|
onAuthed: (a: SelfAccount) => void
|
||||||
|
}) {
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [created, setCreated] = useState<SelfAccount | null>(null)
|
||||||
|
// StrictMode runs effects twice in dev, and a Discord code is single-use — see BenefitsPanel.
|
||||||
|
const redeemed = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const code = params.get('code')
|
||||||
|
const state = params.get('state')
|
||||||
|
const expected = sessionStorage.getItem(OAUTH_STATE_KEY)
|
||||||
|
const next = safeNextPath(sessionStorage.getItem(OAUTH_NEXT_KEY)) ?? '/account'
|
||||||
|
|
||||||
|
if (code === null) {
|
||||||
|
// Opened directly, or Discord sent the player back with `?error=access_denied`
|
||||||
|
// because they cancelled. Nothing to redeem; back to the door.
|
||||||
|
navigate('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.history.replaceState(null, '', '/login/discord')
|
||||||
|
if (redeemed.current) return
|
||||||
|
redeemed.current = true
|
||||||
|
sessionStorage.removeItem(OAUTH_STATE_KEY)
|
||||||
|
sessionStorage.removeItem(OAUTH_NEXT_KEY)
|
||||||
|
|
||||||
|
if (state === null || expected === null || state !== expected) {
|
||||||
|
setError('That Discord sign-in did not match this browser. Please start again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
signInWithDiscord(code)
|
||||||
|
.then(async (result) => {
|
||||||
|
const me = await fetchMe()
|
||||||
|
onAuthed(me)
|
||||||
|
if (result.created) setCreated(me)
|
||||||
|
else navigate(next)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
|
||||||
|
}, [navigate, onAuthed])
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<section className="card">
|
||||||
|
<h2>Welcome to RecFlare</h2>
|
||||||
|
<p>
|
||||||
|
Your account is ready, linked to your Discord. Your username is{' '}
|
||||||
|
<strong>@{created.username}</strong> — you can change it on your account page.
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
It has no password yet. Set one on your account page: your username and that password
|
||||||
|
are how you sign in to the game — including on a Quest without a Meta account.
|
||||||
|
</p>
|
||||||
|
<Link to="/account" navigate={navigate} className="cta">
|
||||||
|
Go to my account
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<section className="card">
|
||||||
|
<h2>Signing in with Discord</h2>
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<p className="error">{error}</p>
|
||||||
|
<Link to="/login" navigate={navigate} className="cta ghost">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="muted">{config === undefined ? 'Loading…' : 'One moment…'}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** The signed-in account page. Redirects to sign-in when there's no session. */
|
/** The signed-in account page. Redirects to sign-in when there's no session. */
|
||||||
function AccountPage({
|
function AccountPage({
|
||||||
account,
|
account,
|
||||||
|
|||||||
@@ -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 |
@@ -219,23 +219,32 @@ export function useAction() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve an `@username` to the account id the workers address a player by.
|
* The public account behind an exact `@username`, or null when nobody has the name.
|
||||||
*
|
* `accounts`' `GET /account/username/:name` is an indexed equality lookup — NOT the
|
||||||
* `accounts` serves no exact-name lookup, so this goes through the PREFIX search and
|
* prefix search, which this once went through and filtered client-side; one profile view
|
||||||
* keeps only an exact (case-insensitive) hit: a prefix match is a different player, and
|
* then cost a 20-row index walk, and the search bar's typeahead already keeps that query
|
||||||
* acting on whoever happened to sort first would be worse than refusing. The exact name
|
* busy enough. A 404 is the one non-ok answer that means "no such player"; anything else
|
||||||
* always sorts first among its own prefixes, so it's inside the search limit whenever it
|
* is a real failure and throws.
|
||||||
* exists.
|
*/
|
||||||
|
export async function accountByUsername(input: string): Promise<PublicAccount | null> {
|
||||||
|
const name = input.trim().replace(/^@/, '')
|
||||||
|
if (name === '') return null
|
||||||
|
const res = await fetch(`${where().accounts}/account/username/${encodeURIComponent(name)}`)
|
||||||
|
if (res.status === 404) return null
|
||||||
|
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
if (!res.ok) throw new Error(errorMessage(data, res.status))
|
||||||
|
return data as unknown as PublicAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an `@username` to the account id the workers address a player by. An exact
|
||||||
|
* match only: a prefix match is a different player, and acting on whoever happened to
|
||||||
|
* sort first would be worse than refusing.
|
||||||
*/
|
*/
|
||||||
export async function accountIdForUsername(input: string): Promise<number> {
|
export async function accountIdForUsername(input: string): Promise<number> {
|
||||||
const name = input.trim().replace(/^@/, '')
|
const name = input.trim().replace(/^@/, '')
|
||||||
if (name === '') throw new Error('Enter a username.')
|
if (name === '') throw new Error('Enter a username.')
|
||||||
const matches = await call<Array<{ accountId?: number; username?: string }>>(
|
const found = await accountByUsername(name)
|
||||||
`${where().accounts}/account/search?name=${encodeURIComponent(name)}`
|
|
||||||
)
|
|
||||||
const found = Array.isArray(matches)
|
|
||||||
? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase())
|
|
||||||
: undefined
|
|
||||||
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
|
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
|
||||||
return found.accountId
|
return found.accountId
|
||||||
}
|
}
|
||||||
@@ -245,6 +254,9 @@ export interface PublicAccount {
|
|||||||
accountId: number
|
accountId: number
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
profileImage: string
|
||||||
|
bannerImage: string
|
||||||
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
|
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 |
@@ -2074,3 +2074,19 @@ button[type='submit']:disabled {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The login card's second door, under the password form: an "or" and the Discord button,
|
||||||
|
separated from the form by a rule so the two aren't read as one. */
|
||||||
|
.alt-signin {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alt-signin > .muted:first-child {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,6 +144,14 @@ export type Env = SharedHonoEnv & {
|
|||||||
* all closes the claim, exactly as an unset one does.
|
* all closes the claim, exactly as an unset one does.
|
||||||
*/
|
*/
|
||||||
DISCORD_BENEFITS_ROLE_IDS?: string
|
DISCORD_BENEFITS_ROLE_IDS?: string
|
||||||
|
/**
|
||||||
|
* Discord role ids whose holders have Rec Room Studio upload access without the
|
||||||
|
* account's `hasStudio` flag (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`; the var
|
||||||
|
* `auth` stamps `betastudio` from). `/api/studio-access` answers `granted` through the
|
||||||
|
* same `hasStudioAccess`, so the Studio settings page agrees with the token. The staff
|
||||||
|
* list and its add/remove still manage the flag alone.
|
||||||
|
*/
|
||||||
|
STUDIO_ROLLOUT?: string
|
||||||
/**
|
/**
|
||||||
* The bot user's token for the scheduled role refresh (discord-roles.ts). Optional in
|
* The bot user's token for the scheduled role refresh (discord-roles.ts). Optional in
|
||||||
* effect: the binding must exist for the deploy, but a placeholder value just leaves the
|
* effect: the binding must exist for the deploy, but a placeholder value just leaves the
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize'
|
|||||||
*/
|
*/
|
||||||
export const SCOPES = 'identify guilds.members.read'
|
export const SCOPES = 'identify guilds.members.read'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What "Sign in with Discord" asks for: the user's id and nothing else. The sign-in has no
|
||||||
|
* question to ask about the guild — it only needs to know WHO, so the account linked to that
|
||||||
|
* Discord user can be signed into (or made). Asking for the member read here would put a
|
||||||
|
* permission on the consent screen that the sign-in never uses; the benefits claim is where
|
||||||
|
* roles are read, with its own consent.
|
||||||
|
*/
|
||||||
|
export const LOGIN_SCOPES = 'identify'
|
||||||
|
|
||||||
/** Everything the claim needs configured. Resolved per request; see `discordConfig`. */
|
/** Everything the claim needs configured. Resolved per request; see `discordConfig`. */
|
||||||
export interface DiscordConfig {
|
export interface DiscordConfig {
|
||||||
/** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */
|
/** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */
|
||||||
@@ -170,6 +179,15 @@ export async function readSecret(secret: SecretsStoreSecret, name: string): Prom
|
|||||||
*/
|
*/
|
||||||
export const redirectUri = (request: Request): string => new URL('/claim', request.url).toString()
|
export const redirectUri = (request: Request): string => new URL('/claim', request.url).toString()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sign-in's return leg — its own registered redirect, so Discord sends a sign-in back to
|
||||||
|
* a page that knows it is one. `/claim` can't double for it: that page needs a session
|
||||||
|
* before it does anything, and a sign-in is how the session gets made. Same derivation,
|
||||||
|
* same byte-for-byte rule, same reason the SPA doesn't get to supply it. See App.tsx.
|
||||||
|
*/
|
||||||
|
export const loginRedirectUri = (request: Request): string =>
|
||||||
|
new URL('/login/discord', request.url).toString()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Swap an authorization code for an access token. Returns null on any refusal — a code
|
* Swap an authorization code for an access token. Returns null on any refusal — a code
|
||||||
* that was already spent, expired (they live ~1 minute), issued to another app, or paired
|
* that was already spent, expired (they live ~1 minute), issued to another app, or paired
|
||||||
@@ -278,6 +296,39 @@ export async function fetchGuildMembership(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who a token belongs to — the `identify` scope's one answer. */
|
||||||
|
export interface DiscordUser {
|
||||||
|
/** The Discord user's id (a snowflake, kept as a string — it exceeds 2^53). */
|
||||||
|
id: string
|
||||||
|
/** Their Discord username, for the confirmation line. Display only, never stored. */
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The token owner's own Discord user, or null when Discord won't say or can't be reached.
|
||||||
|
* The sign-in's one read: it needs the id, and `identify` grants exactly `GET /users/@me`.
|
||||||
|
*/
|
||||||
|
export async function fetchUser(accessToken: string): Promise<DiscordUser | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/@me`, {
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.info('discord did not return the token owner', { status: res.status })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const user = (await res.json()) as { id?: unknown; username?: unknown }
|
||||||
|
if (typeof user.id !== 'string' || user.id === '') {
|
||||||
|
logger.error('discord returned a user with no id')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { id: user.id, username: typeof user.username === 'string' ? user.username : '' }
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('could not reach discord to read the token owner', { error: String(err) })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hand the access token back to Discord once the roles have been read.
|
* Hand the access token back to Discord once the roles have been read.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
import { logger } from '@repo/hono-helpers'
|
import { logger } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
|
import { hasStudioAccess } from '../../auth/src/platform-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { Account } from '@repo/domain'
|
import type { Account } from '@repo/domain'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
@@ -20,15 +22,18 @@ import type { App } from './context'
|
|||||||
* the signed-in player. Staff add and remove people through the routes under
|
* the signed-in player. Staff add and remove people through the routes under
|
||||||
* `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation.
|
* `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation.
|
||||||
* The grant is the account's `hasStudio` flag; `auth` stamps `betastudio` from it
|
* The grant is the account's `hasStudio` flag; `auth` stamps `betastudio` from it
|
||||||
* on the next Studio sign-in or refresh. Who granted what is on `audit_log`.
|
* on the next Studio sign-in or refresh. Who granted what is on `audit_log`. A Discord
|
||||||
|
* role in the `STUDIO_ROLLOUT` var grants the same access without the flag
|
||||||
|
* (`hasStudioAccess`); the status below reports it, the staff list does not — that list
|
||||||
|
* is the accounts staff added by hand.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** The signed-in player's own flag, and nothing else. 401 with no session. */
|
/** Whether the signed-in player can upload — flag or rollout role. 401 with no session. */
|
||||||
export async function studioAccessStatusHandler(c: Context<App>) {
|
export async function studioAccessStatusHandler(c: Context<App>) {
|
||||||
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||||
const account = await getAccount(c.env.DB, accountId)
|
const account = await getAccount(c.env.DB, accountId)
|
||||||
return c.json({ granted: account?.hasStudio === true })
|
return c.json({ granted: await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every account with the flag set. */
|
/** Every account with the flag set. */
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ import {
|
|||||||
countAccountsForPlatformIdentity,
|
countAccountsForPlatformIdentity,
|
||||||
getLinksForAccount,
|
getLinksForAccount,
|
||||||
getLinksForPlatform,
|
getLinksForPlatform,
|
||||||
|
hasRecordedRoles,
|
||||||
isPlatformIdentityLinked,
|
isPlatformIdentityLinked,
|
||||||
linkPlatformIdentity,
|
linkPlatformIdentity,
|
||||||
PLATFORM_SCHEMA_DDL,
|
PLATFORM_SCHEMA_DDL,
|
||||||
|
recordFirstRoles,
|
||||||
} from '../../../../auth/src/platform-db'
|
} from '../../../../auth/src/platform-db'
|
||||||
import {
|
import {
|
||||||
BALANCE_SCHEMA_DDL,
|
BALANCE_SCHEMA_DDL,
|
||||||
@@ -68,7 +70,13 @@ import {
|
|||||||
INVENTORY_CUSTOM_SCHEMA_DDL,
|
INVENTORY_CUSTOM_SCHEMA_DDL,
|
||||||
ownedCustomAvatarItemIds,
|
ownedCustomAvatarItemIds,
|
||||||
} from '../../../../econ/src/inventory-custom-db'
|
} from '../../../../econ/src/inventory-custom-db'
|
||||||
import { discordConfig, parseRoleIds, qualifies } from '../../discord'
|
import {
|
||||||
|
discordConfig,
|
||||||
|
loginRedirectUri,
|
||||||
|
parseRoleIds,
|
||||||
|
qualifies,
|
||||||
|
redirectUri,
|
||||||
|
} from '../../discord'
|
||||||
import { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles'
|
import { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles'
|
||||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||||||
@@ -167,6 +175,8 @@ it('advertises signup and where the other workers live', async () => {
|
|||||||
// even be started. Note the client id is NOT leaked by a closed config.
|
// even be started. Note the client id is NOT leaked by a closed config.
|
||||||
benefitsEnabled: false,
|
benefitsEnabled: false,
|
||||||
discordAuthorizeUrl: null,
|
discordAuthorizeUrl: null,
|
||||||
|
// "Sign in with Discord" is on the same switch, and closed with it.
|
||||||
|
discordLoginUrl: null,
|
||||||
hosts: {
|
hosts: {
|
||||||
auth: 'https://auth.rec.example.com',
|
auth: 'https://auth.rec.example.com',
|
||||||
accounts: 'https://accounts.rec.example.com',
|
accounts: 'https://accounts.rec.example.com',
|
||||||
@@ -671,16 +681,77 @@ it('reports where an account stands on benefits', async () => {
|
|||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({ hasPlus: false, linked: false })
|
expect(await res.json()).toEqual({ hasPlus: false, linked: false })
|
||||||
|
|
||||||
|
// A bare link — what "Sign in with Discord" leaves on an account it just made — is NOT a
|
||||||
|
// claim: no roles were ever recorded, so the page must still offer the claim.
|
||||||
await updateAccount(env.DB, 4003, { hasPlus: true })
|
await updateAccount(env.DB, 4003, { hasPlus: true })
|
||||||
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
|
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
|
||||||
res = await SELF.fetch('https://example.com/api/benefits/status', {
|
res = await SELF.fetch('https://example.com/api/benefits/status', {
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
// `linked` says THAT a Discord identity is attached, never which one — the id is of no
|
expect(await res.json()).toEqual({ hasPlus: true, linked: false })
|
||||||
// use to the page, and an account's linked identities have no business on the wire.
|
|
||||||
|
// Claimed (roles recorded, even none). `linked` says THAT a Discord identity has claimed,
|
||||||
|
// never which one — the id is of no use to the page, and an account's linked identities
|
||||||
|
// have no business on the wire.
|
||||||
|
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001', [])
|
||||||
|
res = await SELF.fetch('https://example.com/api/benefits/status', {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
expect(await res.json()).toEqual({ hasPlus: true, linked: true })
|
expect(await res.json()).toEqual({ hasPlus: true, linked: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// How the claim decides "first claim" now that a Discord link can predate it: a link made by
|
||||||
|
// "Sign in with Discord" has no roles, and writing the first roles onto it is a one-shot
|
||||||
|
// flip — the single-winner write the gift is gated on, which a fresh link gets from its
|
||||||
|
// INSERT instead. Exercised at the helpers, since the route runs through discord.com.
|
||||||
|
it('tells a sign-in’s bare link from a claim, and records the first roles once', async () => {
|
||||||
|
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(false)
|
||||||
|
await linkPlatformIdentity(env.DB, 4010, PlatformType.Discord, '99010')
|
||||||
|
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(false)
|
||||||
|
|
||||||
|
// Nothing to flip on a link that isn't there.
|
||||||
|
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99011', ['r1'])).resolves.toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
// The first write wins; the second finds roles already recorded and writes nothing.
|
||||||
|
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99010', ['r1'])).resolves.toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99010', ['r2'])).resolves.toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(true)
|
||||||
|
const [link] = await getLinksForAccount(env.DB, 4010)
|
||||||
|
expect(link.roles).toEqual(['r1'])
|
||||||
|
|
||||||
|
// A claim on a FRESH link records roles in the insert itself — an empty list included.
|
||||||
|
await linkPlatformIdentity(env.DB, 4011, PlatformType.Discord, '99012', [])
|
||||||
|
await expect(hasRecordedRoles(env.DB, 4011, PlatformType.Discord)).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Sign in with Discord ------------------------------------------------
|
||||||
|
|
||||||
|
// Closed on the same switch as the claim, and refused the same way: the half-configured
|
||||||
|
// app the tests run with has a client id and secret and nothing else.
|
||||||
|
it('refuses a discord sign-in while the discord app is only half configured', async () => {
|
||||||
|
const res = await SELF.fetch('https://example.com/api/login/discord', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code: 'whatever' }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
expect(await res.json()).toEqual({ error: 'Signing in with Discord is not available here.' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// The two return legs are different registered redirects, both derived from the request's
|
||||||
|
// own origin (never the body). A sign-in can't come back to `/claim`: that page wants a
|
||||||
|
// session before it does anything, and a sign-in is how one gets made.
|
||||||
|
it('sends a sign-in back to its own redirect, derived from the request origin', () => {
|
||||||
|
const request = new Request('https://play.example.com/api/config')
|
||||||
|
expect(redirectUri(request)).toBe('https://play.example.com/claim')
|
||||||
|
expect(loginRedirectUri(request)).toBe('https://play.example.com/login/discord')
|
||||||
|
})
|
||||||
|
|
||||||
// The once-only guard the claim is built on. Without it, one Discord member holding the
|
// The once-only guard the claim is built on. Without it, one Discord member holding the
|
||||||
// role could walk it around every RecFlare account they own; with it, the second claim is
|
// role could walk it around every RecFlare account they own; with it, the second claim is
|
||||||
// refused and the first account keeps the benefit. Exercised at the two lookups the route
|
// refused and the first account keeps the benefit. Exercised at the two lookups the route
|
||||||
@@ -3023,6 +3094,27 @@ it('tells a signed-in player whether they can upload, and nobody else', async ()
|
|||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({ granted: false })
|
expect(await res.json()).toEqual({ granted: false })
|
||||||
|
|
||||||
|
// A Discord role named in STUDIO_ROLLOUT grants the same access without the flag, and the
|
||||||
|
// page reads it from the same helper `auth` stamps `betastudio` from, so the two agree.
|
||||||
|
// The account has to exist: a link alone, with no account behind it, grants nothing.
|
||||||
|
await updateAccount(env.DB, 8601, { username: 'StudioFan' })
|
||||||
|
await linkPlatformIdentity(env.DB, 8601, PlatformType.Discord, '500000000000008601', [
|
||||||
|
'1077000000000000031',
|
||||||
|
])
|
||||||
|
const original = env.STUDIO_ROLLOUT
|
||||||
|
try {
|
||||||
|
env.STUDIO_ROLLOUT = '1077000000000000031'
|
||||||
|
const rolled = await SELF.fetch('https://example.com/api/studio-access', {
|
||||||
|
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||||||
|
})
|
||||||
|
expect(await rolled.json()).toEqual({ granted: true })
|
||||||
|
// The flag itself never moved; the staff list is still empty of this account.
|
||||||
|
expect((await getAccount(env.DB, 8601))?.hasStudio).not.toBe(true)
|
||||||
|
} finally {
|
||||||
|
if (original === undefined) delete env.STUDIO_ROLLOUT
|
||||||
|
else env.STUDIO_ROLLOUT = original
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lets staff add, list, and remove studio upload access', async () => {
|
it('lets staff add, list, and remove studio upload access', async () => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { PlatformType } from '@repo/domain/src/enums'
|
|||||||
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
||||||
import { getStatSeries } from '@repo/domain/src/stats-db'
|
import { getStatSeries } from '@repo/domain/src/stats-db'
|
||||||
import { intVar, logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { generateIdentityAssertion, validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
|
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
|
||||||
// Discord identity is stored there as a PlatformType.Discord link — it is an account ↔
|
// Discord identity is stored there as a PlatformType.Discord link — it is an account ↔
|
||||||
@@ -16,9 +16,10 @@ import { validateAndGetAccountId } from '@repo/jwt'
|
|||||||
// verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS).
|
// verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS).
|
||||||
import {
|
import {
|
||||||
countAccountsForPlatformIdentity,
|
countAccountsForPlatformIdentity,
|
||||||
getLinksForAccount,
|
hasRecordedRoles,
|
||||||
isPlatformIdentityLinked,
|
isPlatformIdentityLinked,
|
||||||
linkPlatformIdentity,
|
linkPlatformIdentity,
|
||||||
|
recordFirstRoles,
|
||||||
} from '../../auth/src/platform-db'
|
} from '../../auth/src/platform-db'
|
||||||
// The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is
|
// The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is
|
||||||
// econ's cron paid on demand: the first time an account links its Discord, the claim hands
|
// econ's cron paid on demand: the first time an account links its Discord, the claim hands
|
||||||
@@ -31,6 +32,9 @@ import {
|
|||||||
discordConfig,
|
discordConfig,
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
fetchGuildMembership,
|
fetchGuildMembership,
|
||||||
|
fetchUser,
|
||||||
|
LOGIN_SCOPES,
|
||||||
|
loginRedirectUri,
|
||||||
qualifies,
|
qualifies,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
revokeToken,
|
revokeToken,
|
||||||
@@ -132,14 +136,27 @@ const claimant = async (c: Context<App>): Promise<number | null> =>
|
|||||||
* (see App.tsx). Everything else about the request is fixed by the server.
|
* (see App.tsx). Everything else about the request is fixed by the server.
|
||||||
*/
|
*/
|
||||||
function authorizeUrl(request: Request, clientId: string): string {
|
function authorizeUrl(request: Request, clientId: string): string {
|
||||||
|
return consentUrl(clientId, SCOPES, redirectUri(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The consent URL for "Sign in with Discord": the same app, the narrower `identify` scope,
|
||||||
|
* and the sign-in's own return leg (`/login/discord`). Served beside the claim's URL so the
|
||||||
|
* SPA never assembles either.
|
||||||
|
*/
|
||||||
|
function loginAuthorizeUrl(request: Request, clientId: string): string {
|
||||||
|
return consentUrl(clientId, LOGIN_SCOPES, loginRedirectUri(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
function consentUrl(clientId: string, scope: string, redirect: string): string {
|
||||||
const url = new URL(AUTHORIZE_URL)
|
const url = new URL(AUTHORIZE_URL)
|
||||||
url.search = new URLSearchParams({
|
url.search = new URLSearchParams({
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: SCOPES,
|
scope,
|
||||||
redirect_uri: redirectUri(request),
|
redirect_uri: redirect,
|
||||||
// Skip Discord's "you've already authorized this app, continue?" interstitial on a
|
// Skip Discord's "you've already authorized this app, continue?" interstitial on a
|
||||||
// repeat claim; the player has already pressed a button that says what this does.
|
// repeat visit; the player has already pressed a button that says what this does.
|
||||||
prompt: 'none',
|
prompt: 'none',
|
||||||
}).toString()
|
}).toString()
|
||||||
return url.toString()
|
return url.toString()
|
||||||
@@ -195,6 +212,10 @@ const app = new Hono<App>()
|
|||||||
// ids never leave the worker, since it's the worker that asks Discord the question.
|
// ids never leave the worker, since it's the worker that asks Discord the question.
|
||||||
benefitsEnabled: discord !== null,
|
benefitsEnabled: discord !== null,
|
||||||
discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : null,
|
discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : null,
|
||||||
|
// "Sign in with Discord", on the SAME switch: the sign-in resolves (or makes) the
|
||||||
|
// account through the Discord link, and only the claim ever writes one with roles, so
|
||||||
|
// an operator who hasn't set the claim up has no reason to offer the door either.
|
||||||
|
discordLoginUrl: discord ? loginAuthorizeUrl(c.req.raw, discord.clientId) : null,
|
||||||
hosts: {
|
hosts: {
|
||||||
auth: authBase(c.env),
|
auth: authBase(c.env),
|
||||||
accounts: accountsBase(c.env),
|
accounts: accountsBase(c.env),
|
||||||
@@ -344,6 +365,90 @@ const app = new Hono<App>()
|
|||||||
return c.json(token)
|
return c.json(token)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Sign in with Discord -----------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem a Discord authorization code from the SIGN-IN consent (`/login/discord`), prove
|
||||||
|
* who the Discord user is, and ask `auth` for a session on the account linked to them —
|
||||||
|
* or, when there is none, for a brand-new account linked to them.
|
||||||
|
*
|
||||||
|
* The browser sends ONLY the code, as it does for the claim, and for the same reason: the
|
||||||
|
* exchange needs the client secret. What is different is what comes after. The claim acts
|
||||||
|
* on a session the caller already has; this is how one GETS a session, so there is no
|
||||||
|
* bearer token and nothing here writes to the account. Instead the proven identity is
|
||||||
|
* signed into an ASSERTION with the shared JWT_SECRET and posted to `auth`'s
|
||||||
|
* `identity_assertion` grant, which resolves it through the link table exactly as
|
||||||
|
* `cached_login` resolves a Steam ticket, and answers the ordinary token body. Resolving
|
||||||
|
* the account — and minting one — stays `auth`'s: www never picks an account id.
|
||||||
|
*
|
||||||
|
* Posted over the service binding with the browser's real IP forwarded, like signup, so
|
||||||
|
* the per-IP cap on the sign-up path counts the browser and not Cloudflare. The token
|
||||||
|
* response is relayed verbatim; `created: true` on it is the SPA's cue to say an account
|
||||||
|
* was just made and that setting a password is what makes it usable in the game.
|
||||||
|
*/
|
||||||
|
.post('/api/login/discord', async (c) => {
|
||||||
|
const config = await discordConfig(c.env)
|
||||||
|
if (!config) return c.json({ error: 'Signing in with Discord is not available here.' }, 403)
|
||||||
|
|
||||||
|
type LoginBody = { code?: string }
|
||||||
|
const { code } = await c.req.json<LoginBody>().catch(() => ({}) as LoginBody)
|
||||||
|
if (!code) return c.json({ error: 'No Discord authorization code was provided.' }, 400)
|
||||||
|
|
||||||
|
const accessToken = await exchangeCode(config, code, loginRedirectUri(c.req.raw))
|
||||||
|
if (accessToken === null) {
|
||||||
|
return c.json(
|
||||||
|
{ error: 'That Discord sign-in could not be completed. Please try again.' },
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const user = await fetchUser(accessToken)
|
||||||
|
await revokeToken(config, accessToken)
|
||||||
|
if (user === null) {
|
||||||
|
return c.json(
|
||||||
|
{ error: 'That Discord sign-in could not be completed. Please try again.' },
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwtSecret = await c.env.JWT_SECRET.get().catch(() => '')
|
||||||
|
if (jwtSecret === '') {
|
||||||
|
logger.error('cannot sign an identity assertion: JWT_SECRET is unreadable')
|
||||||
|
return c.json({ error: authUnreachable('login') }, 502)
|
||||||
|
}
|
||||||
|
const assertion = await generateIdentityAssertion(
|
||||||
|
{ platform: PlatformType.Discord, platformId: user.id },
|
||||||
|
jwtSecret
|
||||||
|
)
|
||||||
|
const res = await postAuthForm(
|
||||||
|
c.env,
|
||||||
|
'/connect/token',
|
||||||
|
{ grant_type: 'identity_assertion', assertion },
|
||||||
|
{ clientIp: c.req.header('cf-connecting-ip') }
|
||||||
|
).catch(() => null)
|
||||||
|
if (res === null) {
|
||||||
|
logger.error('could not reach auth to redeem an identity assertion')
|
||||||
|
return c.json({ error: authUnreachable('login') }, 502)
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const failure = await readAuthError(res, 'login')
|
||||||
|
logger.info('auth refused a discord sign-in', {
|
||||||
|
status: res.status,
|
||||||
|
upstream: failure.upstream,
|
||||||
|
})
|
||||||
|
return c.json({ error: failure.message }, failure.status)
|
||||||
|
}
|
||||||
|
const token = (await res.json().catch(() => null)) as {
|
||||||
|
access_token?: string
|
||||||
|
created?: boolean
|
||||||
|
} | null
|
||||||
|
if (!token?.access_token) {
|
||||||
|
logger.error('auth answered a discord sign-in with no access_token')
|
||||||
|
return c.json({ error: authUnreachable('login') }, 502)
|
||||||
|
}
|
||||||
|
logger.info(token.created ? 'created an account from a discord sign-in' : 'discord sign-in')
|
||||||
|
return c.json({ ...token, discordUsername: user.username })
|
||||||
|
})
|
||||||
|
|
||||||
// ---- Discord benefits claim ---------------------------------------------
|
// ---- Discord benefits claim ---------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -358,16 +463,18 @@ const app = new Hono<App>()
|
|||||||
.get('/api/benefits/status', async (c) => {
|
.get('/api/benefits/status', async (c) => {
|
||||||
const accountId = await claimant(c)
|
const accountId = await claimant(c)
|
||||||
if (accountId === null) return c.body(null, 401)
|
if (accountId === null) return c.body(null, 401)
|
||||||
const [account, links] = await Promise.all([
|
const [account, claimed] = await Promise.all([
|
||||||
getAccount(c.env.DB, accountId),
|
getAccount(c.env.DB, accountId),
|
||||||
getLinksForAccount(c.env.DB, accountId),
|
hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord),
|
||||||
])
|
])
|
||||||
return c.json({
|
return c.json({
|
||||||
hasPlus: account?.hasPlus ?? false,
|
hasPlus: account?.hasPlus ?? false,
|
||||||
// Whether this account is already tied to a Discord identity — not WHICH one. The
|
// Whether this account has CLAIMED through a Discord identity — a link with roles
|
||||||
// player knows their own Discord; the id is of no use to the page and every reason
|
// recorded — not WHICH one. The player knows their own Discord; the id is of no use to
|
||||||
// to keep an account's linked identities off the wire.
|
// the page and every reason to keep an account's linked identities off the wire. A
|
||||||
linked: links.some((link) => link.platform === PlatformType.Discord),
|
// bare link, which "Sign in with Discord" leaves on a new account, reads as not yet
|
||||||
|
// claimed: the page then offers the claim, which is what that player still has to do.
|
||||||
|
linked: claimed,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -456,12 +563,6 @@ const app = new Hono<App>()
|
|||||||
PlatformType.Discord,
|
PlatformType.Discord,
|
||||||
membership.userId
|
membership.userId
|
||||||
)
|
)
|
||||||
// Whether this claim is the account's FIRST Discord link — what the one-time gift below
|
|
||||||
// keys on. Decided before the write, from the table: an account re-claiming with the
|
|
||||||
// same Discord is not first (`alreadyMine`), and neither is one that already carries a
|
|
||||||
// DIFFERENT Discord identity, which the once-only guard doesn't stop. Only asked when
|
|
||||||
// it could be true, to spare the re-claim a query.
|
|
||||||
let firstLink = false
|
|
||||||
if (!alreadyMine) {
|
if (!alreadyMine) {
|
||||||
const claimedElsewhere = await countAccountsForPlatformIdentity(
|
const claimedElsewhere = await countAccountsForPlatformIdentity(
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
@@ -477,9 +578,26 @@ const app = new Hono<App>()
|
|||||||
409
|
409
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const links = await getLinksForAccount(c.env.DB, accountId)
|
|
||||||
firstLink = !links.some((link) => link.platform === PlatformType.Discord)
|
|
||||||
}
|
}
|
||||||
|
// Whether this is the account's FIRST CLAIM — what the one-time gift below keys on.
|
||||||
|
// Decided before the write, from the table: a claim is recorded as roles on a Discord
|
||||||
|
// link, so an account with any Discord link carrying roles (this identity's, or a
|
||||||
|
// DIFFERENT one the once-only guard doesn't stop) has claimed before. A link with NO
|
||||||
|
// roles has not: that is what "Sign in with Discord" leaves on an account it just made,
|
||||||
|
// and that player's first claim is still their first.
|
||||||
|
const firstClaim = !(await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord))
|
||||||
|
// On a link that already exists without roles, writing them is the claim's one-shot
|
||||||
|
// write — the single-winner guard a fresh link gets from its INSERT below. Asked only
|
||||||
|
// when the link is the caller's: on anyone else's it would be refused above.
|
||||||
|
const recordedFirst =
|
||||||
|
alreadyMine &&
|
||||||
|
(await recordFirstRoles(
|
||||||
|
c.env.DB,
|
||||||
|
accountId,
|
||||||
|
PlatformType.Discord,
|
||||||
|
membership.userId,
|
||||||
|
membership.roles
|
||||||
|
))
|
||||||
|
|
||||||
// The link is written for EVERY guild member, qualifying role or not, and it is
|
// The link is written for EVERY guild member, qualifying role or not, and it is
|
||||||
// idempotent: INSERT OR IGNORE (so `linkedAt` keeps the FIRST claim's time), with the
|
// idempotent: INSERT OR IGNORE (so `linkedAt` keeps the FIRST claim's time), with the
|
||||||
@@ -519,13 +637,14 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The one-time supporter gift: the box econ's cron would hand this member, paid now,
|
// The one-time supporter gift: the box econ's cron would hand this member, paid now,
|
||||||
// once. Gated on the insert having happened too (`created`), so two claims racing on
|
// once. Gated on a single-winner write having happened too — the insert (`created`), or
|
||||||
|
// the first roles onto a sign-in's bare link (`recordedFirst`) — so two claims racing on
|
||||||
// one fresh account can't both pay. Best-effort AFTER Plus is granted and the link is
|
// one fresh account can't both pay. Best-effort AFTER Plus is granted and the link is
|
||||||
// written: a grant that fails partway is logged with what to check, as econ's cron
|
// written: a grant that fails partway is logged with what to check, as econ's cron
|
||||||
// logs its own, and the claim still answers — the player got what they came for, and
|
// logs its own, and the claim still answers — the player got what they came for, and
|
||||||
// a retry that also failed partway is how a balance gets credited twice.
|
// a retry that also failed partway is how a balance gets credited twice.
|
||||||
let tokensAwarded: number | null = null
|
let tokensAwarded: number | null = null
|
||||||
if (created && firstLink) {
|
if ((created || recordedFirst) && firstClaim) {
|
||||||
try {
|
try {
|
||||||
const role = await grantDiscordRoleGift(
|
const role = await grantDiscordRoleGift(
|
||||||
c.env,
|
c.env,
|
||||||
|
|||||||
@@ -292,10 +292,27 @@ export const SEARCH_LIMIT = 20
|
|||||||
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
|
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
|
||||||
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
|
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The smallest string that sorts after every string beginning with `prefix` under
|
||||||
|
* SQLite's default BINARY (byte-wise) collation: the prefix followed by U+10FFFF, whose
|
||||||
|
* UTF-8 encoding (F4 8F BF BF) is the highest valid sequence. The pair
|
||||||
|
* `>= prefix AND < prefixUpperBound(prefix)` is the prefix match as a range.
|
||||||
|
*/
|
||||||
|
const prefixUpperBound = (prefix: string): string => `${prefix}\u{10FFFF}`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered
|
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered
|
||||||
* alphabetically. Backed by the indexed `username_lower` generated column, so the
|
* alphabetically. Returns up to `limit` matches.
|
||||||
* `name%` LIKE stays index-friendly. Returns up to `limit` matches.
|
*
|
||||||
|
* The prefix is applied as a RANGE on the indexed `username_lower` generated column
|
||||||
|
* (`>= name AND < name+U+10FFFF`), not as `LIKE 'name%'`. SQLite only turns a LIKE into
|
||||||
|
* an index seek when the LIKE is case-sensitive or the index is NOCASE, and never when
|
||||||
|
* it carries an ESCAPE clause, so the LIKE form was a full index scan on every call —
|
||||||
|
* 48k rows read per keystroke in the site's search bar, tens of millions a day on D1's
|
||||||
|
* meter. The range is a seek (`SEARCH … (username_lower>? AND username_lower<?)`) and
|
||||||
|
* walks the index in `ORDER BY` order, so the LIMIT stops it after `limit` rows. The
|
||||||
|
* LIKE stays as a residual filter on those rows so the match is exactly "begins with",
|
||||||
|
* with the user's `%`/`_` taken literally.
|
||||||
*/
|
*/
|
||||||
export async function searchAccounts(
|
export async function searchAccounts(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -306,9 +323,11 @@ export async function searchAccounts(
|
|||||||
if (q === '') return []
|
if (q === '') return []
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT data FROM account WHERE username_lower LIKE ?1 ESCAPE '\\' ORDER BY username_lower LIMIT ?2`
|
`SELECT data FROM account
|
||||||
|
WHERE username_lower >= ?1 AND username_lower < ?2 AND username_lower LIKE ?3 ESCAPE '\\'
|
||||||
|
ORDER BY username_lower LIMIT ?4`
|
||||||
)
|
)
|
||||||
.bind(`${escapeLike(q)}%`, limit)
|
.bind(q, prefixUpperBound(q), `${escapeLike(q)}%`, limit)
|
||||||
.all<AccountRow>()
|
.all<AccountRow>()
|
||||||
return parseAll(results)
|
return parseAll(results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/** Shared persisted Studio authoring state. Existing storefront items remain unchanged. */
|
||||||
|
export const AVATAR_STUDIO_SCHEMA = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface AvatarStudioItem {
|
||||||
|
CustomAvatarItemId: string
|
||||||
|
CreatorAccountId: number
|
||||||
|
OutfitType: number
|
||||||
|
Accessibility: number
|
||||||
|
CurrentSaves: Array<Record<string, unknown>>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
export interface AvatarStudioSave {
|
||||||
|
id: number
|
||||||
|
item_id: string
|
||||||
|
body_type: number
|
||||||
|
unity_asset_id: string
|
||||||
|
source_filename: string
|
||||||
|
source_hash: string
|
||||||
|
thumbnail: string
|
||||||
|
moderation_image: string | null
|
||||||
|
configuration: string
|
||||||
|
description: string | null
|
||||||
|
update_current: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
export async function avatarStudioFlags(db: D1Database, accountId: number): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT flags FROM avatar_studio_access WHERE account_id=?')
|
||||||
|
.bind(accountId)
|
||||||
|
.first<{ flags: number }>()
|
||||||
|
return row?.flags ?? 0
|
||||||
|
}
|
||||||
|
export const AVATAR_STUDIO_SLOTS = [
|
||||||
|
0, 2, 3, 10, 20, 100, 101, 102, 103, 104, 200, 203, 300, 301, 500, 501, 502, 503, 504, 505, 506,
|
||||||
|
507, 508, 509, 510, 511, 512, 1000,
|
||||||
|
]
|
||||||
|
export function avatarItemAccessBit(outfitType: number): number {
|
||||||
|
return outfitType === 1000 ? 4 : outfitType >= 500 && outfitType <= 512 ? 2 : 1
|
||||||
|
}
|
||||||
|
export async function avatarStudioItem(
|
||||||
|
db: D1Database,
|
||||||
|
id: string
|
||||||
|
): Promise<AvatarStudioItem | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
|
||||||
|
.bind(id)
|
||||||
|
.first<{ data: string }>()
|
||||||
|
return row ? (JSON.parse(row.data) as AvatarStudioItem) : null
|
||||||
|
}
|
||||||
|
export async function avatarStudioSave(
|
||||||
|
db: D1Database,
|
||||||
|
itemId: string,
|
||||||
|
id: number
|
||||||
|
): Promise<AvatarStudioSave | null> {
|
||||||
|
return db
|
||||||
|
.prepare('SELECT * FROM avatar_studio_save WHERE item_id=? AND id=?')
|
||||||
|
.bind(itemId, id)
|
||||||
|
.first<AvatarStudioSave>()
|
||||||
|
}
|
||||||
|
export async function avatarSaveDto(db: D1Database, row: AvatarStudioSave, target = 0) {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT unity_asset_id AS UnityAssetId,target AS Target,version AS Version,filename AS Filename,hash AS Hash,kind FROM unity_asset WHERE unity_asset_id=?'
|
||||||
|
)
|
||||||
|
.bind(row.unity_asset_id)
|
||||||
|
.all<{
|
||||||
|
UnityAssetId: string
|
||||||
|
Target: number
|
||||||
|
Version: number
|
||||||
|
Filename: string
|
||||||
|
Hash: string
|
||||||
|
kind: string
|
||||||
|
}>()
|
||||||
|
const main = results.find((a) => a.Target === target && a.kind === 'main')
|
||||||
|
const material = results.find((a) => a.Target === target && a.kind === 'material')
|
||||||
|
return {
|
||||||
|
CustomAvatarItemSaveId: row.id,
|
||||||
|
CustomAvatarItemId: row.item_id,
|
||||||
|
UnityAssetId: row.unity_asset_id,
|
||||||
|
BodyType: row.body_type,
|
||||||
|
CreatedAt: row.created_at,
|
||||||
|
ModifiedAt: row.created_at,
|
||||||
|
Description: row.description,
|
||||||
|
ThumbnailFileName: row.thumbnail,
|
||||||
|
AdditionalConfiguration: row.configuration,
|
||||||
|
SourceUnityAssetFilename: row.source_filename,
|
||||||
|
SourceUnityAssetHash: row.source_hash,
|
||||||
|
ModerationImageFileName: row.moderation_image,
|
||||||
|
UnityAsset: main?.Filename ?? null,
|
||||||
|
UnityAssetHash: main?.Hash ?? null,
|
||||||
|
UnityAsset2: material?.Filename ?? null,
|
||||||
|
UnityAsset2Hash: material?.Hash ?? null,
|
||||||
|
BakedAssets: results.filter((a) => a.kind !== 'stripped').map(({ kind: _kind, ...a }) => a),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Publish exactly this built revision for its body type, preserving other body types. */
|
||||||
|
export async function publishAvatarSave(
|
||||||
|
db: D1Database,
|
||||||
|
itemId: string,
|
||||||
|
saveId: number,
|
||||||
|
automatic = false,
|
||||||
|
attempt = 0
|
||||||
|
): Promise<boolean> {
|
||||||
|
const item = await avatarStudioItem(db, itemId)
|
||||||
|
const save = await avatarStudioSave(db, itemId, saveId)
|
||||||
|
if (!item || !save) return false
|
||||||
|
const dto = await avatarSaveDto(db, save)
|
||||||
|
// Badge images are uploaded and published directly by the SDK, without a Unity build.
|
||||||
|
if (!dto.UnityAsset && item.OutfitType !== 1000) return false
|
||||||
|
const quest = await avatarSaveDto(db, save, 2)
|
||||||
|
const {
|
||||||
|
SourceUnityAssetFilename: _source,
|
||||||
|
SourceUnityAssetHash: _hash,
|
||||||
|
BakedAssets: _baked,
|
||||||
|
...published
|
||||||
|
} = dto
|
||||||
|
// JSON mutation is conditional on the complete prior snapshot: concurrent body-type
|
||||||
|
// publications cannot overwrite each other or resurrect a deleted item.
|
||||||
|
const prior = JSON.stringify(item)
|
||||||
|
item.CurrentSaves = [
|
||||||
|
...(item.CurrentSaves ?? []).filter((s) => s.BodyType !== save.body_type),
|
||||||
|
{
|
||||||
|
...published,
|
||||||
|
OutfitType: item.OutfitType,
|
||||||
|
QuestUnityAssetHash: quest.UnityAssetHash,
|
||||||
|
QuestUnityAsset2Hash: quest.UnityAsset2Hash,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
item.ModifiedAt = new Date().toISOString()
|
||||||
|
item.ThumbnailImageFilename = save.thumbnail
|
||||||
|
if (item.OutfitType === 1000)
|
||||||
|
item.CustomBadgeMetadata = { RoomId: JSON.parse(save.configuration)?.d?.d?.r ?? 0 }
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)
|
||||||
|
AND (?=0 OR ?=(SELECT MAX(id) FROM avatar_studio_save WHERE item_id=? AND body_type=?))`
|
||||||
|
)
|
||||||
|
.bind(JSON.stringify(item), itemId, prior, automatic ? 1 : 0, saveId, itemId, save.body_type)
|
||||||
|
.run()
|
||||||
|
if (!result.meta.changes && attempt < 5 && (await avatarStudioItem(db, itemId)))
|
||||||
|
return publishAvatarSave(db, itemId, saveId, automatic, attempt + 1)
|
||||||
|
return result.meta.changes > 0
|
||||||
|
}
|
||||||
@@ -31,3 +31,4 @@ export * from './outfits-db'
|
|||||||
export * from './progression-db'
|
export * from './progression-db'
|
||||||
export * from './relationships-db'
|
export * from './relationships-db'
|
||||||
export * from './validation'
|
export * from './validation'
|
||||||
|
export * from './avatar-studio'
|
||||||
|
|||||||
@@ -2450,7 +2450,7 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
|
|||||||
* it.
|
* it.
|
||||||
*/
|
*/
|
||||||
const serializeRoom = (room: Room): string => {
|
const serializeRoom = (room: Room): string => {
|
||||||
const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room
|
const { SubRooms: _subRooms, Tags: _tags, Badges: _badges, Stats: stats, ...rest } = room
|
||||||
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
|
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2512,13 +2512,21 @@ export async function attachStudioUnityAssets(
|
|||||||
db: D1Database,
|
db: D1Database,
|
||||||
saves: SubRoomDataSave[]
|
saves: SubRoomDataSave[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ids = [
|
// The cloud build partitions prefab/material exports into separate bundles.
|
||||||
...new Set(
|
// The main scene keeps external references to them, so the loader must receive
|
||||||
saves
|
// every export, including shared dependencies, before it instantiates the scene.
|
||||||
.map((save) => save.UnityAssetId)
|
const assetIds = (save: SubRoomDataSave): string[] => {
|
||||||
.filter((id): id is string => typeof id === 'string' && id !== '')
|
if (typeof save.UnityAssetId !== 'string' || save.UnityAssetId === '') return []
|
||||||
),
|
const serverData = save.ServerData as { Exports?: unknown } | null | undefined
|
||||||
]
|
const exports = Array.isArray(serverData?.Exports) ? serverData.Exports : []
|
||||||
|
const ids = new Set([save.UnityAssetId.toLowerCase()])
|
||||||
|
for (const item of exports) {
|
||||||
|
const id = (item as { u?: unknown } | null)?.u
|
||||||
|
if (typeof id === 'string' && id !== '') ids.add(id.toLowerCase())
|
||||||
|
}
|
||||||
|
return [...ids]
|
||||||
|
}
|
||||||
|
const ids = [...new Set(saves.flatMap(assetIds))]
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
const builds = (await listUnityAssetBuilds(db, ids)).filter((build) =>
|
const builds = (await listUnityAssetBuilds(db, ids)).filter((build) =>
|
||||||
isStudioBundleFilename(build.Filename)
|
isStudioBundleFilename(build.Filename)
|
||||||
@@ -2533,7 +2541,7 @@ export async function attachStudioUnityAssets(
|
|||||||
for (const save of saves) {
|
for (const save of saves) {
|
||||||
const id = save.UnityAssetId
|
const id = save.UnityAssetId
|
||||||
if (typeof id !== 'string') continue
|
if (typeof id !== 'string') continue
|
||||||
const baked = bakedUnityAssets(byAsset.get(id.toLowerCase()) ?? [])
|
const baked = bakedUnityAssets(assetIds(save).flatMap((assetId) => byAsset.get(assetId) ?? []))
|
||||||
if (baked.length === 0) continue
|
if (baked.length === 0) continue
|
||||||
save.UnitySubAssets = baked
|
save.UnitySubAssets = baked
|
||||||
}
|
}
|
||||||
@@ -2842,10 +2850,31 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
|
|||||||
// game's Nullable<DateTime> decoder and discards the entire owned-room list.
|
// game's Nullable<DateTime> decoder and discards the entire owned-room list.
|
||||||
if (room.PublishedAt === '') room.PublishedAt = null
|
if (room.PublishedAt === '') room.PublishedAt = null
|
||||||
}
|
}
|
||||||
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms)])
|
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms), attachBadges(db, rooms)])
|
||||||
return rooms
|
return rooms
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Published room-linked image badges; drafts remain on the creator's Studio shelf. */
|
||||||
|
async function attachBadges(db: D1Database, rooms: Room[]): Promise<void> {
|
||||||
|
if (!rooms.length) return
|
||||||
|
// `custom_avatar_item` is owned by api's migrations (0015, badge index 0035), both long
|
||||||
|
// applied to the shared database; this no longer probes `sqlite_master` for it.
|
||||||
|
// Served by `idx_custom_avatar_item_badge_room` (api migrations/0035), a PARTIAL index over
|
||||||
|
// `outfit_type = 1000` keyed by the `json_extract(data,'$.CustomBadgeMetadata.RoomId')`
|
||||||
|
// expression. SQLite matches both only as written: drop or rephrase either term and this
|
||||||
|
// goes back to scanning every custom avatar item on every room page.
|
||||||
|
const rows = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT data,json_extract(data,'$.CustomBadgeMetadata.RoomId') AS room_id FROM custom_avatar_item WHERE outfit_type=1000 AND accessibility<>0 AND json_array_length(json_extract(data,'$.CurrentSaves'))>0 AND json_extract(data,'$.CustomBadgeMetadata.RoomId') IN (SELECT value FROM json_each(?))"
|
||||||
|
)
|
||||||
|
.bind(JSON.stringify(rooms.map(roomIdOf)))
|
||||||
|
.all<{ data: string; room_id: number }>()
|
||||||
|
for (const room of rooms)
|
||||||
|
room.Badges = rows.results
|
||||||
|
.filter((r) => r.room_id === roomIdOf(room))
|
||||||
|
.map((r) => JSON.parse(r.data))
|
||||||
|
}
|
||||||
|
|
||||||
/** A single subroom of a room (columns authoritative), or null if it doesn't exist. */
|
/** A single subroom of a room (columns authoritative), or null if it doesn't exist. */
|
||||||
export async function getSubRoom(
|
export async function getSubRoom(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ export {
|
|||||||
generateToken,
|
generateToken,
|
||||||
generateStudioBuildValidationToken,
|
generateStudioBuildValidationToken,
|
||||||
validateStudioBuildValidationToken,
|
validateStudioBuildValidationToken,
|
||||||
|
studioBuildValidationId,
|
||||||
generatePhotonAuthToken,
|
generatePhotonAuthToken,
|
||||||
|
generateIdentityAssertion,
|
||||||
|
validateIdentityAssertion,
|
||||||
|
IDENTITY_ASSERTION_TTL_SECONDS,
|
||||||
TOKEN_TTL_SECONDS,
|
TOKEN_TTL_SECONDS,
|
||||||
} from './jwt'
|
} from './jwt'
|
||||||
export type { PhotonAuthClaims } from './jwt'
|
export type { IdentityAssertion, PhotonAuthClaims } from './jwt'
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { GAME_VERSION, gameBuild } from '@repo/domain'
|
|||||||
// last a day so the client doesn't have to refresh it.
|
// last a day so the client doesn't have to refresh it.
|
||||||
export const TOKEN_TTL_SECONDS = 86400
|
export const TOKEN_TTL_SECONDS = 86400
|
||||||
|
|
||||||
/** A build host can check component text, but cannot act as an account. */
|
/** A build host can validate content and its job's grants, but cannot act as an account. */
|
||||||
export async function generateStudioBuildValidationToken(
|
export async function generateStudioBuildValidationToken(
|
||||||
buildId: string,
|
buildId: string,
|
||||||
secret: string
|
secret: string
|
||||||
@@ -39,19 +39,27 @@ export async function validateStudioBuildValidationToken(
|
|||||||
request: Request,
|
request: Request,
|
||||||
secret: string
|
secret: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
return (await studioBuildValidationId(request, secret)) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed job identity for read-only, job-scoped validation routes. */
|
||||||
|
export async function studioBuildValidationId(
|
||||||
|
request: Request,
|
||||||
|
secret: string
|
||||||
|
): Promise<string | null> {
|
||||||
const header = request.headers.get('Authorization') ?? ''
|
const header = request.headers.get('Authorization') ?? ''
|
||||||
if (!header.startsWith('Bearer ')) return false
|
if (!header.startsWith('Bearer ')) return null
|
||||||
try {
|
try {
|
||||||
const payload = await verify(header.slice(7), secret, 'HS256')
|
const payload = await verify(header.slice(7), secret, 'HS256')
|
||||||
return (
|
return payload.sub === undefined &&
|
||||||
payload.sub === undefined &&
|
|
||||||
payload.aud === 'studio-build-validation' &&
|
payload.aud === 'studio-build-validation' &&
|
||||||
payload.scope === 'studio.build.validate' &&
|
payload.scope === 'studio.build.validate' &&
|
||||||
typeof payload.buildId === 'string' &&
|
typeof payload.buildId === 'string' &&
|
||||||
/^[1-9]\d*$/.test(payload.buildId)
|
/^[1-9]\d*$/.test(payload.buildId)
|
||||||
)
|
? (payload.buildId as string)
|
||||||
|
: null
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,3 +328,82 @@ export async function generateToken(
|
|||||||
secret
|
secret
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `aud` of an identity assertion — what tells one apart from every other token this
|
||||||
|
* key signs. An access token's audience is the auth host; a Studio build token's is
|
||||||
|
* `studio-build-validation`. Checked on validation, so neither can be replayed as an
|
||||||
|
* assertion, and an assertion — which carries no `sub` — satisfies none of the
|
||||||
|
* `validateAndGet*` readers either.
|
||||||
|
*/
|
||||||
|
const IDENTITY_ASSERTION_AUDIENCE = 'recflare:identity-assertion'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long an assertion is good for. It is minted and redeemed within one request on the
|
||||||
|
* server side (www proves the identity, then posts the assertion to auth), so a minute
|
||||||
|
* covers clock skew between two Workers and nothing else.
|
||||||
|
*/
|
||||||
|
export const IDENTITY_ASSERTION_TTL_SECONDS = 60
|
||||||
|
|
||||||
|
/** A platform identity one worker has verified and is vouching for to `auth`. */
|
||||||
|
export interface IdentityAssertion {
|
||||||
|
/** PlatformType int — Discord (101) today. */
|
||||||
|
platform: number
|
||||||
|
/** The platform's own id for the user — a Discord snowflake, as a string. */
|
||||||
|
platformId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a short-lived, signed statement that the caller has VERIFIED this platform
|
||||||
|
* identity — the credential behind `auth`'s `identity_assertion` grant.
|
||||||
|
*
|
||||||
|
* This exists so a worker other than `auth` can prove an identity `auth` cannot: www
|
||||||
|
* completes a Discord OAuth2 code exchange (which needs the Discord client secret, bound
|
||||||
|
* only to www) and hands `auth` the result, rather than `auth` learning Discord. Signing it
|
||||||
|
* with the shared `JWT_SECRET` adds no trust that didn't exist — a holder of that key can
|
||||||
|
* already mint access tokens outright — and it means the grant works the same over the
|
||||||
|
* service binding and over the public hostname. The assertion names an identity, never an
|
||||||
|
* account: resolving it to one (or minting one) stays `auth`'s decision.
|
||||||
|
*/
|
||||||
|
export async function generateIdentityAssertion(
|
||||||
|
identity: IdentityAssertion,
|
||||||
|
secret: string
|
||||||
|
): Promise<string> {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
return sign(
|
||||||
|
{
|
||||||
|
aud: IDENTITY_ASSERTION_AUDIENCE,
|
||||||
|
platform: identity.platform,
|
||||||
|
platform_id: identity.platformId,
|
||||||
|
iat: now,
|
||||||
|
nbf: now,
|
||||||
|
exp: now + IDENTITY_ASSERTION_TTL_SECONDS,
|
||||||
|
jti: crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
secret,
|
||||||
|
'HS256'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an identity assertion and return the identity it vouches for, or null when
|
||||||
|
* the token is malformed, mis-signed, expired, or not an assertion at all. The audience
|
||||||
|
* check is what refuses an access token presented here, and a missing `sub` is required
|
||||||
|
* for the same reason in the other direction: an assertion must never pass as a session.
|
||||||
|
*/
|
||||||
|
export async function validateIdentityAssertion(
|
||||||
|
token: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<IdentityAssertion | null> {
|
||||||
|
try {
|
||||||
|
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
|
||||||
|
if (payload.aud !== IDENTITY_ASSERTION_AUDIENCE || payload.sub !== undefined) return null
|
||||||
|
const platform = payload.platform
|
||||||
|
const platformId = payload.platform_id
|
||||||
|
if (typeof platform !== 'number' || !Number.isInteger(platform) || platform < 0) return null
|
||||||
|
if (typeof platformId !== 'string' || platformId === '') return null
|
||||||
|
return { platform, platformId }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||