Compare commits

..

20 Commits

Author SHA1 Message Date
Devin Zuczek ad2b84b358 pw/email recovery 2026-10-26 23:54:25 -04:00
devin 619b4c772c [playersettings] Drop Growth.YIR telemetry and MakerPenSessionCount on write (#145)
* [playersettings] drop Growth.YIR telemetry on write

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

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

* [playersettings] also drop MakerPenSessionCount

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

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

---------

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

* Add Pumple chaos Halloween minigame

---------

Co-authored-by: Mrmadman1 <fusionthemadman@gmail.com>
Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
2026-10-23 17:45:12 -04:00
dukedotdev 3da35a8692 [studio] avatar authoring, managed prefab builds (#140) 2026-10-23 15:25:31 -04:00
90 changed files with 9746 additions and 978 deletions
+46 -3
View File
@@ -77,6 +77,13 @@ RECFLARE_DOMAIN=rec.example.com
# Turning it on also needs the Turnstile keypair in the Secrets Store (see www). # Turning it on also needs the Turnstile keypair in the Secrets Store (see www).
# RECFLARE_PASSWORD_SIGNUP=off # RECFLARE_PASSWORD_SIGNUP=off
# The address the server's emails come from: password-recovery codes (`auth`) and
# email-change confirmations (`accounts`). Its domain has to be enabled for Cloudflare Email
# Sending once (`wrangler email sending enable <domain>`, see DEPLOYING.md) or no email can
# be sent — the player is told so, and nothing else is affected. Unset is no-reply@<RECFLARE_DOMAIN>; set this to send from another domain
# that IS enabled, or from a different mailbox on the same one.
# RECFLARE_EMAIL_FROM=no-reply@rec.example.com
# How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a # How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a
# comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means # comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means
# BOTH, so a ban also blocks accounts sharing a proven platform identity or an IP with a # BOTH, so a ban also blocks accounts sharing a proven platform identity or an IP with a
@@ -227,9 +234,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 +286,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 +311,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
+12
View File
@@ -312,6 +312,18 @@ inconsistency here without checking the client first.
cannot name the file (its decoder wants `UnityAsset`/`UnityAssetHash`), and the room loads cannot name the file (its decoder wants `UnityAsset`/`UnityAssetHash`), and the room loads
nothing. Every imported first-party room broke when the splice moved from the Studio-only nothing. Every imported first-party room broke when the splice moved from the Studio-only
table to `unity_asset`, with every response looking correct. table to `unity_asset`, with every response looking correct.
- "Forgot password" (`auth`: `POST /account/recoverpassword`) posts only `email`, and the client
reads the PascalCase envelope `{ Success, Error, error_id }` — `Error` and `error_id` null on
success — not the lowercase `{ success, error }` the password change answers in. The address is
all the server gets, so an email is UNIQUE across accounts (`idx_account_email_lower`, enforced
by `accounts` `POST /account/me/email`). The recovery finishes on the WEBSITE
(`/reset-password`, posting `auth` `POST /account/resetpassword`): the game has no screen for it.
- Setting the email (`accounts`: `POST /account/me/email`) STAGES the address and still answers
`{ success: true }` — the game reads nothing else — while `/account/me` keeps serving the old
one until the emailed codes come back through `…/email/confirm` (website only, `/confirm-email`).
Two steps: the CURRENT address approves first (so a stolen session can't move the recovery
address), then the NEW address confirms itself (so a typo is never stored); an account with no
email gets only the second, and may re-enter a different address at any time.
- Accessibility is sent as the `RoomAccessibility` enum NAME on - Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
+2 -1
View File
@@ -56,7 +56,8 @@ bun runx admin grant-moderator --username alice --remote
Sets the account's `hasStudio` flag, which `auth` stamps into the token as the Sets the account's `hasStudio` flag, which `auth` stamps into the token as the
`betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff `betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
normally manage this from the website (`/settings/recroomstudio`); this is the normally manage this from the website (`/settings/recroomstudio`); this is the
operator's way in. Takes effect on the player's next Studio sign-in or refresh. operator's way in. Takes effect on the player's next Studio sign-in or refresh. To open
Studio to a whole Discord role instead, name it in `RECFLARE_STUDIO_ROLLOUT` (`.env`).
```sh ```sh
bun runx admin grant-studio --username alice bun runx admin grant-studio --username alice
+30
View File
@@ -140,6 +140,36 @@ 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
```
**Enable Email Sending** for the domain, so the workers can send the two emails they have:
`auth`'s password-recovery code ("Forgot password" in the game sends a six-digit code to
the address on the account, with a link to `www.<domain>/reset-password`) and `accounts`'
email-change confirmation (changing the address on an account sends a code to the current
address to approve it and then one to the new address to confirm it, each with a link to
`www.<domain>/confirm-email`).
Both workers bind Cloudflare Email Sending (`send_email` in their `wrangler.jsonc`, nothing
to splice in), but Cloudflare only delivers from a domain that has been onboarded — once
per account:
```bash
wrangler email sending enable <domain>
```
Emails come from `no-reply@<domain>`; set `RECFLARE_EMAIL_FROM` in `.env` to send from a
different mailbox or domain (that one must be enabled instead). Without this step the
workers still deploy and everything else works; a recovery request or an email change is
answered with "the email could not be sent", and the failure is logged.
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
+3 -1
View File
@@ -20,7 +20,9 @@ worker; this worker binds it read/write.
| GET | `/accountprivacysettings/:id` | | An account's privacy settings | | GET | `/accountprivacysettings/:id` | | An account's privacy settings |
| PUT | `/account/me/displayname` | ✓ | Set display name | | PUT | `/account/me/displayname` | ✓ | Set display name |
| PUT | `/account/me/username` | ✓ | Change username (unique + change remaining) | | PUT | `/account/me/username` | ✓ | Change username (unique + change remaining) |
| POST | `/account/me/email` | ✓ | Set email | | POST | `/account/me/email` | ✓ | Change email: stages it; codes to old, then new |
| GET | `/account/me/email/pending` | ✓ | The email change awaiting a code, and which |
| POST | `/account/me/email/confirm` | ✓ | Spend a code; the new address's one applies it |
| POST | `/account/me/phone` | ✓ | Set phone number | | POST | `/account/me/phone` | ✓ | Set phone number |
| PUT | `/account/me/identityflags` | ✓ | Set identity flags bitmask | | PUT | `/account/me/identityflags` | ✓ | Set identity flags bitmask |
| PUT | `/account/me/personalpronouns` | ✓ | Set personal pronouns (posted as `pronounFlags`) | | PUT | `/account/me/personalpronouns` | ✓ | Set personal pronouns (posted as `pronounFlags`) |
+332 -7
View File
@@ -3,12 +3,20 @@ import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { import {
beginEmailCode,
cancelEmailCode,
completeEmailCode,
createAccount, createAccount,
DEFAULT_USERNAME_CHANGES, DEFAULT_USERNAME_CHANGES,
defaultAccount, defaultAccount,
EMAIL_CODE_TTL_SECONDS,
emailSender,
getAccount, getAccount,
getAccountByEmail,
getAccountByUsername, getAccountByUsername,
getAccountsByIds, getAccountsByIds,
pendingEmailCode,
SEARCH_LIMIT,
searchAccounts, searchAccounts,
updateAccount, updateAccount,
} from '@repo/domain' } from '@repo/domain'
@@ -24,6 +32,8 @@ import { validateAndGetAccountId } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a // The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies. // value — the enum has no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types' import { NotificationType } from '../../notify/src/notification-types'
import { buildEndpoints } from '../../ns/src/endpoints'
import { emailChangeEmail } from './email-change-email'
import { import {
AccountDto, AccountDto,
BannerImageRequest, BannerImageRequest,
@@ -33,6 +43,9 @@ import {
CreateAccountRequest, CreateAccountRequest,
CreateAccountResult, CreateAccountResult,
DisplayNameRequest, DisplayNameRequest,
EmailConfirmRequest,
EmailConfirmResponse,
EmailRefusal,
EmailRequest, EmailRequest,
EmojiRequest, EmojiRequest,
form, form,
@@ -40,6 +53,7 @@ import {
IdentityFlagsRequest, IdentityFlagsRequest,
json, json,
ParentalControl, ParentalControl,
PendingEmailResponse,
PhoneRequest, PhoneRequest,
PrivacySettings, PrivacySettings,
ProfileImageRequest, ProfileImageRequest,
@@ -240,6 +254,91 @@ async function pushAccountUpdate(c: Context<App>, account: Account): Promise<voi
/** The empty-body 401 every auth-gated route returns; reused across their specs. */ /** The empty-body 401 every auth-gated route returns; reused across their specs. */
const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' } const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer token (empty body)' }
/** What the email routes say when the address is already on another account. */
const EMAIL_TAKEN = 'That email address is already on another account.'
/** What `POST /account/me/email/confirm` says to any code that doesn't take. */
const EMAIL_CODE_WRONG =
'That code is wrong or has expired. Save the address again to be sent a new one.'
/** What the email routes say to a repeat within the cooldown. */
const EMAIL_TOO_SOON =
'A code was emailed less than two minutes ago. Enter that one, or try again in a minute.'
/** What the email routes say when the mail service refused the send. */
const EMAIL_NOT_SENT = 'The confirmation email could not be sent. Please try again later.'
/** The account's current address, or null when it has none (absent or empty). */
const currentEmail = (account: Account | null): string | null => account?.email?.trim() || null
/**
* Send one of the email-change codes. False when the mail service refused it, in which case
* the code is dropped (cooldown included) — nothing went out, so nothing to wait on, and the
* player can try again at once. The usual cause is the sender's domain not being enabled for
* Email Sending (DEPLOYING.md): an operator's problem, hence the log.
*/
async function sendEmailCode(
c: Context<App>,
input: {
purpose: 'email_change' | 'email_verify'
to: string
code: string
newEmail: string
currentEmail: string | null
}
): Promise<boolean> {
const id = (await authedId(c))!
const message = emailChangeEmail({
to: input.to,
from: emailSender(c.env),
domain: c.env.DOMAIN,
www: buildEndpoints(c.env.DOMAIN, c.env.SUBDOMAINS).WWW,
code: input.code,
newEmail: input.newEmail,
// Step 2's letter reads as "confirm this address" whether or not there is a current
// one: by then the current address has had its say.
currentEmail: input.purpose === 'email_change' ? input.currentEmail : null,
validMinutes: Math.round(EMAIL_CODE_TTL_SECONDS / 60),
})
try {
await c.env.EMAIL.send(message)
} catch (err) {
await cancelEmailCode(c.env.DB, input.purpose, id)
logger.error('could not send the email-change code', {
accountId: id,
purpose: input.purpose,
error: err instanceof Error ? err.message : String(err),
})
return false
}
logger.info('email-change code sent', { accountId: id, purpose: input.purpose })
return true
}
/** What `GET /account/me/email/pending` serves: the live step, if any. */
async function pendingEmailChange(
c: Context<App>,
id: number
): Promise<{ pending: string | null; sentTo: string | null }> {
const verify = await pendingEmailCode(c.env.DB, 'email_verify', id)
if (verify?.payload) return { pending: verify.payload, sentTo: verify.payload }
const change = await pendingEmailCode(c.env.DB, 'email_change', id)
if (change?.payload) {
const current = currentEmail(await getAccount(c.env.DB, id))
return { pending: change.payload, sentTo: current ?? change.payload }
}
return { pending: null, sentTo: null }
}
/**
* Whether a D1 error is the `idx_account_email_lower` UNIQUE index refusing a second holder
* of an address — two saves racing past the lookup. D1 surfaces SQLite's message verbatim.
*/
function isUniqueEmailViolation(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err)
return message.includes('UNIQUE constraint failed') && message.includes('email_lower')
}
/** Bearer-JWT security requirement, for the auth-gated routes. */ /** Bearer-JWT security requirement, for the auth-gated routes. */
const AUTHED = [{ bearerAuth: [] }] const AUTHED = [{ bearerAuth: [] }]
@@ -337,16 +436,61 @@ const app = new Hono<App>()
description: 'Username prefix; empty matches nothing meaningful', description: 'Username prefix; empty matches nothing meaningful',
schema: { type: 'string' }, schema: { type: 'string' },
}, },
{
name: 'take',
in: 'query',
required: false,
description: `How many to return (default and max ${SEARCH_LIMIT}); fewer reads fewer rows`,
schema: { type: 'integer' },
},
], ],
responses: { 200: json(AccountDto.array(), 'Matching public accounts') }, responses: { 200: json(AccountDto.array(), 'Matching public accounts') },
}), }),
async (c) => { async (c) => {
const name = c.req.query('name') ?? '' const name = c.req.query('name') ?? ''
const accounts = await searchAccounts(c.env.DB, name) // The seek stops at the LIMIT, so a caller that draws five (the site's header
// search) is billed five rows rather than the default twenty.
const take = Number.parseInt(c.req.query('take') ?? '', 10)
const limit = Number.isNaN(take) ? SEARCH_LIMIT : Math.max(1, Math.min(take, SEARCH_LIMIT))
const accounts = await searchAccounts(c.env.DB, name, limit)
return c.json(accounts.map(toAccountDto)) return c.json(accounts.map(toAccountDto))
} }
) )
// Exact lookup by username — what the site's `/user/<name>` profile pages and the staff
// `@username` fields resolve a player through. An indexed equality on `username_lower`;
// before this existed they went through the prefix search above and filtered the
// exact hit client-side, which cost a 20-row index walk per profile view. Same path
// as the official API's `GET /account/username/{username}`.
.get(
'/account/username/:name',
describeRoute({
tags: ['Lookup'],
summary: 'A single public account, by exact username',
description:
'Case-insensitive exact match; a leading `@` is ignored. 404 when no account has the name.',
parameters: [
{
name: 'name',
in: 'path',
required: true,
description: 'The username, exactly (case-insensitive)',
schema: { type: 'string' },
},
],
responses: {
200: json(AccountDto, 'The public account'),
404: { description: 'No account has that username' },
},
}),
async (c) => {
const name = c.req.param('name').trim().replace(/^@/, '')
const account = name === '' ? null : await getAccountByUsername(c.env.DB, name)
if (!account) return c.json({ error: `There's no player called @${name}.` }, 404)
return c.json(toAccountDto(account))
}
)
// ---- Bulk / single lookup ------------------------------------------------ // ---- 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)
@@ -585,18 +729,59 @@ const app = new Hono<App>()
} }
) )
// Set the player's email (persisted on the account row; surfaced by /account/me). // Change the player's email — STAGED, not applied. An address belongs to one account and is
// what password recovery goes to, so moving it is the one profile change a stolen session
// must not be able to make alone. Two steps, each a six-digit code (@repo/domain's
// email-code-db.ts: one attempt, half an hour, and a cooldown on resends):
//
// 1. `email_change` — to the CURRENT address, when the account has one: its owner approves
// giving it up. A stolen session can't pass this. Skipped for an account with no
// confirmed address, which has nobody to ask.
// 2. `email_verify` — to the NEW address: proves it is reachable before it becomes the
// recovery address, so a typo can never be stored. This is the step that applies the
// change. Issued as step 1 is spent, or at once when there was no step 1.
//
// `/account/me` keeps serving the old address until step 2 lands. A player with NO address
// who mistypes one just enters another: a different address always gets a fresh code (the
// cooldown only refuses a repeat to the SAME address), since nothing confirmed is at stake.
// With a confirmed address, every new attempt starts again at step 1, and step 1's cooldown
// applies whatever the new address is — it is the current inbox being written to.
//
// The game posts this and reads `{ success: true }`, as it always did; it has no screen for
// the code, so the emails' links finish the change on the website. A same-address save (a
// case change, or re-saving what is there) applies at once — nothing to approve.
//
// An address another account holds (case-insensitively) is refused with a sentence in the
// body, which the website shows, where the malformed-address refusal is an empty 400. Same
// status for both: a 400 is what the game client is known to cope with on this route. The
// lookup is what the player reads; the UNIQUE index on `email_lower` is the backstop for a
// race, re-checked when the change is applied.
.post( .post(
'/account/me/email', '/account/me/email',
describeRoute({ describeRoute({
tags: ['Profile'], tags: ['Profile'],
summary: 'Set email', summary: 'Change email (sends a confirmation code)',
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.', description: [
'STAGES the change. An account with a confirmed address is sent a six-digit code',
'THERE first (its owner approves the change); once that code is posted to',
'`POST /account/me/email/confirm`, a second code goes to the NEW address, and',
'posting that one applies the change. An account with no address gets only the',
'second step. Each email links to the website’s `/confirm-email` page.',
'`{ success: true }` means a code was sent. Re-saving the current address applies',
'at once. An address on another account (case-insensitive) is refused with',
'`{ success: false, error }`; so is a repeat within two minutes of the last code,',
'except that an account with no address may enter a DIFFERENT address at any time.',
'The malformed-address refusal has no body.',
].join(' '),
security: AUTHED, security: AUTHED,
responses: { responses: {
200: json(SuccessResponse, 'Updated'), 200: json(SuccessResponse, 'Code sent (or the same address re-saved)'),
400: { description: 'Not a syntactically valid address (empty body)' }, 400: json(
EmailRefusal,
'Not a syntactically valid address (empty body), or already on another account / asked again too soon'
),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
502: json(EmailRefusal, 'The confirmation email could not be sent'),
}, },
}), }),
validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))), validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
@@ -604,11 +789,151 @@ const app = new Hono<App>()
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const { email } = c.req.valid('form') const { email } = c.req.valid('form')
await updateAccount(c.env.DB, id, { email }) const taken = { success: false, error: EMAIL_TAKEN }
const holder = await getAccountByEmail(c.env.DB, email)
if (holder !== null && holder.accountId !== id) return c.json(taken, 400)
const current = currentEmail(await getAccount(c.env.DB, id))
if (current !== null && current.toLowerCase() === email.toLowerCase()) {
await updateAccount(c.env.DB, id, { email })
return c.json({ success: true })
}
let code: string | null
if (current === null) {
// Step 2 straight away. A different address than the one last staged is never
// a repeat, so it skips the cooldown — the typo case.
const staged = await pendingEmailCode(c.env.DB, 'email_verify', id)
code = await beginEmailCode(c.env.DB, 'email_verify', id, email, {
force: staged?.payload !== email,
})
} else {
// Step 1; a step 2 left over from an earlier attempt is voided, since the current
// address has not approved THIS one.
await cancelEmailCode(c.env.DB, 'email_verify', id)
code = await beginEmailCode(c.env.DB, 'email_change', id, email)
}
if (code === null) return c.json({ success: false, error: EMAIL_TOO_SOON }, 400)
const sent = await sendEmailCode(c, {
purpose: current === null ? 'email_verify' : 'email_change',
to: current ?? email,
code,
newEmail: email,
currentEmail: current,
})
if (!sent) return c.json({ success: false, error: EMAIL_NOT_SENT }, 502)
return c.json({ success: true }) return c.json({ success: true })
} }
) )
// The change awaiting a code, for the website to pick up where it left off after a reload
// (and right after signup, which saves the address and lands on the account page).
.get(
'/account/me/email/pending',
describeRoute({
tags: ['Profile'],
summary: 'The email change awaiting confirmation',
description: [
'The address a code is out for, and where the code went — the current address while',
'it is approving the change, the new one while it is confirming itself — or nulls when',
'no change is in flight (none staged, or the code lapsed or was spent by a wrong guess).',
].join(' '),
security: AUTHED,
responses: {
200: json(PendingEmailResponse, 'The pending change, or nulls'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await pendingEmailChange(c, id))
}
)
// Spend the pending code. The code is what approves the step; the session says whose. One
// guess: a wrong code voids the step (and the change), and the address has to be saved again.
// The refusal reads the same whether the code was wrong, lapsed or never issued. Which step
// is being confirmed follows from what is pending — only one ever is.
.post(
'/account/me/email/confirm',
describeRoute({
tags: ['Profile'],
summary: 'Confirm an email change step with the emailed code',
description: [
'Spends the code staged by `POST /account/me/email`. When it was the CURRENT address',
'approving the change, a second code is sent to the new address and `applied` is',
'false, with `pending`/`sentTo` saying so; when it was the new address confirming',
'itself, the change is applied and `applied` is true. The code works ONCE: a wrong one',
'voids the change. The refusal is the same sentence whether the code was wrong, has',
'lapsed (30 minutes) or was never issued. The address is re-checked against other',
'accounts as it lands.',
].join(' '),
security: AUTHED,
responses: {
200: json(EmailConfirmResponse, 'Step confirmed; `applied` says whether the change landed'),
400: json(
EmailRefusal,
'No code, a code that is wrong / lapsed / not issued, or an address another account took meanwhile'
),
401: UNAUTHORIZED_RESPONSE,
502: json(EmailRefusal, 'The second code’s email could not be sent'),
},
}),
validator('form', EmailConfirmRequest, (r, c) =>
r.success
? undefined
: c.json({ success: false, error: 'Enter the code from the email.' }, 400)
),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { code } = c.req.valid('form')
// Step 2, if it is the one out; else step 1.
const verifying = (await pendingEmailCode(c.env.DB, 'email_verify', id)) !== null
const purpose = verifying ? 'email_verify' : 'email_change'
const result = await completeEmailCode(c.env.DB, purpose, id, code)
if (result.outcome !== 'ok' || !result.payload) {
logger.info('email change refused', { accountId: id, purpose, outcome: result.outcome })
return c.json({ success: false, error: EMAIL_CODE_WRONG }, 400)
}
const email = result.payload
const taken = { success: false, error: EMAIL_TAKEN }
const holder = await getAccountByEmail(c.env.DB, email)
if (holder !== null && holder.accountId !== id) return c.json(taken, 400)
if (!verifying) {
// The current address has approved; now the new one proves itself. Forced past the
// cooldown: step 1 just gated this, and the previous verify code (if any) went out
// at least a step-1 cooldown ago.
const next = await beginEmailCode(c.env.DB, 'email_verify', id, email, { force: true })
const sent =
next !== null &&
(await sendEmailCode(c, {
purpose: 'email_verify',
to: email,
code: next,
newEmail: email,
currentEmail: currentEmail(await getAccount(c.env.DB, id)),
}))
if (!sent) return c.json({ success: false, error: EMAIL_NOT_SENT }, 502)
logger.info('email change approved by the current address', { accountId: id })
return c.json({ success: true, applied: false, pending: email, sentTo: email })
}
try {
await updateAccount(c.env.DB, id, { email })
} catch (err) {
if (isUniqueEmailViolation(err)) return c.json(taken, 400)
throw err
}
logger.info('email changed', { accountId: id })
return c.json({ success: true, applied: true, pending: null, sentTo: null })
}
)
// Set the player's phone (persisted on the account row). // Set the player's phone (persisted on the account row).
.post( .post(
'/account/me/phone', '/account/me/phone',
+18
View File
@@ -15,6 +15,24 @@ export type Env = SharedHonoEnv & {
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to // SignalR notifications hub (DO owned by the `notify` worker). Bound here to
// push AccountUpdate/SelfAccountUpdate notifications on profile mutations. // push AccountUpdate/SelfAccountUpdate notifications on profile mutations.
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub> RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
/**
* Cloudflare Email Sending (`send_email` in wrangler.jsonc). The one thing this worker
* mails is the code that confirms an email change (`POST /account/me/email`), from
* `EMAIL_FROM` below (read through `emailSender`); `auth` binds the same for password
* recovery. The sender's domain has to be onboarded onto Email Sending (see DEPLOYING.md)
* or every send fails — reported to the player as the email not going out. Locally and
* in tests the binding logs the message instead of sending it.
*/
EMAIL: SendEmail
/** See `auth`: the address emails come from; unset means `no-reply@<DOMAIN>`. */
EMAIL_FROM?: string
/**
* Base domain, e.g. `recflare.net`, injected for every worker at deploy time
* (`--var DOMAIN`). The confirmation email links to `www.<domain>/confirm-email`.
*/
DOMAIN: string
/** The same `SUBDOMAINS` JSON `ns` applies: a `www` override moves that link's host. */
SUBDOMAINS: string
} }
/** Variables can be extended */ /** Variables can be extended */
+86
View File
@@ -0,0 +1,86 @@
/**
* The email that confirms a change of address, as the message builder Cloudflare Email
* Sending takes (`env.EMAIL.send(...)`). A pure function of its inputs, so the message can
* be read in a test without a mail service in the way.
*
* Two different letters, one per step of the change (see `/account/me/email`):
*
* - To the CURRENT address, when the account has one: "someone signed in to your account
* wants to change its email to X". The point of confirming at all — a stolen session
* can't move the address, and with it the password recovery, somewhere else.
* - To the NEW address, once the current one has approved (or at once, when there is none):
* "confirm this is yours". Proves the address being added is reachable before it is
* stored, so a typo never becomes the recovery address.
*
* Either way the code is printed and prefilled into a link to the website's confirm page,
* which needs the account's own session to post it: the code approves the change, the
* session says whose.
*/
import { escapeHtml } from '@repo/domain'
/** The website's confirm page, which reads `code` off the query and wants a session. */
export const CONFIRM_EMAIL_PATH = '/confirm-email'
export interface EmailChangeEmailInput {
/** Where the email goes: the current address when there is one, else the new one. */
to: string
/** The address it comes from; must be on a domain enabled for Email Sending. */
from: string
/** The server's base domain, named as the sender and in the copy. */
domain: string
/** The website's origin (`https://www.<domain>`), which the confirm link is built on. */
www: string
/** The six-digit code, in plaintext — the only place it is written down. */
code: string
/** The address the account would change to. */
newEmail: string
/** The address this letter asks to give up, or null for the letter to the new address. */
currentEmail: string | null
/** How long the code is good for, in minutes, for the copy. */
validMinutes: number
}
/** The confirm link: the website's page with the code already filled in. */
export function confirmEmailUrl(www: string, code: string): string {
const url = new URL(CONFIRM_EMAIL_PATH, www)
url.searchParams.set('code', code)
return url.toString()
}
export function emailChangeEmail(input: EmailChangeEmailInput) {
const link = confirmEmailUrl(input.www, input.code)
const changing = input.currentEmail !== null
const subject = changing
? `Confirm the new email address on your ${input.domain} account`
: `Confirm your ${input.domain} email address`
const opening = changing
? `Someone signed in to your ${input.domain} account asked to change its email address from this one to ${input.newEmail}.`
: `This address is being set on a ${input.domain} account. Confirm it to finish.`
const closing = changing
? `The code works once and for ${input.validMinutes} minutes. If you didn't ask for this, don't enter it — nothing changes without it, and this address stays on the account. Someone else may have your password: sign in and change it, or use "Forgot password".`
: `The code works once and for ${input.validMinutes} minutes. If you didn't set this address on an account, ignore this email — nothing changes without the code.`
const text = [
opening,
'',
`Your code is: ${input.code}`,
'',
'Confirm it here (you will need to sign in):',
link,
'',
closing,
].join('\n')
const html = [
`<p>${escapeHtml(opening)}</p>`,
`<p>Your code is: <strong style="font-size:1.5em;letter-spacing:0.15em">${input.code}</strong></p>`,
`<p><a href="${escapeHtml(link)}">Confirm it here</a> (you will need to sign in).</p>`,
`<p>${escapeHtml(closing)}</p>`,
].join('\n')
return {
to: input.to,
from: { email: input.from, name: input.domain },
subject,
text,
html,
}
}
+32
View File
@@ -231,6 +231,38 @@ export const EmailRequest = z.object({
.describe('A syntactically valid address (RFC 5321/5322, so at most 254); otherwise 400'), .describe('A syntactically valid address (RFC 5321/5322, so at most 254); otherwise 400'),
}) })
/** `POST /account/me/email/confirm` form body. */
export const EmailConfirmRequest = z.object({
code: z
.string()
.trim()
.min(1, 'Enter the code from the email.')
.describe('The six digits from the email'),
})
/** `POST /account/me/email/confirm` response body. */
export const EmailConfirmResponse = z.object({
success: z.literal(true),
applied: z.boolean().describe('Whether the change has landed, or a second code is now out'),
pending: z.string().nullable().describe('The new address, while a second code is out'),
sentTo: z.string().nullable().describe('Where that second code went (the new address)'),
})
/** `GET /account/me/email/pending` response body. */
export const PendingEmailResponse = z.object({
pending: z
.string()
.nullable()
.describe('The address awaiting confirmation, or null when no change is in flight'),
sentTo: z
.string()
.nullable()
.describe('Where the code went: the current address, or the new one when there was none'),
})
/** The `{ success: false, error }` the email routes refuse with. */
export const EmailRefusal = z.object({ success: z.literal(false), error: z.string() })
export const PhoneRequest = z.object({ export const PhoneRequest = z.object({
// No shape rule on purpose: the client sends E.164 (`+15552223333`), which the name // No shape rule on purpose: the client sends E.164 (`+15552223333`), which the name
// rule above would reject outright by eating the leading `+`. // rule above would reject outright by eating the leading `+`.
+267 -12
View File
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../accounts.app' import '../../accounts.app'
import { SCHEMA_DDL } from '@repo/domain' import { EMAIL_CODE_SCHEMA_DDL, hashPassword, SCHEMA_DDL } from '@repo/domain'
import { WHITELISTED_EMOJIS } from '../../whitelisted-emojis' import { WHITELISTED_EMOJIS } from '../../whitelisted-emojis'
@@ -22,6 +22,8 @@ beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves. // Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key') await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The codes an email change is confirmed with (owned by `auth`'s migrations).
for (const stmt of EMAIL_CODE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
const insert = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') const insert = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
await env.DB.batch([ await env.DB.batch([
insert.bind(JSON.stringify({ accountId: 0, username: 'RecRoom', displayName: 'Rec Room' })), insert.bind(JSON.stringify({ accountId: 0, username: 'RecRoom', displayName: 'Rec Room' })),
@@ -153,6 +155,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: '' })
@@ -650,18 +670,249 @@ describe('auth-gated endpoints', () => {
expect(res.status).toBe(400) expect(res.status).toBe(400)
}) })
test('POST /account/me/email persists the email, surfaced by /account/me', async () => { /** POST an address for an account, as the website and the game do. */
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, { const postEmail = async (id: string, email: string) =>
...form({ email: 'ners@recroom.com' }), exports.default.fetch(`${ORIGIN}/account/me/email`, {
...form({ email }),
method: 'POST', method: 'POST',
headers: { ...(await bearer('888')), 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { ...(await bearer(id)), 'Content-Type': 'application/x-www-form-urlencoded' },
}) })
/** POST a code to confirm the account's staged change. */
const confirmEmail = async (id: string, code: string) =>
exports.default.fetch(`${ORIGIN}/account/me/email/confirm`, {
...form({ code }),
method: 'POST',
headers: { ...(await bearer(id)), 'Content-Type': 'application/x-www-form-urlencoded' },
})
const pendingEmail = async (id: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/account/me/email/pending`, {
headers: await bearer(id),
})
).json()) as { pending: string | null; sentTo: string | null }
const emailOf = async (id: string) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer(id) })
).json()) as { email: string }
).email
type Purpose = 'email_change' | 'email_verify'
/** The staged step's row. Only the code's hash is stored; `plantCode` puts a known one in. */
const stagedRow = (id: string, purpose: Purpose) =>
env.DB.prepare(
'SELECT code_hash AS codeHash, payload, created_at AS createdAt FROM email_code WHERE account_id = ?1 AND purpose = ?2'
)
.bind(Number(id), purpose)
.first<{ codeHash: string | null; payload: string; createdAt: number }>()
const plantCode = async (id: string, purpose: Purpose, code: string) => {
await env.DB.prepare(
'UPDATE email_code SET code_hash = ?2 WHERE account_id = ?1 AND purpose = ?3'
)
.bind(Number(id), await hashPassword(code), purpose)
.run()
}
/** Set an address the way a player does, through every step the account needs. */
const setEmail = async (id: string, email: string) => {
const staged = await postEmail(id, email)
if (staged.status !== 200) return staged
if ((await pendingEmail(id)).sentTo !== email) {
await plantCode(id, 'email_change', '100000')
const approved = await confirmEmail(id, '100000')
if (approved.status !== 200) return approved
}
await plantCode(id, 'email_verify', '100001')
return confirmEmail(id, '100001')
}
test('POST /account/me/email on an account with no address: one code, to the new address', async () => {
const res = await postEmail('888', 'ners@recroom.com')
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true }) expect(await res.json()).toEqual({ success: true })
// The stored email is now returned by the self account (was a null stub). // Staged, not applied: the self account still has no email, and the pending read says
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('888') }) // where the code went — the NEW address, since there is nobody else to ask.
expect(((await me.json()) as { email: string }).email).toBe('ners@recroom.com') expect(await emailOf('888')).toBe('')
expect(await pendingEmail('888')).toEqual({
pending: 'ners@recroom.com',
sentTo: 'ners@recroom.com',
})
const row = await stagedRow('888', 'email_verify')
expect(row?.payload).toBe('ners@recroom.com')
expect(row?.codeHash).toMatch(/^[^:]+:[^:]+$/)
expect(await stagedRow('888', 'email_change')).toBeNull()
await plantCode('888', 'email_verify', '123456')
const ok = await confirmEmail('888', '123456')
expect(ok.status).toBe(200)
expect(await ok.json()).toEqual({ success: true, applied: true, pending: null, sentTo: null })
expect(await emailOf('888')).toBe('ners@recroom.com')
expect(await pendingEmail('888')).toEqual({ pending: null, sentTo: null })
// Spent: the same code again is refused.
expect((await confirmEmail('888', '123456')).status).toBe(400)
})
test('with no address on file, a mistyped address can be replaced at once; the same one waits', async () => {
await postEmail('892', 'typo@exmaple.com')
const first = await stagedRow('892', 'email_verify')
// A DIFFERENT address right away: a fresh code, to it.
expect((await postEmail('892', 'right@example.com')).status).toBe(200)
const second = await stagedRow('892', 'email_verify')
expect(second?.payload).toBe('right@example.com')
expect(second?.codeHash).not.toBe(first?.codeHash)
expect((await pendingEmail('892')).sentTo).toBe('right@example.com')
// The SAME address again within the cooldown is a repeat, and refused.
const repeat = await postEmail('892', 'right@example.com')
expect(repeat.status).toBe(400)
expect(((await repeat.json()) as { error: string }).error).toMatch(/less than two minutes/)
expect(await stagedRow('892', 'email_verify')).toEqual(second)
})
test('with an address on file, the current address approves and then the new one confirms', async () => {
expect((await setEmail('889', 'first@example.com')).status).toBe(200)
expect(await emailOf('889')).toBe('first@example.com')
// Step 1: the code goes to the address being given up, not the new one.
expect((await postEmail('889', 'second@example.com')).status).toBe(200)
expect(await pendingEmail('889')).toEqual({
pending: 'second@example.com',
sentTo: 'first@example.com',
})
expect(await stagedRow('889', 'email_verify')).toBeNull()
expect(await emailOf('889')).toBe('first@example.com')
// Approving it applies nothing yet: step 2's code goes to the new address.
await plantCode('889', 'email_change', '111111')
const approved = await confirmEmail('889', '111111')
expect(approved.status).toBe(200)
expect(await approved.json()).toEqual({
success: true,
applied: false,
pending: 'second@example.com',
sentTo: 'second@example.com',
})
expect(await stagedRow('889', 'email_change')).toBeNull()
expect(await pendingEmail('889')).toEqual({
pending: 'second@example.com',
sentTo: 'second@example.com',
})
expect(await emailOf('889')).toBe('first@example.com')
// Step 2 lands it.
await plantCode('889', 'email_verify', '222222')
const done = await confirmEmail('889', '222222')
expect(await done.json()).toEqual({ success: true, applied: true, pending: null, sentTo: null })
expect(await emailOf('889')).toBe('second@example.com')
})
test('a wrong code at either step voids the change; a new attempt starts again at step 1', async () => {
expect((await setEmail('893', 'keep@example.com')).status).toBe(200)
// Wrong at step 1: burned, and the right code no longer works.
await postEmail('893', 'next@example.com')
await plantCode('893', 'email_change', '333333')
const wrong = await confirmEmail('893', '999999')
expect(wrong.status).toBe(400)
expect(await wrong.json()).toEqual({
success: false,
error: 'That code is wrong or has expired. Save the address again to be sent a new one.',
})
expect((await stagedRow('893', 'email_change'))?.codeHash).toBeNull()
expect(await pendingEmail('893')).toEqual({ pending: null, sentTo: null })
expect((await confirmEmail('893', '333333')).status).toBe(400)
// Past the cooldown, try again: step 1 again, then wrong at step 2.
await env.DB.prepare(
"UPDATE email_code SET created_at = created_at - 600 WHERE account_id = 893 AND purpose = 'email_change'"
).run()
expect((await postEmail('893', 'next@example.com')).status).toBe(200)
await plantCode('893', 'email_change', '444444')
expect((await confirmEmail('893', '444444')).status).toBe(200)
await plantCode('893', 'email_verify', '555555')
expect((await confirmEmail('893', '000000')).status).toBe(400)
expect((await stagedRow('893', 'email_verify'))?.codeHash).toBeNull()
expect((await confirmEmail('893', '555555')).status).toBe(400)
expect(await emailOf('893')).toBe('keep@example.com')
// A fresh attempt (cooldown passed) starts at step 1 again, whatever the address: the
// current address has approved nothing yet.
await env.DB.prepare(
"UPDATE email_code SET created_at = created_at - 600 WHERE account_id = 893 AND purpose = 'email_change'"
).run()
expect((await postEmail('893', 'other@example.com')).status).toBe(200)
expect((await pendingEmail('893')).sentTo).toBe('keep@example.com')
expect(await stagedRow('893', 'email_verify')).toBeNull()
})
test('with an address on file, a second attempt within the cooldown is refused whatever the address, and re-saving the same address applies at once', async () => {
expect((await setEmail('890', 'cool@example.com')).status).toBe(200)
await postEmail('890', 'cooler@example.com')
const first = await stagedRow('890', 'email_change')
// A different new address is still the current inbox being written to: refused.
const again = await postEmail('890', 'coolest@example.com')
expect(again.status).toBe(400)
expect(((await again.json()) as { error: string }).error).toMatch(/less than two minutes/)
expect(await stagedRow('890', 'email_change')).toEqual(first)
// Re-saving the current address in another case: no code, applied directly.
expect((await postEmail('890', 'COOL@example.com')).status).toBe(200)
expect(await emailOf('890')).toBe('COOL@example.com')
})
test('POST /account/me/email/confirm refuses an empty code and a code with nothing staged', async () => {
const empty = await confirmEmail('891', '')
expect(empty.status).toBe(400)
const none = await confirmEmail('891', '123456')
expect(none.status).toBe(400)
expect(await none.json()).toEqual({
success: false,
error: 'That code is wrong or has expired. Save the address again to be sent a new one.',
})
})
test('the email routes 401 without a token', async () => {
const pending = await exports.default.fetch(`${ORIGIN}/account/me/email/pending`)
expect(pending.status).toBe(401)
// A well-formed body, so the refusal is for the missing token and not the form.
const confirm = await exports.default.fetch(`${ORIGIN}/account/me/email/confirm`, {
...form({ code: '123456' }),
method: 'POST',
})
expect(confirm.status).toBe(401)
})
test('POST /account/me/email refuses an address another account holds, case-insensitively', async () => {
expect((await setEmail('886', 'shared@example.com')).status).toBe(200)
// Another account, same address in different case: refused with a sentence the
// website shows — a 400 like the malformed-address refusal, but with a body.
const taken = await postEmail('887', 'Shared@Example.com')
expect(taken.status).toBe(400)
expect(await taken.json()).toEqual({
success: false,
error: 'That email address is already on another account.',
})
expect(await emailOf('887')).toBe('')
// The holder may save it again (a no-op), and may change case — no code either way.
expect((await postEmail('886', 'shared@example.com')).status).toBe(200)
expect((await postEmail('886', 'SHARED@example.com')).status).toBe(200)
expect(await emailOf('886')).toBe('SHARED@example.com')
// A change that was staged while the address was free is refused as it lands, if
// another account took the address meanwhile.
expect((await postEmail('887', 'contested@example.com')).status).toBe(200)
expect((await setEmail('886', 'contested@example.com')).status).toBe(200)
await plantCode('887', 'email_verify', '100002')
const late = await confirmEmail('887', '100002')
expect(late.status).toBe(400)
expect(((await late.json()) as { error: string }).error).toMatch(/already on another account/)
// Once the holder moves on, the address is free.
expect((await setEmail('886', 'moved@example.com')).status).toBe(200)
expect((await setEmail('887', 'shared@example.com')).status).toBe(200)
}) })
test('GET /openapi.json documents every route', async () => { test('GET /openapi.json documents every route', async () => {
@@ -688,7 +939,9 @@ describe('auth-gated endpoints', () => {
'GET /', 'GET /',
'GET /account/bulk', 'GET /account/bulk',
'GET /account/me', 'GET /account/me',
'GET /account/me/email/pending',
'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}',
@@ -697,6 +950,7 @@ describe('auth-gated endpoints', () => {
'POST /account/bulk', 'POST /account/bulk',
'POST /account/create', 'POST /account/create',
'POST /account/me/email', 'POST /account/me/email',
'POST /account/me/email/confirm',
'POST /account/me/phone', 'POST /account/me/phone',
'PUT /account/me/bannerimage', 'PUT /account/me/bannerimage',
'PUT /account/me/bio', 'PUT /account/me/bio',
@@ -852,16 +1106,17 @@ describe('name, email and bio validation', () => {
} }
// `someone@localhost` is in the ACCEPTED list on purpose: it's valid per the RFC, // `someone@localhost` is in the ACCEPTED list on purpose: it's valid per the RFC,
// and an undeliverable address costs nothing here. // and an undeliverable address costs nothing here. One account per address: a second
for (const email of [ // save on the same account within two minutes is refused for the cooldown, not the syntax.
for (const [i, email] of [
'someone@example.com', 'someone@example.com',
'first.last+tag@mail.example.co.uk', 'first.last+tag@mail.example.co.uk',
'someone@localhost', 'someone@localhost',
]) { ].entries()) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, { const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
...form({ email }), ...form({ email }),
method: 'POST', method: 'POST',
headers, headers: await authed(`881${i}`),
}) })
expect(res.status, email).toBe(200) expect(res.status, email).toBe(200)
} }
+953 -242
View File
@@ -1,5 +1,5 @@
/* eslint-disable */ /* eslint-disable */
// Runtime types generated with workerd@1.20260625.1 2025-09-20 nodejs_compat // Runtime types generated with workerd@1.20260831.1 2026-06-16 nodejs_compat
// Begin runtime types // Begin runtime types
/*! ***************************************************************************** /*! *****************************************************************************
Copyright (c) Cloudflare. All rights reserved. Copyright (c) Cloudflare. All rights reserved.
@@ -19,26 +19,26 @@ and limitations under the License.
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
declare var onmessage: never; declare var onmessage: never;
/** /**
* The **`DOMException`** interface represents an abnormal event (called an **exception**) that occurs as a result of calling a method or accessing a property of a web API. * The **`DOMException`** interface represents an abnormal event (called an exception) that occurs as a result of calling a method or accessing a property of a web API. This is how error conditions are described in web APIs.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException)
*/ */
declare class DOMException extends Error { declare class DOMException extends Error {
constructor(message?: string, name?: string); constructor(message?: string, name?: string);
/** /**
* The **`message`** read-only property of the a message or description associated with the given error name. * The **`message`** read-only property of the DOMException interface returns a string representing a message or description associated with the given error name.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message)
*/ */
readonly message: string; readonly message: string;
/** /**
* The **`name`** read-only property of the one of the strings associated with an error name. * The **`name`** read-only property of the DOMException interface returns a string that contains one of the strings associated with an error name.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name)
*/ */
readonly name: string; readonly name: string;
/** /**
* The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or `0` if none match. * The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or 0 if none match.
* @deprecated * @deprecated
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)
@@ -96,61 +96,61 @@ interface Console {
*/ */
clear(): void; clear(): void;
/** /**
* The **`console.count()`** static method logs the number of times that this particular call to `count()` has been called. * The **`console.count()`** static method logs the number of times that this particular call to count() has been called.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static)
*/ */
count(label?: string): void; count(label?: string): void;
/** /**
* The **`console.countReset()`** static method resets counter used with console/count_static. * The **`console.countReset()`** static method resets counter used with console.count().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static)
*/ */
countReset(label?: string): void; countReset(label?: string): void;
/** /**
* The **`console.debug()`** static method outputs a message to the console at the 'debug' log level. * The **`console.debug()`** static method outputs a message to the console at the "debug" log level. The message is only displayed to the user if the console is configured to display debug output. In most cases, the log level is configured within the console UI. This log level might correspond to the Debug or Verbose log level.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static)
*/ */
debug(...data: any[]): void; debug(...data: any[]): void;
/** /**
* The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object. * The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object. In browser consoles, the output is presented as a hierarchical listing with disclosure triangles that let you see the contents of child objects.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static)
*/ */
dir(item?: any, options?: any): void; dir(item?: any, options?: any): void;
/** /**
* The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element. * The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element. If it is not possible to display as an element the JavaScript Object view is shown instead. The output is presented as a hierarchical listing of expandable nodes that let you see the contents of child nodes.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static)
*/ */
dirxml(...data: any[]): void; dirxml(...data: any[]): void;
/** /**
* The **`console.error()`** static method outputs a message to the console at the 'error' log level. * The **`console.error()`** static method outputs a message to the console at the "error" log level. The message is only displayed to the user if the console is configured to display error output. In most cases, the log level is configured within the console UI. The message may be formatted as an error, with red colors and call stack information.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static)
*/ */
error(...data: any[]): void; error(...data: any[]): void;
/** /**
* The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console/groupEnd_static is called. * The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console.groupEnd() is called.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static)
*/ */
group(...data: any[]): void; group(...data: any[]): void;
/** /**
* The **`console.groupCollapsed()`** static method creates a new inline group in the console. * The **`console.groupCollapsed()`** static method creates a new inline group in the console. Unlike console.group(), however, the new group is created collapsed. The user will need to use the disclosure button next to it to expand it, revealing the entries created in the group.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static)
*/ */
groupCollapsed(...data: any[]): void; groupCollapsed(...data: any[]): void;
/** /**
* The **`console.groupEnd()`** static method exits the current inline group in the console. * The **`console.groupEnd()`** static method exits the current inline group in the console. See Using groups in the console in the console documentation for details and examples.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static)
*/ */
groupEnd(): void; groupEnd(): void;
/** /**
* The **`console.info()`** static method outputs a message to the console at the 'info' log level. * The **`console.info()`** static method outputs a message to the console at the "info" log level. The message is only displayed to the user if the console is configured to display info output. In most cases, the log level is configured within the console UI. The message may receive special formatting, such as a small "i" icon next to it.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static)
*/ */
@@ -168,23 +168,24 @@ interface Console {
*/ */
table(tabularData?: any, properties?: string[]): void; table(tabularData?: any, properties?: string[]): void;
/** /**
* The **`console.time()`** static method starts a timer you can use to track how long an operation takes. * The **`console.time()`** static method starts a timer you can use to track how long an operation takes. You give each timer a unique name, and may have up to 10,000 timers running on a given page. When you call console.timeEnd() with the same name, the browser will output the time, in milliseconds, that elapsed since the timer was started.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static)
*/ */
time(label?: string): void; time(label?: string): void;
/** /**
* The **`console.timeEnd()`** static method stops a timer that was previously started by calling console/time_static. * The **`console.timeEnd()`** static method stops a timer that was previously started by calling console.time().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static)
*/ */
timeEnd(label?: string): void; timeEnd(label?: string): void;
/** /**
* The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console/time_static. * The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console.time().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static)
*/ */
timeLog(label?: string, ...data: any[]): void; timeLog(label?: string, ...data: any[]): void;
/* The **`console.timeStamp()`** static method adds a single marker to the browser's Performance tool (Firefox bug 1387528, Chrome). This lets you correlate a point in your code with the other events recorded in the timeline, such as layout and paint events. */
timeStamp(label?: string): void; timeStamp(label?: string): void;
/** /**
* The **`console.trace()`** static method outputs a stack trace to the console. * The **`console.trace()`** static method outputs a stack trace to the console.
@@ -193,7 +194,7 @@ interface Console {
*/ */
trace(...data: any[]): void; trace(...data: any[]): void;
/** /**
* The **`console.warn()`** static method outputs a warning message to the console at the 'warning' log level. * The **`console.warn()`** static method outputs a warning message to the console at the "warning" log level. The message is only displayed to the user if the console is configured to display warning output. In most cases, the log level is configured within the console UI. The message may receive special formatting, such as yellow colors and a warning icon.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static)
*/ */
@@ -359,7 +360,7 @@ interface ServiceWorkerGlobalScope extends WorkerGlobalScope {
declare function addEventListener<Type extends keyof WorkerGlobalScopeEventMap>(type: Type, handler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>, options?: EventTargetAddEventListenerOptions | boolean): void; declare function addEventListener<Type extends keyof WorkerGlobalScopeEventMap>(type: Type, handler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>, options?: EventTargetAddEventListenerOptions | boolean): void;
declare function removeEventListener<Type extends keyof WorkerGlobalScopeEventMap>(type: Type, handler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>, options?: EventTargetEventListenerOptions | boolean): void; declare function removeEventListener<Type extends keyof WorkerGlobalScopeEventMap>(type: Type, handler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>, options?: EventTargetEventListenerOptions | boolean): void;
/** /**
* The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. The normal event processing rules (including the capturing and optional bubbling phase) also apply to events dispatched manually with dispatchEvent().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
*/ */
@@ -404,6 +405,7 @@ declare const crypto: Crypto;
* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
*/ */
declare const caches: CacheStorage; declare const caches: CacheStorage;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/scheduler) */
declare const scheduler: Scheduler; declare const scheduler: Scheduler;
/** /**
* The Workers runtime supports a subset of the Performance API, used to measure timing and performance, * The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
@@ -420,10 +422,12 @@ interface TestController {
interface ExecutionContext<Props = unknown> { interface ExecutionContext<Props = unknown> {
waitUntil(promise: Promise<any>): void; waitUntil(promise: Promise<any>): void;
passThroughOnException(): void; passThroughOnException(): void;
readonly exports: Cloudflare.Exports;
readonly props: Props; readonly props: Props;
cache?: CacheContext; cache?: CacheContext;
readonly access?: CloudflareAccessContext; readonly access?: CloudflareAccessContext;
tracing: Tracing; tracing: Tracing;
abort(reason?: any): void;
} }
type ExportedHandlerFetchHandler<Env = unknown, CfHostMetadata = unknown, Props = unknown> = (request: Request<CfHostMetadata, IncomingRequestCfProperties<CfHostMetadata>>, env: Env, ctx: ExecutionContext<Props>) => Response | Promise<Response>; type ExportedHandlerFetchHandler<Env = unknown, CfHostMetadata = unknown, Props = unknown> = (request: Request<CfHostMetadata, IncomingRequestCfProperties<CfHostMetadata>>, env: Env, ctx: ExecutionContext<Props>) => Response | Promise<Response>;
type ExportedHandlerConnectHandler<Env = unknown, Props = unknown> = (socket: Socket, env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>; type ExportedHandlerConnectHandler<Env = unknown, Props = unknown> = (socket: Socket, env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>;
@@ -512,7 +516,7 @@ declare abstract class DurableObjectNamespace<T extends Rpc.DurableObjectBranded
getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub<T>; getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub<T>;
jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace<T>; jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace<T>;
} }
type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high"; type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us";
interface DurableObjectNamespaceNewUniqueIdOptions { interface DurableObjectNamespaceNewUniqueIdOptions {
jurisdiction?: DurableObjectJurisdiction; jurisdiction?: DurableObjectJurisdiction;
} }
@@ -526,6 +530,7 @@ interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = u
} }
interface DurableObjectState<Props = unknown> { interface DurableObjectState<Props = unknown> {
waitUntil(promise: Promise<any>): void; waitUntil(promise: Promise<any>): void;
readonly exports: Cloudflare.Exports;
readonly props: Props; readonly props: Props;
readonly id: DurableObjectId; readonly id: DurableObjectId;
readonly storage: DurableObjectStorage; readonly storage: DurableObjectStorage;
@@ -540,7 +545,7 @@ interface DurableObjectState<Props = unknown> {
setHibernatableWebSocketEventTimeout(timeoutMs?: number): void; setHibernatableWebSocketEventTimeout(timeoutMs?: number): void;
getHibernatableWebSocketEventTimeout(): number | null; getHibernatableWebSocketEventTimeout(): number | null;
getTags(ws: WebSocket): string[]; getTags(ws: WebSocket): string[];
abort(reason?: string): void; abort(reason?: string, options?: DurableObjectAbortOptions): void;
} }
interface DurableObjectTransaction { interface DurableObjectTransaction {
get<T = unknown>(key: string, options?: DurableObjectGetOptions): Promise<T | undefined>; get<T = unknown>(key: string, options?: DurableObjectGetOptions): Promise<T | undefined>;
@@ -576,6 +581,9 @@ interface DurableObjectStorage {
getBookmarkForTime(timestamp: number | Date): Promise<string>; getBookmarkForTime(timestamp: number | Date): Promise<string>;
onNextSessionRestoreBookmark(bookmark: string): Promise<string>; onNextSessionRestoreBookmark(bookmark: string): Promise<string>;
} }
interface DurableObjectAbortOptions {
retryAlarm?: boolean;
}
interface DurableObjectListOptions { interface DurableObjectListOptions {
start?: string; start?: string;
startAfter?: string; startAfter?: string;
@@ -626,26 +634,26 @@ interface AnalyticsEngineDataPoint {
blobs?: ((ArrayBuffer | string) | null)[]; blobs?: ((ArrayBuffer | string) | null)[];
} }
/** /**
* The **`Event`** interface represents an event which takes place on an `EventTarget`. * The **`Event`** interface represents an event which takes place on an EventTarget.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event)
*/ */
declare class Event { declare class Event {
constructor(type: string, init?: EventInit); constructor(type: string, init?: EventInit);
/** /**
* The **`type`** read-only property of the Event interface returns a string containing the event's type. * The **`type`** read-only property of the Event interface returns a string containing the event's type. It is set when the event is constructed and is the name commonly used to refer to the specific event, such as click, load, or error.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type)
*/ */
get type(): string; get type(): string;
/** /**
* The **`eventPhase`** read-only property of the being evaluated. * The **`eventPhase`** read-only property of the Event interface indicates which phase of the event flow is currently being evaluated.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase)
*/ */
get eventPhase(): number; get eventPhase(): number;
/** /**
* The read-only **`composed`** property of the or not the event will propagate across the shadow DOM boundary into the standard DOM. * The read-only **`composed`** property of the Event interface returns a boolean value which indicates whether or not the event will propagate across the shadow DOM boundary into the standard DOM.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed)
*/ */
@@ -682,13 +690,13 @@ declare class Event {
*/ */
get currentTarget(): EventTarget | undefined; get currentTarget(): EventTarget | undefined;
/** /**
* The read-only **`target`** property of the dispatched. * The read-only **`target`** property of the Event interface is a reference to the object onto which the event was dispatched. It is different from Event.currentTarget when the event handler is called during the bubbling or capturing phase of the event.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target)
*/ */
get target(): EventTarget | undefined; get target(): EventTarget | undefined;
/** /**
* The deprecated **`Event.srcElement`** is an alias for the Event.target property. * The deprecated **`Event.srcElement`** is an alias for the Event.target property. Use Event.target instead.
* @deprecated * @deprecated
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement)
@@ -701,45 +709,45 @@ declare class Event {
*/ */
get timeStamp(): number; get timeStamp(): number;
/** /**
* The **`isTrusted`** read-only property of the when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and `false` when the event was dispatched via The only exception is the `click` event, which initializes the `isTrusted` property to `false` in user agents. * The **`isTrusted`** read-only property of the Event interface is a boolean value that is true when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and false when the event was dispatched via EventTarget.dispatchEvent(). The only exception is the click event, which initializes the isTrusted property to false in user agents.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted)
*/ */
get isTrusted(): boolean; get isTrusted(): boolean;
/** /**
* The **`cancelBubble`** property of the Event interface is deprecated. * The **`cancelBubble`** property of the Event interface is deprecated. Use Event.stopPropagation() instead. Setting its value to true before returning from an event handler prevents propagation of the event. In later implementations, setting this to false does nothing. See Browser compatibility for details.
* @deprecated * @deprecated
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
*/ */
get cancelBubble(): boolean; get cancelBubble(): boolean;
/** /**
* The **`cancelBubble`** property of the Event interface is deprecated. * The **`cancelBubble`** property of the Event interface is deprecated. Use Event.stopPropagation() instead. Setting its value to true before returning from an event handler prevents propagation of the event. In later implementations, setting this to false does nothing. See Browser compatibility for details.
* @deprecated * @deprecated
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
*/ */
set cancelBubble(value: boolean); set cancelBubble(value: boolean);
/** /**
* The **`stopImmediatePropagation()`** method of the If several listeners are attached to the same element for the same event type, they are called in the order in which they were added. * The **`stopImmediatePropagation()`** method of the Event interface prevents other listeners of the same event from being called.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation)
*/ */
stopImmediatePropagation(): void; stopImmediatePropagation(): void;
/** /**
* The **`preventDefault()`** method of the Event interface tells the user agent that if the event does not get explicitly handled, its default action should not be taken as it normally would be. * The **`preventDefault()`** method of the Event interface tells the user agent that the event is being explicitly handled, so its default action, such as page scrolling, link navigation, or pasting text, should not be taken.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault)
*/ */
preventDefault(): void; preventDefault(): void;
/** /**
* The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases. * The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases. It does not, however, prevent any default behaviors from occurring; for instance, clicks on links are still processed. If you want to stop those behaviors, see the preventDefault() method. It also does not prevent propagation to other event-handlers of the current element. If you want to stop those, see stopImmediatePropagation().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation)
*/ */
stopPropagation(): void; stopPropagation(): void;
/** /**
* The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked. * The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked. This does not include nodes in shadow trees if the shadow root was created with its ShadowRoot.mode closed.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath)
*/ */
@@ -760,7 +768,7 @@ interface EventListenerObject<EventType extends Event = Event> {
} }
type EventListenerOrEventListenerObject<EventType extends Event = Event> = EventListener<EventType> | EventListenerObject<EventType>; type EventListenerOrEventListenerObject<EventType extends Event = Event> = EventListener<EventType> | EventListenerObject<EventType>;
/** /**
* The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them. * The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them. In other words, any target of events implements the three methods associated with this interface.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget)
*/ */
@@ -773,13 +781,13 @@ declare class EventTarget<EventMap extends Record<string, Event> = Record<string
*/ */
addEventListener<Type extends keyof EventMap>(type: Type, handler: EventListenerOrEventListenerObject<EventMap[Type]>, options?: EventTargetAddEventListenerOptions | boolean): void; addEventListener<Type extends keyof EventMap>(type: Type, handler: EventListenerOrEventListenerObject<EventMap[Type]>, options?: EventTargetAddEventListenerOptions | boolean): void;
/** /**
* The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target. * The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target. The event listener to be removed is identified using a combination of the event type, the event listener function itself, and various optional options that may affect the matching process; see Matching event listeners for removal.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener)
*/ */
removeEventListener<Type extends keyof EventMap>(type: Type, handler: EventListenerOrEventListenerObject<EventMap[Type]>, options?: EventTargetEventListenerOptions | boolean): void; removeEventListener<Type extends keyof EventMap>(type: Type, handler: EventListenerOrEventListenerObject<EventMap[Type]>, options?: EventTargetEventListenerOptions | boolean): void;
/** /**
* The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. The normal event processing rules (including the capturing and optional bubbling phase) also apply to events dispatched manually with dispatchEvent().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
*/ */
@@ -811,7 +819,7 @@ declare class AbortController {
*/ */
get signal(): AbortSignal; get signal(): AbortSignal;
/** /**
* The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed. * The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed. This is able to abort fetch requests, the consumption of any response bodies, or streams.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort)
*/ */
@@ -824,7 +832,7 @@ declare class AbortController {
*/ */
declare abstract class AbortSignal extends EventTarget { declare abstract class AbortSignal extends EventTarget {
/** /**
* The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an AbortSignal/abort_event event). * The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an abort event).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static)
*/ */
@@ -836,13 +844,13 @@ declare abstract class AbortSignal extends EventTarget {
*/ */
static timeout(delay: number): AbortSignal; static timeout(delay: number): AbortSignal;
/** /**
* The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal. * The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal. The returned abort signal is aborted when any of the input iterable abort signals are aborted. The abort reason will be set to the reason of the first signal that is aborted. If any of the given abort signals are already aborted then so will be the returned AbortSignal.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static)
*/ */
static any(signals: AbortSignal[]): AbortSignal; static any(signals: AbortSignal[]): AbortSignal;
/** /**
* The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (`true`) or not (`false`). * The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (true) or not (false).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted)
*/ */
@@ -858,12 +866,17 @@ declare abstract class AbortSignal extends EventTarget {
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */
set onabort(value: any | null); set onabort(value: any | null);
/** /**
* The **`throwIfAborted()`** method throws the signal's abort AbortSignal.reason if the signal has been aborted; otherwise it does nothing. * The **`throwIfAborted()`** method throws the signal's abort reason if the signal has been aborted; otherwise it does nothing.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted)
*/ */
throwIfAborted(): void; throwIfAborted(): void;
} }
/**
* The **`Scheduler`** interface of the Prioritized Task Scheduling API provides methods for scheduling prioritized tasks.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Scheduler)
*/
interface Scheduler { interface Scheduler {
wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise<void>; wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise<void>;
} }
@@ -871,20 +884,20 @@ interface SchedulerWaitOptions {
signal?: AbortSignal; signal?: AbortSignal;
} }
/** /**
* The **`ExtendableEvent`** interface extends the lifetime of the `install` and `activate` events dispatched on the global scope as part of the service worker lifecycle. * The **`ExtendableEvent`** interface extends the lifetime of the install and activate events dispatched on the global scope as part of the service worker lifecycle. This ensures that any functional events (like FetchEvent) are not dispatched until it upgrades database schemas and deletes the outdated cache entries.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent)
*/ */
declare abstract class ExtendableEvent extends Event { declare abstract class ExtendableEvent extends Event {
/** /**
* The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing. * The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing. It can also be used to detect whether that work was successful. In service workers, waitUntil() tells the browser that work is ongoing until the promise settles, and it shouldn't terminate the service worker if it wants that work to complete.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil)
*/ */
waitUntil(promise: Promise<any>): void; waitUntil(promise: Promise<any>): void;
} }
/** /**
* The **`CustomEvent`** interface represents events initialized by an application for any purpose. * The **`CustomEvent`** interface can be used to attach custom data to an event generated by an application.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent)
*/ */
@@ -923,7 +936,7 @@ declare class Blob {
*/ */
get type(): string; get type(): string;
/** /**
* The **`slice()`** method of the Blob interface creates and returns a new `Blob` object which contains data from a subset of the blob on which it's called. * The **`slice()`** method of the Blob interface creates and returns a new Blob object which contains data from a subset of the blob on which it's called.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice)
*/ */
@@ -941,13 +954,13 @@ declare class Blob {
*/ */
bytes(): Promise<Uint8Array>; bytes(): Promise<Uint8Array>;
/** /**
* The **`text()`** method of the string containing the contents of the blob, interpreted as UTF-8. * The **`text()`** method of the Blob interface returns a Promise that resolves with a string containing the contents of the blob, interpreted as UTF-8.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text)
*/ */
text(): Promise<string>; text(): Promise<string>;
/** /**
* The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the `Blob`. * The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the Blob.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream)
*/ */
@@ -964,13 +977,13 @@ interface BlobOptions {
declare class File extends Blob { declare class File extends Blob {
constructor(bits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined, name: string, options?: FileOptions); constructor(bits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined, name: string, options?: FileOptions);
/** /**
* The **`name`** read-only property of the File interface returns the name of the file represented by a File object. * The **`name`** read-only property of the File interface returns the name of the file represented by a File object. For security reasons, the path is excluded from this property.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name)
*/ */
get name(): string; get name(): string;
/** /**
* The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). * The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). Files without a known last modified date return the current date.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified)
*/ */
@@ -987,7 +1000,7 @@ interface FileOptions {
*/ */
declare abstract class CacheStorage { declare abstract class CacheStorage {
/** /**
* The **`open()`** method of the the Cache object matching the `cacheName`. * The **`open()`** method of the CacheStorage interface returns a Promise that resolves to the Cache object matching the cacheName.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open)
*/ */
@@ -1020,14 +1033,14 @@ interface CacheQueryOptions {
*/ */
declare abstract class Crypto { declare abstract class Crypto {
/** /**
* The **`Crypto.subtle`** read-only property returns a cryptographic operations. * The **`Crypto.subtle`** read-only property returns a SubtleCrypto which can then be used to perform low-level cryptographic operations.
* Available only in secure contexts. * Available only in secure contexts.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle)
*/ */
get subtle(): SubtleCrypto; get subtle(): SubtleCrypto;
/** /**
* The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values. * The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values. The array given as the parameter is filled with random numbers (random in its cryptographic meaning).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues)
*/ */
@@ -1055,7 +1068,7 @@ declare abstract class SubtleCrypto {
*/ */
encrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, plainText: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer>; encrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, plainText: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer>;
/** /**
* The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data. * The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data. It takes as arguments a key to decrypt with, some optional extra parameters, and the data to decrypt (also known as "ciphertext"). It returns a Promise which will be fulfilled with the decrypted data (also known as "plaintext").
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt)
*/ */
@@ -1073,7 +1086,7 @@ declare abstract class SubtleCrypto {
*/ */
verify(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise<boolean>; verify(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise<boolean>;
/** /**
* The **`digest()`** method of the SubtleCrypto interface generates a _digest_ of the given data, using the specified hash function. * The **`digest()`** method of the SubtleCrypto interface generates a digest of the given data, using the specified hash function. A digest is a short fixed-length value derived from some variable-length input. Cryptographic digests should exhibit collision-resistance, meaning that it's hard to come up with two different inputs that have the same digest value.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest)
*/ */
@@ -1091,7 +1104,7 @@ declare abstract class SubtleCrypto {
*/ */
deriveKey(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, derivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise<CryptoKey>; deriveKey(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, derivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise<CryptoKey>;
/** /**
* The **`deriveBits()`** method of the key. * The **`deriveBits()`** method of the SubtleCrypto interface can be used to derive an array of bits from a base key.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits)
*/ */
@@ -1109,13 +1122,13 @@ declare abstract class SubtleCrypto {
*/ */
exportKey(format: string, key: CryptoKey): Promise<ArrayBuffer | JsonWebKey>; exportKey(format: string, key: CryptoKey): Promise<ArrayBuffer | JsonWebKey>;
/** /**
* The **`wrapKey()`** method of the SubtleCrypto interface 'wraps' a key. * The **`wrapKey()`** method of the SubtleCrypto interface "wraps" a key. This means that it exports the key in an external, portable format, then encrypts the exported key. Wrapping a key helps protect it in untrusted environments, such as inside an otherwise unprotected data store or in transmission over an unprotected network.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey)
*/ */
wrapKey(format: string, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: string | SubtleCryptoEncryptAlgorithm): Promise<ArrayBuffer>; wrapKey(format: string, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: string | SubtleCryptoEncryptAlgorithm): Promise<ArrayBuffer>;
/** /**
* The **`unwrapKey()`** method of the SubtleCrypto interface 'unwraps' a key. * The **`unwrapKey()`** method of the SubtleCrypto interface "unwraps" a key. This means that it takes as its input a key that has been exported and then encrypted (also called "wrapped"). It decrypts the key and then imports it, returning a CryptoKey object that can be used in the Web Crypto API.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey)
*/ */
@@ -1123,20 +1136,20 @@ declare abstract class SubtleCrypto {
timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean; timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean;
} }
/** /**
* The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods SubtleCrypto.generateKey, SubtleCrypto.deriveKey, SubtleCrypto.importKey, or SubtleCrypto.unwrapKey. * The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods generateKey(), deriveKey(), importKey(), or unwrapKey().
* Available only in secure contexts. * Available only in secure contexts.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey)
*/ */
declare abstract class CryptoKey { declare abstract class CryptoKey {
/** /**
* The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object. * The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object. It can have the following values:
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type)
*/ */
readonly type: string; readonly type: string;
/** /**
* The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using `SubtleCrypto.exportKey()` or `SubtleCrypto.wrapKey()`. * The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using SubtleCrypto.exportKey() or SubtleCrypto.wrapKey().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable)
*/ */
@@ -1253,12 +1266,15 @@ interface CryptoKeyArbitraryKeyAlgorithm {
length?: number; length?: number;
} }
declare class DigestStream extends WritableStream<ArrayBuffer | ArrayBufferView> { declare class DigestStream extends WritableStream<ArrayBuffer | ArrayBufferView> {
constructor(algorithm: string | SubtleCryptoHashAlgorithm); constructor(algorithm: string | SubtleCryptoHashAlgorithm, options?: DigestStreamOptions);
readonly digest: Promise<ArrayBuffer>; readonly digest: Promise<ArrayBuffer>;
get bytesWritten(): number | bigint; get bytesWritten(): number | bigint;
} }
interface DigestStreamOptions {
toWellFormed?: boolean;
}
/** /**
* The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as `UTF-8`, `ISO-8859-2`, `KOI8-R`, `GBK`, etc. * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as UTF-8, ISO-8859-2, or GBK. A decoder takes an array of bytes as input and returns a JavaScript string.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder)
*/ */
@@ -1275,20 +1291,20 @@ declare class TextDecoder {
get ignoreBOM(): boolean; get ignoreBOM(): boolean;
} }
/** /**
* The **`TextEncoder`** interface takes a stream of code points as input and emits a stream of UTF-8 bytes. * The **`TextEncoder`** interface enables you to encode a JavaScript string using UTF-8.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder)
*/ */
declare class TextEncoder { declare class TextEncoder {
constructor(); constructor();
/** /**
* The **`TextEncoder.encode()`** method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object. * The **`TextEncoder.encode()`** method takes a string as input, and returns a Uint8Array containing the string encoded using UTF-8.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode)
*/ */
encode(input?: string): Uint8Array; encode(input?: string): Uint8Array;
/** /**
* The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns a dictionary object indicating the progress of the encoding. * The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns an object indicating the progress of the encoding. This is potentially more performant than the encode() method — especially when the target buffer is a view into a Wasm heap.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto)
*/ */
@@ -1345,6 +1361,9 @@ declare class ErrorEvent extends Event {
get error(): any; get error(): any;
} }
interface ErrorEventErrorEventInit { interface ErrorEventErrorEventInit {
bubbles?: boolean;
cancelable?: boolean;
composed?: boolean;
message?: string; message?: string;
filename?: string; filename?: string;
lineno?: number; lineno?: number;
@@ -1357,135 +1376,139 @@ interface ErrorEventErrorEventInit {
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent)
*/ */
declare class MessageEvent extends Event { declare class MessageEvent extends Event {
constructor(type: string, initializer: MessageEventInit); constructor(type: string, initializer?: MessageEventInit);
/** /**
* The **`data`** read-only property of the The data sent by the message emitter; this can be any data type, depending on what originated this event. * The **`data`** read-only property of the MessageEvent interface represents the data sent by the message emitter.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data)
*/ */
readonly data: any; readonly data: any;
/** /**
* The **`origin`** read-only property of the origin of the message emitter. * The **`origin`** read-only property of the MessageEvent interface is a string representing the origin of the message emitter.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/origin) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/origin)
*/ */
readonly origin: string | null; readonly origin: string | null;
/** /**
* The **`lastEventId`** read-only property of the unique ID for the event. * The **`lastEventId`** read-only property of the MessageEvent interface is a string representing a unique ID for the event.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/lastEventId) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/lastEventId)
*/ */
readonly lastEventId: string; readonly lastEventId: string;
/** /**
* The **`source`** read-only property of the a WindowProxy, MessagePort, or a `MessageEventSource` (which can be a WindowProxy, message emitter. * The **`source`** read-only property of the MessageEvent interface is a MessageEventSource (which can be a WindowProxy, MessagePort, or ServiceWorker object) representing the message emitter.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/source) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/source)
*/ */
readonly source: MessagePort | null; readonly source: MessagePort | null;
/** /**
* The **`ports`** read-only property of the containing all MessagePort objects sent with the message, in order. * The **`ports`** read-only property of the MessageEvent interface is an array of MessagePort objects containing all MessagePort objects sent with the message, in order.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/ports) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/ports)
*/ */
readonly ports: MessagePort[]; readonly ports: MessagePort[];
} }
interface MessageEventInit { interface MessageEventInit {
data: ArrayBuffer | string; bubbles?: boolean;
cancelable?: boolean;
composed?: boolean;
data?: any;
origin?: string;
lastEventId?: string;
source?: MessagePort;
ports?: MessagePort[];
} }
/** /**
* The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected. * The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected. These events are particularly useful for telemetry and debugging purposes.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent)
*/ */
declare abstract class PromiseRejectionEvent extends Event { declare abstract class PromiseRejectionEvent extends Event {
/** /**
* The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript rejected. * The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript Promise which was rejected. You can examine the event's PromiseRejectionEvent.reason property to learn why the promise was rejected.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise)
*/ */
readonly promise: Promise<any>; readonly promise: Promise<any>;
/** /**
* The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject(). * The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject(). This in theory provides information about why the promise was rejected.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason)
*/ */
readonly reason: any; readonly reason: any;
} }
/** /**
* The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the Window/fetch, XMLHttpRequest.send() or navigator.sendBeacon() methods. * The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the fetch(), XMLHttpRequest.send() or navigator.sendBeacon() methods. It uses the same format a form would use if the encoding type were set to "multipart/form-data".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData)
*/ */
declare class FormData { declare class FormData {
constructor(); constructor();
/** /**
* The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)
*/ */
append(name: string, value: string | Blob): void; append(name: string, value: string | Blob): void;
/** /**
* The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)
*/ */
append(name: string, value: string): void; append(name: string, value: string): void;
/** /**
* The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)
*/ */
append(name: string, value: Blob, filename?: string): void; append(name: string, value: Blob, filename?: string): void;
/** /**
* The **`delete()`** method of the FormData interface deletes a key and its value(s) from a `FormData` object. * The **`delete()`** method of the FormData interface deletes a key and its value(s) from a FormData object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete)
*/ */
delete(name: string): void; delete(name: string): void;
/** /**
* The **`get()`** method of the FormData interface returns the first value associated with a given key from within a `FormData` object. * The **`get()`** method of the FormData interface returns the first value associated with a given key from within a FormData object. If you expect multiple values and want all of them, use the getAll() method instead.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get)
*/ */
get(name: string): (File | string) | null; get(name: string): (File | string) | null;
/** /**
* The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a `FormData` object. * The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a FormData object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll)
*/ */
getAll(name: string): (File | string)[]; getAll(name: string): (File | string)[];
/** /**
* The **`has()`** method of the FormData interface returns whether a `FormData` object contains a certain key. * The **`has()`** method of the FormData interface returns whether a FormData object contains a certain key.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has)
*/ */
has(name: string): boolean; has(name: string): boolean;
/** /**
* The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)
*/ */
set(name: string, value: string | Blob): void; set(name: string, value: string | Blob): void;
/** /**
* The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)
*/ */
set(name: string, value: string): void; set(name: string, value: string): void;
/** /**
* The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)
*/ */
set(name: string, value: Blob, filename?: string): void; set(name: string, value: Blob, filename?: string): void;
/* Returns an array of key, value pairs for every entry in the list. */
entries(): IterableIterator<[ entries(): IterableIterator<[
key: string, key: string,
value: File | string value: File | string
]>; ]>;
/* Returns a list of keys in the list. */
keys(): IterableIterator<string>; keys(): IterableIterator<string>;
/* Returns a list of values in the list. */
values(): IterableIterator<(File | string)>; values(): IterableIterator<(File | string)>;
forEach<This = unknown>(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void; forEach<This = unknown>(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void;
[Symbol.iterator](): IterableIterator<[ [Symbol.iterator](): IterableIterator<[
@@ -1564,19 +1587,19 @@ interface DocumentEnd {
append(content: string, options?: ContentOptions): DocumentEnd; append(content: string, options?: ContentOptions): DocumentEnd;
} }
/** /**
* This is the event type for `fetch` events dispatched on the ServiceWorkerGlobalScope. * This is the event type for fetch events dispatched on the service worker global scope. It contains information about the fetch, including the request and how the receiver will treat the response. It provides the event.respondWith() method, which allows us to provide a response to this fetch.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent)
*/ */
declare abstract class FetchEvent extends ExtendableEvent { declare abstract class FetchEvent extends ExtendableEvent {
/** /**
* The **`request`** read-only property of the the event handler. * The **`request`** read-only property of the FetchEvent interface returns the Request that triggered the event handler.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request)
*/ */
readonly request: Request; readonly request: Request;
/** /**
* The **`respondWith()`** method of allows you to provide a promise for a Response yourself. * The **`respondWith()`** method of FetchEvent prevents the browser's default fetch handling, and allows you to provide a promise for a Response yourself.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith)
*/ */
@@ -1585,65 +1608,62 @@ declare abstract class FetchEvent extends ExtendableEvent {
} }
type HeadersInit = Headers | Iterable<Iterable<string>> | Record<string, string>; type HeadersInit = Headers | Iterable<Iterable<string>> | Record<string, string>;
/** /**
* The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers. * The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers. These actions include retrieving, setting, adding to, and removing headers from the list of the request's headers.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers)
*/ */
declare class Headers { declare class Headers {
constructor(init?: HeadersInit); constructor(init?: HeadersInit);
/** /**
* The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a `Headers` object with a given name. * The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a Headers object with a given name. If the requested header doesn't exist in the Headers object, it returns null.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get)
*/ */
get(name: string): string | null; get(name: string): string | null;
getAll(name: string): string[]; getAll(name: string): string[];
/** /**
* The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response. * The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response. This allows Headers objects to handle having multiple Set-Cookie headers, which wasn't possible prior to its implementation.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie)
*/ */
getSetCookie(): string[]; getSetCookie(): string[];
/** /**
* The **`has()`** method of the Headers interface returns a boolean stating whether a `Headers` object contains a certain header. * The **`has()`** method of the Headers interface returns a boolean stating whether a Headers object contains a certain header.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has)
*/ */
has(name: string): boolean; has(name: string): boolean;
/** /**
* The **`set()`** method of the Headers interface sets a new value for an existing header inside a `Headers` object, or adds the header if it does not already exist. * The **`set()`** method of the Headers interface sets a new value for an existing header inside a Headers object, or adds the header if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set)
*/ */
set(name: string, value: string): void; set(name: string, value: string): void;
/** /**
* The **`append()`** method of the Headers interface appends a new value onto an existing header inside a `Headers` object, or adds the header if it does not already exist. * The **`append()`** method of the Headers interface appends a new value onto an existing header inside a Headers object, or adds the header if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append)
*/ */
append(name: string, value: string): void; append(name: string, value: string): void;
/** /**
* The **`delete()`** method of the Headers interface deletes a header from the current `Headers` object. * The **`delete()`** method of the Headers interface deletes a header from the current Headers object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete)
*/ */
delete(name: string): void; delete(name: string): void;
forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: Headers) => void, thisArg?: This): void; forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: Headers) => void, thisArg?: This): void;
/* Returns an iterator allowing to go through all key/value pairs contained in this object. */
entries(): IterableIterator<[ entries(): IterableIterator<[
key: string, key: string,
value: string value: string
]>; ]>;
/* Returns an iterator allowing to go through all keys of the key/value pairs contained in this object. */
keys(): IterableIterator<string>; keys(): IterableIterator<string>;
/* Returns an iterator allowing to go through all values of the key/value pairs contained in this object. */
values(): IterableIterator<string>; values(): IterableIterator<string>;
[Symbol.iterator](): IterableIterator<[ [Symbol.iterator](): IterableIterator<[
key: string, key: string,
value: string value: string
]>; ]>;
} }
type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData; type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable<ArrayBuffer | ArrayBufferView> | AsyncIterable<ArrayBuffer | ArrayBufferView>;
declare abstract class Body { declare abstract class Body {
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */
get body(): ReadableStream | null; get body(): ReadableStream | null;
@@ -1699,7 +1719,7 @@ interface Response extends Body {
*/ */
statusText: string; statusText: string;
/** /**
* The **`headers`** read-only property of the with the response. * The **`headers`** read-only property of the Response interface contains the Headers object associated with the response.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers)
*/ */
@@ -1717,7 +1737,7 @@ interface Response extends Body {
*/ */
redirected: boolean; redirected: boolean;
/** /**
* The **`url`** read-only property of the Response interface contains the URL of the response. * The **`url`** read-only property of the Response interface contains the URL of the response. The value of the url property will be the final URL obtained after any redirects.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url)
*/ */
@@ -1725,7 +1745,7 @@ interface Response extends Body {
webSocket: WebSocket | null; webSocket: WebSocket | null;
cf: any | undefined; cf: any | undefined;
/** /**
* The **`type`** read-only property of the Response interface contains the type of the response. * The **`type`** read-only property of the Response interface contains the type of the response. The type determines whether scripts are able to access the response body and headers.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type)
*/ */
@@ -1756,13 +1776,13 @@ declare var Request: {
*/ */
interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> extends Body { interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> extends Body {
/** /**
* The **`clone()`** method of the Request interface creates a copy of the current `Request` object. * The **`clone()`** method of the Request interface creates a copy of the current Request object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone)
*/ */
clone(): Request<CfHostMetadata, Cf>; clone(): Request<CfHostMetadata, Cf>;
/** /**
* The **`method`** read-only property of the `POST`, etc.) A String indicating the method of the request. * The **`method`** read-only property of the Request interface contains the request's method (GET, POST, etc.)
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method)
*/ */
@@ -1774,7 +1794,7 @@ interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> e
*/ */
url: string; url: string;
/** /**
* The **`headers`** read-only property of the with the request. * The **`headers`** read-only property of the Request interface contains the Headers object associated with the request.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers)
*/ */
@@ -1800,13 +1820,13 @@ interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> e
*/ */
integrity: string; integrity: string;
/** /**
* The **`keepalive`** read-only property of the Request interface contains the request's `keepalive` setting (`true` or `false`), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete. * The **`keepalive`** read-only property of the Request interface contains the request's keepalive setting (true or false), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive)
*/ */
keepalive: boolean; keepalive: boolean;
/** /**
* The **`cache`** read-only property of the Request interface contains the cache mode of the request. * The **`cache`** read-only property of the Request interface contains the cache mode of the request. It controls how the request will interact with the browser's HTTP cache.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache)
*/ */
@@ -2195,7 +2215,7 @@ type ReadableStreamReadResult<R = any> = {
value?: undefined; value?: undefined;
}; };
/** /**
* The `ReadableStream` interface of the Streams API represents a readable stream of byte data. * The **`ReadableStream`** interface of the Streams API represents a readable stream of byte data. The Fetch API offers a concrete instance of a ReadableStream through the body property of a Response object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream)
*/ */
@@ -2213,13 +2233,13 @@ interface ReadableStream<R = any> {
*/ */
cancel(reason?: any): Promise<void>; cancel(reason?: any): Promise<void>;
/** /**
* The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. While the stream is locked, no other reader can be acquired until this one is released.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader)
*/ */
getReader(): ReadableStreamDefaultReader<R>; getReader(): ReadableStreamDefaultReader<R>;
/** /**
* The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. While the stream is locked, no other reader can be acquired until this one is released.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader)
*/ */
@@ -2231,13 +2251,13 @@ interface ReadableStream<R = any> {
*/ */
pipeThrough<T>(transform: ReadableWritablePair<T, R>, options?: StreamPipeOptions): ReadableStream<T>; pipeThrough<T>(transform: ReadableWritablePair<T, R>, options?: StreamPipeOptions): ReadableStream<T>;
/** /**
* The **`pipeTo()`** method of the ReadableStream interface pipes the current `ReadableStream` to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. * The **`pipeTo()`** method of the ReadableStream interface pipes the current ReadableStream to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo)
*/ */
pipeTo(destination: WritableStream<R>, options?: StreamPipeOptions): Promise<void>; pipeTo(destination: WritableStream<R>, options?: StreamPipeOptions): Promise<void>;
/** /**
* The **`tee()`** method of the two-element array containing the two resulting branches as new ReadableStream instances. * The **`tee()`** method of the ReadableStream interface tees the current readable stream, returning a two-element array containing the two resulting branches as new ReadableStream instances.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/tee) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/tee)
*/ */
@@ -2249,7 +2269,7 @@ interface ReadableStream<R = any> {
[Symbol.asyncIterator](options?: ReadableStreamValuesOptions): AsyncIterableIterator<R>; [Symbol.asyncIterator](options?: ReadableStreamValuesOptions): AsyncIterableIterator<R>;
} }
/** /**
* The `ReadableStream` interface of the Streams API represents a readable stream of byte data. * The **`ReadableStream`** interface of the Streams API represents a readable stream of byte data. The Fetch API offers a concrete instance of a ReadableStream through the body property of a Response object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream)
*/ */
@@ -2281,7 +2301,7 @@ declare class ReadableStreamDefaultReader<R = any> {
releaseLock(): void; releaseLock(): void;
} }
/** /**
* The `ReadableStreamBYOBReader` interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source. * The **`ReadableStreamBYOBReader`** interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source. It is used for efficient copying from underlying sources where the data is delivered as an "anonymous" sequence of bytes, such as files.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader)
*/ */
@@ -2290,13 +2310,13 @@ declare class ReadableStreamBYOBReader {
get closed(): Promise<void>; get closed(): Promise<void>;
cancel(reason?: any): Promise<void>; cancel(reason?: any): Promise<void>;
/** /**
* The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream. * The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream. A request for data will be satisfied from the stream's internal queues if there is any data present. If the stream queues are empty, the request may be supplied as a zero-copy transfer from the underlying byte source.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/read) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/read)
*/ */
read<T extends ArrayBufferView>(view: T): Promise<ReadableStreamReadResult<T>>; read<T extends ArrayBufferView>(view: T): Promise<ReadableStreamReadResult<T>>;
/** /**
* The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream. * The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream. After the lock is released, the reader is no longer active.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/releaseLock) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/releaseLock)
*/ */
@@ -2315,7 +2335,7 @@ interface ReadableStreamGetReaderOptions {
mode: "byob"; mode: "byob";
} }
/** /**
* The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a 'pull request' for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues). * The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a "pull request" for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest)
*/ */
@@ -2341,13 +2361,13 @@ declare abstract class ReadableStreamBYOBRequest {
get atLeast(): number | null; get atLeast(): number | null;
} }
/** /**
* The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue. * The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue. Default controllers are for streams that are not byte streams.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController)
*/ */
declare abstract class ReadableStreamDefaultController<R = any> { declare abstract class ReadableStreamDefaultController<R = any> {
/** /**
* The **`desiredSize`** read-only property of the required to fill the stream's internal queue. * The **`desiredSize`** read-only property of the ReadableStreamDefaultController interface returns the desired size required to fill the stream's internal queue.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/desiredSize) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/desiredSize)
*/ */
@@ -2359,32 +2379,32 @@ declare abstract class ReadableStreamDefaultController<R = any> {
*/ */
close(): void; close(): void;
/** /**
* The **`enqueue()`** method of the ```js-nolint enqueue(chunk) ``` - `chunk` - : The chunk to enqueue. * The **`enqueue()`** method of the ReadableStreamDefaultController interface enqueues a given chunk in the associated stream.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/enqueue) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/enqueue)
*/ */
enqueue(chunk?: R): void; enqueue(chunk?: R): void;
/** /**
* The **`error()`** method of the with the associated stream to error. * The **`error()`** method of the ReadableStreamDefaultController interface causes any future interactions with the associated stream to error.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/error) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/error)
*/ */
error(reason: any): void; error(reason: any): void;
} }
/** /**
* The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream. * The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream. It allows control of the state and internal queue of a ReadableStream with an underlying byte source, and enables efficient zero-copy transfer of data from the underlying source to a consumer when the stream's internal queue is empty.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController)
*/ */
declare abstract class ReadableByteStreamController { declare abstract class ReadableByteStreamController {
/** /**
* The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or `null` if there are no pending requests. * The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or null if there are no pending requests.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/byobRequest) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/byobRequest)
*/ */
get byobRequest(): ReadableStreamBYOBRequest | null; get byobRequest(): ReadableStreamBYOBRequest | null;
/** /**
* The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its 'desired size'. * The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its "desired size".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/desiredSize) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/desiredSize)
*/ */
@@ -2396,7 +2416,7 @@ declare abstract class ReadableByteStreamController {
*/ */
close(): void; close(): void;
/** /**
* The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is copied into the stream's internal queues). * The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is transferred into the stream's internal queues).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/enqueue) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/enqueue)
*/ */
@@ -2409,7 +2429,7 @@ declare abstract class ReadableByteStreamController {
error(reason: any): void; error(reason: any): void;
} }
/** /**
* The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state. * The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state. When constructing a WritableStream, the underlying sink is given a corresponding WritableStreamDefaultController instance to manipulate.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController)
*/ */
@@ -2421,7 +2441,7 @@ declare abstract class WritableStreamDefaultController {
*/ */
get signal(): AbortSignal; get signal(): AbortSignal;
/** /**
* The **`error()`** method of the with the associated stream to error. * The **`error()`** method of the WritableStreamDefaultController interface causes any future interactions with the associated stream to error.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/error) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/error)
*/ */
@@ -2446,7 +2466,7 @@ declare abstract class TransformStreamDefaultController<O = any> {
*/ */
enqueue(chunk?: O): void; enqueue(chunk?: O): void;
/** /**
* The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream. * The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream. Any further interactions with it will fail with the given error message, and any chunks in the queue will be discarded.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/error) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/error)
*/ */
@@ -2468,14 +2488,14 @@ interface ReadableWritablePair<R = any, W = any> {
writable: WritableStream<W>; writable: WritableStream<W>;
} }
/** /**
* The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. * The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. This object comes with built-in backpressure and queuing.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream)
*/ */
declare class WritableStream<W = any> { declare class WritableStream<W = any> {
constructor(underlyingSink?: UnderlyingSink, queuingStrategy?: QueuingStrategy); constructor(underlyingSink?: UnderlyingSink, queuingStrategy?: QueuingStrategy);
/** /**
* The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the `WritableStream` is locked to a writer. * The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the WritableStream is locked to a writer.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/locked) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/locked)
*/ */
@@ -2487,83 +2507,83 @@ declare class WritableStream<W = any> {
*/ */
abort(reason?: any): Promise<void>; abort(reason?: any): Promise<void>;
/** /**
* The **`close()`** method of the WritableStream interface closes the associated stream. * The **`close()`** method of the WritableStream interface closes the associated stream. All chunks written before this method is called are sent before the returned promise is fulfilled.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/close) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/close)
*/ */
close(): Promise<void>; close(): Promise<void>;
/** /**
* The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance. * The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance. While the stream is locked, no other writer can be acquired until this one is released.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/getWriter) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/getWriter)
*/ */
getWriter(): WritableStreamDefaultWriter<W>; getWriter(): WritableStreamDefaultWriter<W>;
} }
/** /**
* The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the `WritableStream` ensuring that no other streams can write to the underlying sink. * The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the WritableStream ensuring that no other streams can write to the underlying sink.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter)
*/ */
declare class WritableStreamDefaultWriter<W = any> { declare class WritableStreamDefaultWriter<W = any> {
constructor(stream: WritableStream); constructor(stream: WritableStream);
/** /**
* The **`closed`** read-only property of the the stream errors or the writer's lock is released. * The **`closed`** read-only property of the WritableStreamDefaultWriter interface returns a Promise that fulfills if the stream becomes closed, or rejects if the stream errors or the writer's lock is released.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/closed) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/closed)
*/ */
get closed(): Promise<void>; get closed(): Promise<void>;
/** /**
* The **`ready`** read-only property of the that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure. * The **`ready`** read-only property of the WritableStreamDefaultWriter interface returns a Promise that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/ready) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/ready)
*/ */
get ready(): Promise<void>; get ready(): Promise<void>;
/** /**
* The **`desiredSize`** read-only property of the to fill the stream's internal queue. * The **`desiredSize`** read-only property of the WritableStreamDefaultWriter interface returns the desired size required to fill the stream's internal queue.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/desiredSize) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/desiredSize)
*/ */
get desiredSize(): number | null; get desiredSize(): number | null;
/** /**
* The **`abort()`** method of the the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. * The **`abort()`** method of the WritableStreamDefaultWriter interface aborts the stream, signaling that the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/abort) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/abort)
*/ */
abort(reason?: any): Promise<void>; abort(reason?: any): Promise<void>;
/** /**
* The **`close()`** method of the stream. * The **`close()`** method of the WritableStreamDefaultWriter interface closes the associated writable stream.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/close) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/close)
*/ */
close(): Promise<void>; close(): Promise<void>;
/** /**
* The **`write()`** method of the operation. * The **`write()`** method of the WritableStreamDefaultWriter interface writes a passed chunk of data to a WritableStream and its underlying sink, then returns a Promise that resolves to indicate the success or failure of the write operation.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/write) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/write)
*/ */
write(chunk?: W): Promise<void>; write(chunk?: W): Promise<void>;
/** /**
* The **`releaseLock()`** method of the corresponding stream. * The **`releaseLock()`** method of the WritableStreamDefaultWriter interface releases the writer's lock on the corresponding stream. After the lock is released, the writer is no longer active. If the associated stream is errored when the lock is released, the writer will appear errored in the same way from now on; otherwise, the writer will appear closed.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/releaseLock) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/releaseLock)
*/ */
releaseLock(): void; releaseLock(): void;
} }
/** /**
* The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain _transform stream_ concept. * The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain transform stream concept.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream)
*/ */
declare class TransformStream<I = any, O = any> { declare class TransformStream<I = any, O = any> {
constructor(transformer?: Transformer<I, O>, writableStrategy?: QueuingStrategy<I>, readableStrategy?: QueuingStrategy<O>); constructor(transformer?: Transformer<I, O>, writableStrategy?: QueuingStrategy<I>, readableStrategy?: QueuingStrategy<O>);
/** /**
* The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this `TransformStream`. * The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this TransformStream. This stream emits the transformed output data.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/readable) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/readable)
*/ */
get readable(): ReadableStream<O>; get readable(): ReadableStream<O>;
/** /**
* The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this `TransformStream`. * The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this TransformStream. This stream accepts input data that will be transformed and emitted to the readable stream.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/writable) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/writable)
*/ */
@@ -2582,7 +2602,7 @@ interface ReadableStreamValuesOptions {
preventCancel?: boolean; preventCancel?: boolean;
} }
/** /**
* The **`CompressionStream`** interface of the Compression Streams API is an API for compressing a stream of data. * The **`CompressionStream`** interface of the Compression Streams API compresses a stream of data. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream)
*/ */
@@ -2590,7 +2610,7 @@ declare class CompressionStream extends TransformStream<ArrayBuffer | ArrayBuffe
constructor(format: "gzip" | "deflate" | "deflate-raw"); constructor(format: "gzip" | "deflate" | "deflate-raw");
} }
/** /**
* The **`DecompressionStream`** interface of the Compression Streams API is an API for decompressing a stream of data. * The **`DecompressionStream`** interface of the Compression Streams API decompresses a stream of data. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DecompressionStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DecompressionStream)
*/ */
@@ -2598,7 +2618,7 @@ declare class DecompressionStream extends TransformStream<ArrayBuffer | ArrayBuf
constructor(format: "gzip" | "deflate" | "deflate-raw"); constructor(format: "gzip" | "deflate" | "deflate-raw");
} }
/** /**
* The **`TextEncoderStream`** interface of the Encoding API converts a stream of strings into bytes in the UTF-8 encoding. * The **`TextEncoderStream`** interface of the Encoding API converts a stream of strings into bytes in the UTF-8 encoding. It is the streaming equivalent of TextEncoder. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoderStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoderStream)
*/ */
@@ -2607,7 +2627,7 @@ declare class TextEncoderStream extends TransformStream<string, Uint8Array> {
get encoding(): string; get encoding(): string;
} }
/** /**
* The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings. * The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings. It is the streaming equivalent of TextDecoder. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoderStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoderStream)
*/ */
@@ -2756,6 +2776,12 @@ interface TraceLog {
readonly timestamp: number; readonly timestamp: number;
readonly level: string; readonly level: string;
readonly message: any; readonly message: any;
readonly errorInfo?: (TraceLogErrorInfo | null)[];
}
interface TraceLogErrorInfo {
name: string;
message: string;
stack?: string;
} }
interface TraceException { interface TraceException {
readonly timestamp: number; readonly timestamp: number;
@@ -2776,7 +2802,7 @@ interface UnsafeTraceMetrics {
fromTrace(item: TraceItem): TraceMetrics; fromTrace(item: TraceItem): TraceMetrics;
} }
/** /**
* The **`URL`** interface is used to parse, construct, normalize, and encode URL. * The **`URL`** interface is used to parse, construct, normalize, and encode URLs. It works by providing properties which allow you to easily read and modify the components of a URL.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL)
*/ */
@@ -2801,121 +2827,121 @@ declare class URL {
*/ */
set href(value: string); set href(value: string);
/** /**
* The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final ":".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol)
*/ */
get protocol(): string; get protocol(): string;
/** /**
* The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final ":".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol)
*/ */
set protocol(value: string); set protocol(value: string);
/** /**
* The **`username`** property of the URL interface is a string containing the username component of the URL. * The **`username`** property of the URL interface is a string containing the username component of the URL. If the URL does not have a username, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username)
*/ */
get username(): string; get username(): string;
/** /**
* The **`username`** property of the URL interface is a string containing the username component of the URL. * The **`username`** property of the URL interface is a string containing the username component of the URL. If the URL does not have a username, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username)
*/ */
set username(value: string); set username(value: string);
/** /**
* The **`password`** property of the URL interface is a string containing the password component of the URL. * The **`password`** property of the URL interface is a string containing the password component of the URL. If the URL does not have a password, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password)
*/ */
get password(): string; get password(): string;
/** /**
* The **`password`** property of the URL interface is a string containing the password component of the URL. * The **`password`** property of the URL interface is a string containing the password component of the URL. If the URL does not have a password, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password)
*/ */
set password(value: string); set password(value: string);
/** /**
* The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. * The **`host`** property of the URL interface is a string containing the host, which is the hostname, and then, if the port of the URL is nonempty, a ":", followed by the port of the URL. If the URL does not have a hostname, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host)
*/ */
get host(): string; get host(): string;
/** /**
* The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. * The **`host`** property of the URL interface is a string containing the host, which is the hostname, and then, if the port of the URL is nonempty, a ":", followed by the port of the URL. If the URL does not have a hostname, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host)
*/ */
set host(value: string); set host(value: string);
/** /**
* The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. If the URL does not have a hostname, this property contains an empty string, "". IPv4 and IPv6 addresses are normalized, such as stripping leading zeros, and domain names are converted to IDN.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname)
*/ */
get hostname(): string; get hostname(): string;
/** /**
* The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. If the URL does not have a hostname, this property contains an empty string, "". IPv4 and IPv6 addresses are normalized, such as stripping leading zeros, and domain names are converted to IDN.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname)
*/ */
set hostname(value: string); set hostname(value: string);
/** /**
* The **`port`** property of the URL interface is a string containing the port number of the URL. * The **`port`** property of the URL interface is a string containing the port number of the URL. If the port is the default for the protocol (80 for ws: and http:, 443 for wss: and https:, and 21 for ftp:), this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port)
*/ */
get port(): string; get port(): string;
/** /**
* The **`port`** property of the URL interface is a string containing the port number of the URL. * The **`port`** property of the URL interface is a string containing the port number of the URL. If the port is the default for the protocol (80 for ws: and http:, 443 for wss: and https:, and 21 for ftp:), this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port)
*/ */
set port(value: string); set port(value: string);
/** /**
* The **`pathname`** property of the URL interface represents a location in a hierarchical structure. * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. It is a string constructed from a list of path segments, each of which is prefixed by a / character.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname)
*/ */
get pathname(): string; get pathname(): string;
/** /**
* The **`pathname`** property of the URL interface represents a location in a hierarchical structure. * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. It is a string constructed from a list of path segments, each of which is prefixed by a / character.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname)
*/ */
set pathname(value: string); set pathname(value: string);
/** /**
* The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. * The **`search`** property of the URL interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the URL. If the URL does not have a search query, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search)
*/ */
get search(): string; get search(): string;
/** /**
* The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. * The **`search`** property of the URL interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the URL. If the URL does not have a search query, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search)
*/ */
set search(value: string); set search(value: string);
/** /**
* The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. * The **`hash`** property of the URL interface is a string containing a "#" followed by the fragment identifier of the URL. If the URL does not have a fragment identifier, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash)
*/ */
get hash(): string; get hash(): string;
/** /**
* The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. * The **`hash`** property of the URL interface is a string containing a "#" followed by the fragment identifier of the URL. If the URL does not have a fragment identifier, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash)
*/ */
set hash(value: string); set hash(value: string);
/** /**
* The **`searchParams`** read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL. * The **`searchParams`** read-only property of the URL interface returns a URLSearchParams object allowing access to the GET decoded query arguments contained in the URL.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/searchParams) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/searchParams)
*/ */
get searchParams(): URLSearchParams; get searchParams(): URLSearchParams;
/** /**
* The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as ```js-nolint toJSON() ``` None. * The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as URL.toString().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/toJSON) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/toJSON)
*/ */
@@ -2935,13 +2961,13 @@ declare class URL {
*/ */
static parse(url: string, base?: string): URL | null; static parse(url: string, base?: string): URL | null;
/** /**
* The **`createObjectURL()`** static method of the URL interface creates a string containing a URL representing the object given in the parameter. * The **`createObjectURL()`** static method of the URL interface creates a string containing a blob URL pointing to the object given in the parameter.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static)
*/ */
static createObjectURL(object: File | Blob): string; static createObjectURL(object: File | Blob): string;
/** /**
* The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling Call this method when you've finished using an object URL to let the browser know not to keep the reference to the file any longer. * The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling URL.createObjectURL().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/revokeObjectURL_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/revokeObjectURL_static)
*/ */
@@ -2991,25 +3017,22 @@ declare class URLSearchParams {
*/ */
has(name: string, value?: string): boolean; has(name: string, value?: string): boolean;
/** /**
* The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value. * The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value. If there were several matching values, this method deletes the others. If the search parameter doesn't exist, this method creates it.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set)
*/ */
set(name: string, value: string): void; set(name: string, value: string): void;
/** /**
* The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns `undefined`. * The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns undefined. Key/value pairs are sorted by the values of the UTF-16 code units of the keys. This method uses a stable sorting algorithm (i.e., the relative order between key/value pairs with equal keys will be preserved).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort)
*/ */
sort(): void; sort(): void;
/* Returns an array of key, value pairs for every entry in the search params. */
entries(): IterableIterator<[ entries(): IterableIterator<[
key: string, key: string,
value: string value: string
]>; ]>;
/* Returns a list of keys in the search params. */
keys(): IterableIterator<string>; keys(): IterableIterator<string>;
/* Returns a list of values in the search params. */
values(): IterableIterator<string>; values(): IterableIterator<string>;
forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void; forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void;
/*function toString() { [native code] }*/ /*function toString() { [native code] }*/
@@ -3019,18 +3042,78 @@ declare class URLSearchParams {
value: string value: string
]>; ]>;
} }
/**
* The **`URLPattern`** interface of the URL Pattern API matches URLs or parts of URLs against a pattern. The pattern can contain capturing groups that extract parts of the matched URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern)
*/
declare class URLPattern { declare class URLPattern {
constructor(input?: (string | URLPatternInit), baseURL?: (string | URLPatternOptions), patternOptions?: URLPatternOptions); constructor(input?: (string | URLPatternInit), baseURL?: (string | URLPatternOptions), patternOptions?: URLPatternOptions);
/**
* The **`protocol`** read-only property of the URLPattern interface is a string containing the pattern used to match the protocol part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/protocol)
*/
get protocol(): string; get protocol(): string;
/**
* The **`username`** read-only property of the URLPattern interface is a string containing the pattern used to match the username part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/username)
*/
get username(): string; get username(): string;
/**
* The **`password`** read-only property of the URLPattern interface is a string containing the pattern used to match the password part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/password)
*/
get password(): string; get password(): string;
/**
* The **`hostname`** read-only property of the URLPattern interface is a string containing the pattern used to match the hostname part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hostname)
*/
get hostname(): string; get hostname(): string;
/**
* The **`port`** read-only property of the URLPattern interface is a string containing the pattern used to match the port part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/port)
*/
get port(): string; get port(): string;
/**
* The **`pathname`** read-only property of the URLPattern interface is a string containing the pattern used to match the pathname part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/pathname)
*/
get pathname(): string; get pathname(): string;
/**
* The **`search`** read-only property of the URLPattern interface is a string containing the pattern used to match the search part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/search)
*/
get search(): string; get search(): string;
/**
* The **`hash`** read-only property of the URLPattern interface is a string containing the pattern used to match the fragment part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hash)
*/
get hash(): string; get hash(): string;
/**
* The **`hasRegExpGroups`** read-only property of the URLPattern interface is a boolean indicating whether or not any of the URLPattern components contain regular expression capturing groups.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hasRegExpGroups)
*/
get hasRegExpGroups(): boolean; get hasRegExpGroups(): boolean;
/**
* The **`test()`** method of the URLPattern interface takes a URL string or object of URL parts, and returns a boolean indicating if the given input matches the current pattern.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/test)
*/
test(input?: (string | URLPatternInit), baseURL?: string): boolean; test(input?: (string | URLPatternInit), baseURL?: string): boolean;
/**
* The **`exec()`** method of the URLPattern interface takes a URL or object of URL parts, and returns either an object containing the results of matching the URL to the pattern, or null if the URL does not match the pattern.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/exec)
*/
exec(input?: (string | URLPatternInit), baseURL?: string): URLPatternResult | null; exec(input?: (string | URLPatternInit), baseURL?: string): URLPatternResult | null;
} }
interface URLPatternInit { interface URLPatternInit {
@@ -3063,7 +3146,7 @@ interface URLPatternOptions {
ignoreCase?: boolean; ignoreCase?: boolean;
} }
/** /**
* A `CloseEvent` is sent to clients using WebSockets when the connection is closed. * A **`CloseEvent`** is sent to clients using WebSockets when the connection is closed. This is delivered to the listener indicated by the WebSocket object's onclose attribute.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent)
*/ */
@@ -3082,13 +3165,16 @@ declare class CloseEvent extends Event {
*/ */
readonly reason: string; readonly reason: string;
/** /**
* The **`wasClean`** read-only property of the CloseEvent interface returns `true` if the connection closed cleanly. * The **`wasClean`** read-only property of the CloseEvent interface returns true if the connection closed cleanly.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/wasClean) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/wasClean)
*/ */
readonly wasClean: boolean; readonly wasClean: boolean;
} }
interface CloseEventInit { interface CloseEventInit {
bubbles?: boolean;
cancelable?: boolean;
composed?: boolean;
code?: number; code?: number;
reason?: string; reason?: string;
wasClean?: boolean; wasClean?: boolean;
@@ -3100,7 +3186,7 @@ type WebSocketEventMap = {
error: ErrorEvent; error: ErrorEvent;
}; };
/** /**
* The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. * The **`WebSocket`** object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket)
*/ */
@@ -3117,20 +3203,20 @@ declare var WebSocket: {
readonly CLOSED: number; readonly CLOSED: number;
}; };
/** /**
* The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. * The **`WebSocket`** object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket)
*/ */
interface WebSocket extends EventTarget<WebSocketEventMap> { interface WebSocket extends EventTarget<WebSocketEventMap> {
accept(options?: WebSocketAcceptOptions): void; accept(options?: WebSocketAcceptOptions): void;
/** /**
* The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of `bufferedAmount` by the number of bytes needed to contain the data. * The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of bufferedAmount by the number of bytes needed to contain the data. If the data can't be sent (for example, because it needs to be buffered but the buffer is full), the socket is closed automatically. The browser will throw an exception if you call send() when the connection is in the CONNECTING state. If you call send() when the connection is in the CLOSING or CLOSED states, the browser will silently discard the data.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/send) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/send)
*/ */
send(message: (ArrayBuffer | ArrayBufferView) | string): void; send(message: (ArrayBuffer | ArrayBufferView) | string): void;
/** /**
* The **`WebSocket.close()`** method closes the already `CLOSED`, this method does nothing. * The **`WebSocket.close()`** method closes the WebSocket connection or connection attempt, if any. If the connection is already CLOSED, this method does nothing.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/close) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/close)
*/ */
@@ -3150,13 +3236,13 @@ interface WebSocket extends EventTarget<WebSocketEventMap> {
*/ */
url: string | null; url: string | null;
/** /**
* The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the `protocols` parameter when creating the WebSocket object, or the empty string if no connection is established. * The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the protocols parameter when creating the WebSocket object, or the empty string if no connection is established.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/protocol) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/protocol)
*/ */
protocol: string | null; protocol: string | null;
/** /**
* The **`WebSocket.extensions`** read-only property returns the extensions selected by the server. * The **`WebSocket.extensions`** read-only property returns the extensions selected by the server. This is currently only the empty string or a list of extensions as negotiated by the connection.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions)
*/ */
@@ -3243,25 +3329,25 @@ interface SocketInfo {
declare class EventSource extends EventTarget { declare class EventSource extends EventTarget {
constructor(url: string, init?: EventSourceEventSourceInit); constructor(url: string, init?: EventSourceEventSourceInit);
/** /**
* The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the ```js-nolint close() ``` None. * The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the EventSource.readyState attribute to 2 (closed).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close)
*/ */
close(): void; close(): void;
/** /**
* The **`url`** read-only property of the URL of the source. * The **`url`** read-only property of the EventSource interface returns a string representing the URL of the source.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url)
*/ */
get url(): string; get url(): string;
/** /**
* The **`withCredentials`** read-only property of the the `EventSource` object was instantiated with CORS credentials set. * The **`withCredentials`** read-only property of the EventSource interface returns a boolean value indicating whether the EventSource object was instantiated with CORS credentials set.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials)
*/ */
get withCredentials(): boolean; get withCredentials(): boolean;
/** /**
* The **`readyState`** read-only property of the connection. * The **`readyState`** read-only property of the EventSource interface returns a number representing the state of the connection.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState)
*/ */
@@ -3296,18 +3382,26 @@ interface ContainerExecOptions {
cwd?: string; cwd?: string;
env?: Record<string, string>; env?: Record<string, string>;
user?: string; user?: string;
signal?: AbortSignal;
pty?: boolean | ContainerExecPtyOptions;
stdin?: ReadableStream | "pipe"; stdin?: ReadableStream | "pipe";
stdout?: "pipe" | "ignore"; stdout?: "pipe" | "ignore";
stderr?: "pipe" | "ignore" | "combined"; stderr?: "pipe" | "ignore" | "combined";
} }
interface ContainerExecPtyOptions {
cols?: number;
rows?: number;
}
interface ExecProcess { interface ExecProcess {
readonly stdin: WritableStream | null; readonly stdin: WritableStream | null;
readonly stdout: ReadableStream | null; readonly stdout: ReadableStream | null;
readonly stderr: ReadableStream | null; readonly stderr: ReadableStream | null;
readonly pid: number; readonly pid: number;
readonly isPty: boolean;
readonly exitCode: Promise<number>; readonly exitCode: Promise<number>;
output(): Promise<ExecOutput>; output(): Promise<ExecOutput>;
kill(signal?: number): void; kill(signal?: number): void;
resize(cols: number, rows: number): void;
} }
interface Container { interface Container {
get running(): boolean; get running(): boolean;
@@ -3334,25 +3428,42 @@ interface ContainerDirectorySnapshotOptions {
dir: string; dir: string;
name?: string; name?: string;
} }
interface ContainerDirectorySnapshotRestoreParams { type ContainerDirectorySnapshotRestoreParams = {
snapshot: ContainerDirectorySnapshot; snapshot: ContainerDirectorySnapshot;
mountPoint?: string; mountPoint?: string;
} } | {
snapshot?: undefined;
mountPoint: string;
};
interface ContainerSnapshot { interface ContainerSnapshot {
id: string; id: string;
size: number; size: number;
name?: string; name?: string;
} }
interface ContainerSnapshotRestoreParams {
id: string;
}
interface ContainerSnapshotOptions { interface ContainerSnapshotOptions {
name?: string; name?: string;
} }
interface ContainerStartupOptions { type ContainerStartupOptions = {
entrypoint?: string[]; entrypoint?: string[];
enableInternet: boolean; enableInternet: boolean;
env?: Record<string, string>; env?: Record<string, string>;
instance?: "lite" | "standard-1" | "standard-2" | "standard-3" | "standard-4" | ContainerStartResources;
labels?: Record<string, string>; labels?: Record<string, string>;
directorySnapshots?: ContainerDirectorySnapshotRestoreParams[]; directorySnapshots?: ContainerDirectorySnapshotRestoreParams[];
containerSnapshot?: ContainerSnapshot; } & ({
image: string;
containerSnapshot?: never;
} | {
image?: never;
containerSnapshot?: ContainerSnapshotRestoreParams;
});
interface ContainerStartResources {
vcpu: number;
memoryMib: number;
diskMb: number;
} }
/** /**
* The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other. * The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other.
@@ -3361,19 +3472,19 @@ interface ContainerStartupOptions {
*/ */
declare abstract class MessagePort extends EventTarget { declare abstract class MessagePort extends EventTarget {
/** /**
* The **`postMessage()`** method of the transfers ownership of objects to other browsing contexts. * The **`postMessage()`** method of the MessagePort interface sends a message from the port, and optionally, transfers ownership of objects to other browsing contexts.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/postMessage) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/postMessage)
*/ */
postMessage(data?: any, options?: (any[] | MessagePortPostMessageOptions)): void; postMessage(data?: any, options?: (any[] | MessagePortPostMessageOptions)): void;
/** /**
* The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active. * The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active. This stops the flow of messages to that port.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/close) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/close)
*/ */
close(): void; close(): void;
/** /**
* The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port. * The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port. This method is only needed when using EventTarget.addEventListener; it is implied when using onmessage.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/start) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/start)
*/ */
@@ -3389,13 +3500,13 @@ declare abstract class MessagePort extends EventTarget {
declare class MessageChannel { declare class MessageChannel {
constructor(); constructor();
/** /**
* The **`port1`** read-only property of the the port attached to the context that originated the channel. * The **`port1`** read-only property of the MessageChannel interface returns the first port of the message channel — the port attached to the context that originated the channel.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port1) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port1)
*/ */
readonly port1: MessagePort; readonly port1: MessagePort;
/** /**
* The **`port2`** read-only property of the the port attached to the context at the other end of the channel, which the message is initially sent to. * The **`port2`** read-only property of the MessageChannel interface returns the second port of the message channel — the port attached to the context at the other end of the channel, which the message is initially sent to.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port2) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port2)
*/ */
@@ -3455,7 +3566,7 @@ interface WorkerLoaderModule {
data?: ArrayBuffer; data?: ArrayBuffer;
json?: any; json?: any;
py?: string; py?: string;
wasm?: ArrayBuffer; wasm?: ArrayBuffer | ArrayBufferView | WebAssembly.Module;
} }
interface WorkerLoaderWorkerCode { interface WorkerLoaderWorkerCode {
compatibilityDate: string; compatibilityDate: string;
@@ -3463,7 +3574,7 @@ interface WorkerLoaderWorkerCode {
allowExperimental?: boolean; allowExperimental?: boolean;
limits?: workerdResourceLimits; limits?: workerdResourceLimits;
mainModule: string; mainModule: string;
modules: Record<string, WorkerLoaderModule | string>; modules: Record<string, string | WebAssembly.Module | WorkerLoaderModule>;
env?: any; env?: any;
globalOutbound?: (Fetcher | null); globalOutbound?: (Fetcher | null);
tails?: Fetcher[]; tails?: Fetcher[];
@@ -3485,7 +3596,7 @@ declare abstract class Performance {
/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */ /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */
now(): number; now(): number;
/** /**
* The **`toJSON()`** method of the Performance interface is a Serialization; it returns a JSON representation of the Performance object. * The **`toJSON()`** method of the Performance interface is a serializer; it returns a JSON representation of the Performance object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON)
*/ */
@@ -3494,11 +3605,13 @@ declare abstract class Performance {
interface Tracing { interface Tracing {
enterSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; enterSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T;
startActiveSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; startActiveSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T;
startSpan(name: string): Span;
Span: typeof Span; Span: typeof Span;
} }
declare abstract class Span { declare abstract class Span {
get isTraced(): boolean; get isTraced(): boolean;
setAttribute(key: string, value?: (boolean | number | string)): void; setAttribute(key: string, value: boolean | number | string): this;
setAttributes(attributes: Record<string, boolean | number | string | undefined>): this;
end(): void; end(): void;
} }
/** /**
@@ -4138,6 +4251,13 @@ type AiSearchListItemsParams = {
source?: string; source?: string;
/** JSON-encoded Vectorize filter for metadata filtering. */ /** JSON-encoded Vectorize filter for metadata filtering. */
metadata_filter?: string; metadata_filter?: string;
/** Filter items by their unique ID. Returns at most one item. */
item_id?: string;
/**
* Filter items by their exact key (object key / filename). Keys are unique
* per source, so combine with `source` to disambiguate across data sources.
*/
key?: string;
}; };
type AiSearchListItemsResponse = { type AiSearchListItemsResponse = {
result: AiSearchItemInfo[]; result: AiSearchItemInfo[];
@@ -10035,6 +10155,163 @@ declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT {
inputs: ChatCompletionsInput; inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput; postProcessedOutputs: ChatCompletionsOutput;
} }
declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_7_Code {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Zai_Org_Glm_5_2 {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
interface Ai_Cf_Moondream_Moondream3_1_9B_A2B_Input {
/**
* Which Moondream skill to run.
*/
task?: "query" | "caption" | "point" | "detect";
/**
* Input image as a public HTTPS URL or base64 data URI. Optional for `query`; required for `caption`, `point`, and `detect`.
*/
image?: string;
/**
* Question for the `query` task.
*/
question?: string;
/**
* Caption length for the `caption` task.
*/
caption_length?: "short" | "normal" | "long";
/**
* Object phrase to locate for `point` and `detect` tasks (e.g. 'person wearing a red shirt').
*/
target?: string;
/**
* Enable reasoning trace for the `query` task.
*/
reasoning?: boolean;
/**
* Sampling temperature.
*/
temperature?: number;
/**
* Top-p (nucleus) sampling.
*/
top_p?: number;
/**
* Max tokens to generate for `query` and `caption`.
*/
max_tokens?: number;
/**
* Max objects to return for `point` and `detect`.
*/
max_objects?: number;
/**
* Return incremental tokens for `query` and `caption`. `point` and `detect` do not support streaming.
*/
stream?: boolean;
}
interface Ai_Cf_Moondream_Moondream3_1_9B_A2B_Output {
/**
* Reason the generation finished.
*/
finish_reason: string;
metrics: {
/**
* Number of input tokens consumed.
*/
input_tokens: number;
/**
* Number of output tokens generated.
*/
output_tokens: number;
/**
* Prefill time in milliseconds.
*/
prefill_time_ms: number;
/**
* Decode time in milliseconds.
*/
decode_time_ms: number;
/**
* Time to first token in milliseconds.
*/
ttft_ms: number;
};
/**
* Answer text for the `query` task. Null for other tasks.
*/
answer?: string;
/**
* Caption text for the `caption` task. Null for other tasks.
*/
caption?: string;
/**
* Located points for the `point` task. Null for other tasks.
*/
points?: {
/**
* X coordinate.
*/
x: number;
/**
* Y coordinate.
*/
y: number;
}[];
/**
* Detected bounding boxes for the `detect` task. Null for other tasks.
*/
objects?: {
/**
* Minimum X coordinate.
*/
x_min: number;
/**
* Minimum Y coordinate.
*/
y_min: number;
/**
* Maximum X coordinate.
*/
x_max: number;
/**
* Maximum Y coordinate.
*/
y_max: number;
}[];
/**
* Reasoning trace for the `query` task when reasoning=true. Null otherwise.
*/
reasoning?: {
/**
* Reasoning text.
*/
text: string;
/**
* Grounding information.
*/
grounding?: {}[];
};
}
declare abstract class Base_Ai_Cf_Moondream_Moondream3_1_9B_A2B {
inputs: Ai_Cf_Moondream_Moondream3_1_9B_A2B_Input;
postProcessedOutputs: Ai_Cf_Moondream_Moondream3_1_9B_A2B_Output;
}
declare abstract class Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Flash_0731 {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Pro_0813 {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Qwen_Qwen3_8_27B {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Zai_Org_Glm_5_3_Flash {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
interface AiModels { interface AiModels {
"@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification; "@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification;
"@cf/stabilityai/stable-diffusion-xl-base-1.0": BaseAiTextToImage; "@cf/stabilityai/stable-diffusion-xl-base-1.0": BaseAiTextToImage;
@@ -10127,6 +10404,13 @@ interface AiModels {
"@cf/moonshotai/kimi-k2.6": Base_Ai_Cf_Moonshotai_Kimi_K2_6; "@cf/moonshotai/kimi-k2.6": Base_Ai_Cf_Moonshotai_Kimi_K2_6;
"@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B; "@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B;
"@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT; "@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT;
"@cf/moonshotai/kimi-k2.7-code": Base_Ai_Cf_Moonshotai_Kimi_K2_7_Code;
"@cf/zai-org/glm-5.2": Base_Ai_Cf_Zai_Org_Glm_5_2;
"@cf/moondream/moondream3.1-9B-A2B": Base_Ai_Cf_Moondream_Moondream3_1_9B_A2B;
"@cf/deepseek-ai/deepseek-v4-flash-0731": Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Flash_0731;
"@cf/deepseek-ai/deepseek-v4-pro-0813": Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Pro_0813;
"@cf/qwen/qwen3.8-27b": Base_Ai_Cf_Qwen_Qwen3_8_27B;
"@cf/zai-org/glm-5.3-flash": Base_Ai_Cf_Zai_Org_Glm_5_3_Flash;
} }
type AiOptions = { type AiOptions = {
/** /**
@@ -10793,6 +11077,15 @@ interface BrowserRunBaseOptions {
*/ */
cacheTTL?: number; cacheTTL?: number;
} }
/**
* Backend selection, mixed into the options of the quick actions that support it.
* Deliberately not part of `BrowserRunBaseOptions`: `scrape`, `links` and `snapshot`
* reject an alternate backend, so they must not accept the field.
*/
interface BrowserRunAlternateBackendOptions {
/** Render with an alternate browser backend instead of the default one. */
browser?: 'kitesurf';
}
/** Common options shared by all quick actions. Exactly one of `url` or `html` must be provided.*/ /** Common options shared by all quick actions. Exactly one of `url` or `html` must be provided.*/
type BrowserRunCommonOptions = (BrowserRunBaseOptions & { type BrowserRunCommonOptions = (BrowserRunBaseOptions & {
/** URL to navigate to, e.g. `"https://example.com"`. */ /** URL to navigate to, e.g. `"https://example.com"`. */
@@ -10827,7 +11120,7 @@ type BrowserRunScreenshotOptions = BrowserRunCommonOptions & {
scrollPage?: boolean; scrollPage?: boolean;
/** @see https://pptr.dev/api/puppeteer.screenshotoptions */ /** @see https://pptr.dev/api/puppeteer.screenshotoptions */
screenshotOptions?: BrowserRunPuppeteerScreenshotOptions; screenshotOptions?: BrowserRunPuppeteerScreenshotOptions;
}; } & BrowserRunAlternateBackendOptions;
type BrowserRunPDFOptions = BrowserRunCommonOptions & { type BrowserRunPDFOptions = BrowserRunCommonOptions & {
/** @see https://pptr.dev/api/puppeteer.pdfoptions */ /** @see https://pptr.dev/api/puppeteer.pdfoptions */
pdfOptions?: { pdfOptions?: {
@@ -10863,7 +11156,7 @@ type BrowserRunPDFOptions = BrowserRunCommonOptions & {
/** @default 30000 */ /** @default 30000 */
timeout?: number; timeout?: number;
}; };
}; } & BrowserRunAlternateBackendOptions;
type BrowserRunScrapeOptions = BrowserRunCommonOptions & { type BrowserRunScrapeOptions = BrowserRunCommonOptions & {
/** CSS selectors to scrape. At least one element is required. */ /** CSS selectors to scrape. At least one element is required. */
elements: Array<{ elements: Array<{
@@ -10876,10 +11169,29 @@ type BrowserRunLinksOptions = BrowserRunCommonOptions & {
/** When true, exclude links pointing to external domains. @default false */ /** When true, exclude links pointing to external domains. @default false */
excludeExternalLinks?: boolean; excludeExternalLinks?: boolean;
}; };
type BrowserRunSnapshotFormat = 'content' | 'screenshot' | 'markdown' | 'accessibilityTree';
type BrowserRunSnapshotOptions = BrowserRunCommonOptions & { type BrowserRunSnapshotOptions = BrowserRunCommonOptions & {
/** Which representations of the page to return. At least two distinct formats
* are required; request a single format from its dedicated action instead.
* @default ["content","screenshot"]
*/
formats?: BrowserRunSnapshotFormat[];
/** @see https://pptr.dev/api/puppeteer.screenshotoptions */ /** @see https://pptr.dev/api/puppeteer.screenshotoptions */
screenshotOptions?: Omit<BrowserRunPuppeteerScreenshotOptions, 'encoding'>; screenshotOptions?: Omit<BrowserRunPuppeteerScreenshotOptions, 'encoding'>;
}; };
/** Options for the `accessibilityTree` quick action. */
type BrowserRunAccessibilityTreeOptions = BrowserRunCommonOptions & {
/** When true, prune nodes that carry no semantic meaning, such as generic
* containers. Defaults to true, or to false when `root` is set so that the
* requested subtree is returned as-is.
*/
interestingOnly?: boolean;
/** CSS selector limiting the tree to the matching element's subtree.
* A selector that matches nothing yields `accessibilityTree: null` with
* HTTP 200; a malformed selector is an error.
*/
root?: string;
} & BrowserRunAlternateBackendOptions;
interface BrowserRunJsonBaseOptions { interface BrowserRunJsonBaseOptions {
/** Custom AI models to try in order. Max 3. Falls back to next on error. */ /** Custom AI models to try in order. Max 3. Falls back to next on error. */
custom_ai?: Array<{ custom_ai?: Array<{
@@ -10893,7 +11205,7 @@ interface BrowserRunJsonBaseOptions {
* Options for the `json` quick action. * Options for the `json` quick action.
* At least one of `prompt` or `response_format` must be provided. * At least one of `prompt` or `response_format` must be provided.
*/ */
type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunJsonBaseOptions & ({ type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunAlternateBackendOptions & BrowserRunJsonBaseOptions & ({
/** Natural-language prompt describing what data to extract. */ /** Natural-language prompt describing what data to extract. */
prompt: string; prompt: string;
/** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */ /** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */
@@ -10904,14 +11216,80 @@ type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunJsonBaseOptions
/** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */ /** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */
response_format: AiTextGenerationResponseFormat; response_format: AiTextGenerationResponseFormat;
}); });
type BrowserRunContentOptions = BrowserRunCommonOptions; type BrowserRunContentOptions = BrowserRunCommonOptions & BrowserRunAlternateBackendOptions;
type BrowserRunMarkdownOptions = BrowserRunCommonOptions; type BrowserRunMarkdownOptions = BrowserRunCommonOptions & BrowserRunAlternateBackendOptions;
type BrowserRunRedirectHop = {
/** URL that returned the redirect. */
url: string;
/** HTTP status of the redirect. */
status: number;
/** Redirect response headers, including `location`. */
headers: Record<string, string>;
};
type BrowserRunResponseMeta = { type BrowserRunResponseMeta = {
/** HTTP status code of the rendered page */ /** HTTP status code of the rendered page */
status: number; status: number;
/** Page title */ /** Page title */
title: string; title: string;
/** Origin response headers, lowercased. Repeated headers are joined with a newline. Credential and transport-only headers that do not survive rendering are omitted. */
headers?: Record<string, string>;
/** URL that served the response, after any redirects the browser followed. */
finalUrl?: string;
/** HTTP redirects followed to reach `finalUrl`, oldest first. Omitted for direct navigation and for client-side redirects such as meta refresh. An empty array means redirects occurred but their intermediate responses could not be read. */
redirectChain?: BrowserRunRedirectHop[];
}; };
/**
* A node in the page's accessibility tree, as exposed to assistive technology.
* `role` is the only field always present; the rest are populated when the
* underlying element defines them.
* @see https://pptr.dev/api/puppeteer.serializedaxnode
*/
interface BrowserRunSerializedAXNode {
/** The ARIA role, e.g. `"button"`, `"heading"`, `"RootWebArea"`. */
role: string;
/** The `aria-autocomplete` value. */
autocomplete?: string;
/** Checked state of a checkbox, radio, or menu item. */
checked?: boolean | 'mixed';
/** Accessible description, typically from `aria-describedby` or `title`. */
description?: string;
disabled?: boolean;
expanded?: boolean;
/** Whether the element currently holds keyboard focus. */
focused?: boolean;
/** The kind of popup the element triggers, e.g. `"menu"`, `"dialog"`. */
haspopup?: string;
/** The `aria-invalid` value. */
invalid?: string;
/** Keyboard shortcuts bound to the element, from `aria-keyshortcuts`. */
keyshortcuts?: string;
/** Hierarchical level, e.g. the heading level of an `<h2>`. */
level?: number;
/** Whether the element is a modal dialog. */
modal?: boolean;
/** Whether a text input accepts multiple lines. */
multiline?: boolean;
/** Whether more than one option can be selected. */
multiselectable?: boolean;
/** Accessible name, e.g. a button's label or an image's alt text. */
name?: string;
orientation?: string;
/** Pressed state of a toggle button. */
pressed?: boolean | 'mixed';
readonly?: boolean;
required?: boolean;
/** Author-supplied role description, from `aria-roledescription`. */
roledescription?: string;
selected?: boolean;
/** Current value of an input or range element. */
value?: string | number;
valuemax?: number;
valuemin?: number;
/** Human-readable form of `value`, from `aria-valuetext`. */
valuetext?: string;
/** Child nodes. Absent for leaf nodes. */
children?: BrowserRunSerializedAXNode[];
}
/** Success response for `content` action. */ /** Success response for `content` action. */
type BrowserRunContentSuccessResponse = { type BrowserRunContentSuccessResponse = {
success: true; success: true;
@@ -10924,6 +11302,7 @@ type BrowserRunLinksSuccessResponse = {
success: true; success: true;
/** Extracted links */ /** Extracted links */
result: string[]; result: string[];
meta: BrowserRunResponseMeta;
}; };
/** Success response for `scrape` action. */ /** Success response for `scrape` action. */
type BrowserRunScrapeSuccessResponse = { type BrowserRunScrapeSuccessResponse = {
@@ -10954,15 +11333,33 @@ type BrowserRunScrapeSuccessResponse = {
}>; }>;
}>; }>;
}>; }>;
meta: BrowserRunResponseMeta;
}; };
/** Success response for `snapshot` action. */ /** Success response for `snapshot` action. Each field is present only when the
* corresponding entry was requested in `formats`.
*/
type BrowserRunSnapshotSuccessResponse = { type BrowserRunSnapshotSuccessResponse = {
success: true; success: true;
result: { result: {
/** HTML content of the page. */ /** HTML content of the page. */
content: string; content?: string;
/** Base64-encoded screenshot image. */ /** Base64-encoded screenshot image. */
screenshot: string; screenshot?: string;
/** Markdown content. Prefixed with YAML frontmatter (e.g. `title`) when the
* page provides that metadata.
*/
markdown?: string;
/** Root of the page's accessibility tree. */
accessibilityTree?: BrowserRunSerializedAXNode;
};
meta: BrowserRunResponseMeta;
};
/** Success response for `accessibilityTree` action. */
type BrowserRunAccessibilityTreeSuccessResponse = {
success: true;
result: {
/** Root of the accessibility tree, or `null` when `root` matched no element. */
accessibilityTree: BrowserRunSerializedAXNode | null;
}; };
meta: BrowserRunResponseMeta; meta: BrowserRunResponseMeta;
}; };
@@ -10971,12 +11368,14 @@ type BrowserRunJsonSuccessResponse = {
success: true; success: true;
/** JSON data extracted from the page using an AI model */ /** JSON data extracted from the page using an AI model */
result: Record<string, unknown>; result: Record<string, unknown>;
meta: BrowserRunResponseMeta;
}; };
/** Success response for `markdown` action. */ /** Success response for `markdown` action. */
type BrowserRunMarkdownSuccessResponse = { type BrowserRunMarkdownSuccessResponse = {
success: true; success: true;
/** Extracted markdown content */ /** Extracted markdown content */
result: string; result: string;
meta: BrowserRunResponseMeta;
}; };
/** Error response for BrowserRun actions. */ /** Error response for BrowserRun actions. */
type BrowserRunErrorResponse = { type BrowserRunErrorResponse = {
@@ -11086,9 +11485,10 @@ declare abstract class BrowserRun {
*/ */
quickAction(action: 'links', options: BrowserRunLinksOptions): Promise<Response>; quickAction(action: 'links', options: BrowserRunLinksOptions): Promise<Response>;
/** /**
* Get both the HTML content and a base64-encoded screenshot of a web page. * Get several representations of a web page in one request.
* @param action - Must be `'snapshot'`. * @param action - Must be `'snapshot'`.
* @param options - Snapshot options including screenshot settings (encoding is always base64). * @param options - Snapshot options including the `formats` to return and
* screenshot settings (encoding is always base64).
* @returns A `Response` containing one of: * @returns A `Response` containing one of:
* *
* **Success (HTTP 200):** * **Success (HTTP 200):**
@@ -11136,6 +11536,26 @@ declare abstract class BrowserRun {
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500) * - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/ */
quickAction(action: 'markdown', options: BrowserRunMarkdownOptions): Promise<Response>; quickAction(action: 'markdown', options: BrowserRunMarkdownOptions): Promise<Response>;
/**
* Get the accessibility tree of a web page.
* @param action - Must be `'accessibilityTree'`.
* @param options - Options to scope the tree to a subtree and to control
* whether semantically uninteresting nodes are pruned.
* @returns A `Response` containing one of:
*
* **Success (HTTP 200):**
* - `BrowserRunAccessibilityTreeSuccessResponse` JSON with `Content-Type: application/json`
* - `result.accessibilityTree` is `null` when `root` matched no element
*
* **Error:**
* - `BrowserRunErrorResponse` JSON with appropriate HTTP status code (400, 422, 429, 500, 503)
* - HTTP 422 for a malformed `root` selector
* - HTTP 500 with code `2017` or `2018` when the tree could not be built
*
* **Headers:**
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/
quickAction(action: 'accessibilityTree', options: BrowserRunAccessibilityTreeOptions): Promise<Response>;
} }
/** /**
* In addition to the properties you can set in the RequestInit dict * In addition to the properties you can set in the RequestInit dict
@@ -12206,6 +12626,13 @@ interface ForwardableEmailMessage extends EmailMessage {
* @returns A promise that resolves when the email message is replied. * @returns A promise that resolves when the email message is replied.
*/ */
reply(message: EmailMessage): Promise<EmailSendResult>; reply(message: EmailMessage): Promise<EmailSendResult>;
/**
* Reply to the sender of this email message with a message built from the given
* fields. Threading headers (In-Reply-To/References) are set automatically.
* @param builder The reply message contents.
* @returns A promise that resolves when the email message is replied.
*/
reply(builder: EmailReplyMessageBuilder): Promise<EmailSendResult>;
} }
/** A file attachment for an email message */ /** A file attachment for an email message */
type EmailAttachment = { type EmailAttachment = {
@@ -12226,23 +12653,46 @@ interface EmailAddress {
name: string; name: string;
email: string; email: string;
} }
/**
* Recipient fields for `SendEmail.send()`. At least one of `to`, `cc`, or
* `bcc` must be provided.
*/
type EmailDestinations = {
to?: string | EmailAddress | (string | EmailAddress)[];
cc?: string | EmailAddress | (string | EmailAddress)[];
bcc?: string | EmailAddress | (string | EmailAddress)[];
} & ({
to: string | EmailAddress | (string | EmailAddress)[];
} | {
cc: string | EmailAddress | (string | EmailAddress)[];
} | {
bcc: string | EmailAddress | (string | EmailAddress)[];
});
/**
* Fields shared by all composed emails (no recipients). Used directly by
* `ForwardableEmailMessage.reply()`, which always replies to the original
* sender, and extended by `EmailMessageBuilder` for `SendEmail.send()`.
*/
interface EmailReplyMessageBuilder {
from: string | EmailAddress;
subject: string;
replyTo?: string | EmailAddress;
headers?: Record<string, string>;
text?: string;
html?: string;
attachments?: EmailAttachment[];
}
/**
* Fields for composing an email without constructing raw MIME, for
* `SendEmail.send()`. Requires at least one of `to`, `cc`, or `bcc`.
*/
type EmailMessageBuilder = EmailReplyMessageBuilder & EmailDestinations;
/** /**
* A binding that allows a Worker to send email messages. * A binding that allows a Worker to send email messages.
*/ */
interface SendEmail { interface SendEmail {
send(message: EmailMessage): Promise<EmailSendResult>; send(message: EmailMessage): Promise<EmailSendResult>;
send(builder: { send(builder: EmailMessageBuilder): Promise<EmailSendResult>;
from: string | EmailAddress;
to: string | EmailAddress | (string | EmailAddress)[];
subject: string;
replyTo?: string | EmailAddress;
cc?: string | EmailAddress | (string | EmailAddress)[];
bcc?: string | EmailAddress | (string | EmailAddress)[];
headers?: Record<string, string>;
text?: string;
html?: string;
attachments?: EmailAttachment[];
}): Promise<EmailSendResult>;
} }
declare abstract class EmailEvent extends ExtendableEvent { declare abstract class EmailEvent extends ExtendableEvent {
readonly message: ForwardableEmailMessage; readonly message: ForwardableEmailMessage;
@@ -12396,6 +12846,15 @@ interface Hyperdrive {
* for your database. * for your database.
*/ */
readonly host: string; readonly host: string;
/*
* A synthetic IPv4 address (in the reserved 240.0.0.0/4 range) that, like the
* host field, is only valid within the context of the currently running
* Worker and, when passed into the `connect()` function from the
* "cloudflare:sockets" module, will connect to the Hyperdrive instance for
* your database. This is provided for database drivers that require the host
* to be an IP literal rather than a hostname.
*/
readonly ip: string;
/* /*
* The port that must be paired the the host field when connecting. * The port that must be paired the the host field when connecting.
*/ */
@@ -12417,6 +12876,78 @@ interface Hyperdrive {
*/ */
readonly database: string; readonly database: string;
} }
/**
* A handle to a dynamically-provisioned Hyperdrive connection, returned by
* `HyperdriveApi.get()`.
*/
interface HyperdriveDynamic extends Disposable {
/**
* The database name to use when connecting through this Hyperdrive.
*/
readonly database: Promise<string>;
/*
* The randomly generated user to use when authenticating to your
* database via Hyperdrive.
*/
readonly user: Promise<string>;
/*
* The randomly generated password to use when authenticating to your
* database via Hyperdrive.
*/
readonly password: Promise<string>;
/**
* Open a TCP socket to the target database through this Hyperdrive.
*
*/
connect(): Promise<Socket>;
}
/**
* Binding that provisions Hyperdrive connections at request time, rather than
* from static configuration.
*/
interface HyperdriveDynamicApi {
/**
* Provision a connection for the database described by `args`.
*
*/
get(args: HyperdriveDynamicConfig): Promise<HyperdriveDynamic>;
/**
* Get a pre-generated connection string used for connecting to dynamic Hyperdrive.
*/
getHyperdriveConnectionString(connectionString: string): Promise<string>;
}
/**
* Parameters identifying the database that a dynamically-provisioned
* Hyperdrive connection should target.
*/
interface HyperdriveDynamicConfig {
/**
* Generated connection string to pass into the dynamic worker.
*
*/
dynamicHyperdriveConnectionString: string;
/**
* Connection string for the origin database Hyperdrive should connect to.
* Contains credentials, so treat it as a secret.
*
* The scheme selects the database engine. PostgreSQL origins are supported.
*/
connectionString: string;
/**
* Region in which to place the connection pool. See the Hyperdrive
* documentation for the set of supported regions.
*/
targetRegion: string;
/**
* Whether Hyperdrive should cache query results for this connection.
*/
cachingEnabled?: boolean;
/**
* Maximum number of connections the pool may open to the origin database.
* Defaults to 60.
*/
maxConnections?: number;
}
// Copyright (c) 2024 Cloudflare, Inc. // Copyright (c) 2024 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at: // Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0 // https://opensource.org/licenses/Apache-2.0
@@ -12428,6 +12959,27 @@ type ImageInfoResponse = {
width: number; width: number;
height: number; height: number;
}; };
/**
* Parameters for rasterizing text into an image.
*/
type TextRasterize = {
/** The text content to render */
content: string;
/** rasterization options for the text **/
options: TextOptions;
};
type TextOptions = {
/** Font configuration */
font: {
/** URL to a font file in TrueType (.ttf), OpenType (.otf), WOFF (.woff), or WOFF2 (.woff2) format */
url: string;
};
/** Font size in points (pt) */
size?: number;
/** Text color in CSS format: hex (#RRGGBB or #RRGGBBAA), rgb(r,g,b), rgba(r,g,b,a), or named colors */
color?: string;
};
type ImageSource = ReadableStream<Uint8Array> | TextRasterize;
type ImageTransform = { type ImageTransform = {
width?: number; width?: number;
height?: number; height?: number;
@@ -12517,6 +13069,9 @@ interface ImageUploadOptions {
requireSignedURLs?: boolean; requireSignedURLs?: boolean;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
creator?: string; creator?: string;
/**
* If 'base64', the input data will be decoded from base64 before processing
*/
encoding?: 'base64'; encoding?: 'base64';
} }
interface ImageUpdateOptions { interface ImageUpdateOptions {
@@ -12524,11 +13079,40 @@ interface ImageUpdateOptions {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
creator?: string; creator?: string;
} }
type ImageMetadataFilterOperators = {
eq?: string | number | boolean;
in?: string[] | number[];
gt?: number;
gte?: number;
lt?: number;
lte?: number;
};
type ImageMetadataFilterValue = string | number | boolean | ImageMetadataFilterOperators;
interface ImageListFilter {
metadata?: Record<string, ImageMetadataFilterValue>;
}
interface ImageListOptions { interface ImageListOptions {
limit?: number; limit?: number;
cursor?: string; cursor?: string;
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
creator?: string; creator?: string;
filter?: ImageListFilter;
}
interface ImageSignedUrlOptions {
variant: string;
expiresIn?: number;
keyName?: string;
}
interface ImageDirectUploadOptions {
id?: string;
requireSignedURLs?: boolean;
metadata?: Record<string, unknown>;
creator?: string;
expiresIn?: number;
}
interface ImageDirectUploadResult {
id: string;
uploadURL: string;
} }
interface ImageList { interface ImageList {
images: ImageMetadata[]; images: ImageMetadata[];
@@ -12546,6 +13130,13 @@ interface ImageHandle {
* @returns ReadableStream of image bytes, or null if not found * @returns ReadableStream of image bytes, or null if not found
*/ */
bytes(): Promise<ReadableStream<Uint8Array> | null>; bytes(): Promise<ReadableStream<Uint8Array> | null>;
/**
* Generate a signed delivery URL for this hosted image.
* @param options Signing configuration
* @returns A signed image delivery URL
* @throws {@link ImagesError} if signing fails
*/
signedUrl(options: ImageSignedUrlOptions): Promise<string>;
/** /**
* Update hosted image metadata * Update hosted image metadata
* @param options Properties to update * @param options Properties to update
@@ -12581,6 +13172,14 @@ interface HostedImagesBinding {
* @throws {@link ImagesError} if list fails * @throws {@link ImagesError} if list fails
*/ */
list(options?: ImageListOptions): Promise<ImageList>; list(options?: ImageListOptions): Promise<ImageList>;
/**
* Create a Direct Creator Upload link, letting an end user upload an
* image straight to Cloudflare without exposing an API token
* @param options Upload link configuration
* @returns The new image ID and the upload URL to hand to the end user
* @throws {@link ImagesError} if creation fails
*/
createDirectUpload(options?: ImageDirectUploadOptions): Promise<ImageDirectUploadResult>;
} }
interface ImagesBinding { interface ImagesBinding {
/** /**
@@ -12595,6 +13194,13 @@ interface ImagesBinding {
* @returns A transform handle * @returns A transform handle
*/ */
input(stream: ReadableStream<Uint8Array>, options?: ImageInputOptions): ImageTransformer; input(stream: ReadableStream<Uint8Array>, options?: ImageInputOptions): ImageTransformer;
/**
* Begin applying a series of transformations to text
* @param content string to be rendered
* @param options font, optional color and size to use in rendering text
* @returns A transform handle
*/
text(content: string, options: TextOptions): ImageTransformer;
/** /**
* Access hosted images CRUD operations * Access hosted images CRUD operations
*/ */
@@ -12624,11 +13230,15 @@ interface ImageTransformer {
type ImageTransformationOutputOptions = { type ImageTransformationOutputOptions = {
encoding?: 'base64'; encoding?: 'base64';
}; };
type ImageTransformationResponseOptions = {
headers?: HeadersInit;
};
interface ImageTransformationResult { interface ImageTransformationResult {
/** /**
* The image as a response, ready to store in cache or return to users * The image as a response, ready to store in cache or return to users
* @param options Options that apply to the returned response, e.g. additional headers
*/ */
response(): Response; response(options?: ImageTransformationResponseOptions): Response;
/** /**
* The content type of the returned image * The content type of the returned image
*/ */
@@ -13064,6 +13674,11 @@ declare namespace CloudflareWorkersModule {
export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number;
export type WorkflowDelayDuration = WorkflowSleepDuration; export type WorkflowDelayDuration = WorkflowSleepDuration;
export type WorkflowDynamicDelayContext = {
ctx: WorkflowStepContext<WorkflowDelayFunction>;
error: Error;
};
export type WorkflowDelayFunction = (input: WorkflowDynamicDelayContext) => WorkflowDelayDuration | Promise<WorkflowDelayDuration>;
export type WorkflowTimeoutDuration = WorkflowSleepDuration; export type WorkflowTimeoutDuration = WorkflowSleepDuration;
export type WorkflowRetentionDuration = WorkflowSleepDuration; export type WorkflowRetentionDuration = WorkflowSleepDuration;
export type WorkflowBackoff = 'constant' | 'linear' | 'exponential'; export type WorkflowBackoff = 'constant' | 'linear' | 'exponential';
@@ -13071,12 +13686,32 @@ declare namespace CloudflareWorkersModule {
export type WorkflowStepConfig = { export type WorkflowStepConfig = {
retries?: { retries?: {
limit: number; limit: number;
delay: WorkflowDelayDuration | number; delay: WorkflowDelayDuration | number | WorkflowDelayFunction;
backoff?: WorkflowBackoff; backoff?: WorkflowBackoff;
}; };
timeout?: WorkflowTimeoutDuration | number; timeout?: WorkflowTimeoutDuration | number;
sensitive?: WorkflowStepSensitivity; sensitive?: WorkflowStepSensitivity;
}; };
// Internal discriminators used only for `WorkflowStep.do` overload
// resolution. They mirror `WorkflowStepConfig` but pin `retries.delay` to a
// single kind so the callback context can be narrowed based on the shape of
// the config argument (rather than on an inferred type parameter, which is
// lost when the caller supplies an explicit return-type argument). Not
// exported: they must not widen the public type surface.
type WorkflowStepConfigWithStaticDelay = Omit<WorkflowStepConfig, 'retries'> & {
retries?: {
limit: number;
delay: WorkflowDelayDuration | number;
backoff?: WorkflowBackoff;
};
};
type WorkflowStepConfigWithDelayFunction = Omit<WorkflowStepConfig, 'retries'> & {
retries: {
limit: number;
delay: WorkflowDelayFunction;
backoff?: WorkflowBackoff;
};
};
export type WorkflowStepRollbackConfig = Pick<WorkflowStepConfig, 'retries' | 'timeout'>; export type WorkflowStepRollbackConfig = Pick<WorkflowStepConfig, 'retries' | 'timeout'>;
export type WorkflowCronSchedule = { export type WorkflowCronSchedule = {
/** Cron expression that triggered this event. */ /** Cron expression that triggered this event. */
@@ -13097,28 +13732,51 @@ declare namespace CloudflareWorkersModule {
type: string; type: string;
sensitive?: WorkflowStepSensitivity; sensitive?: WorkflowStepSensitivity;
}; };
export type WorkflowStepContext = { export type WorkflowStepContext<Delay = WorkflowDelayDuration | number> = {
step: { step: {
name: string; name: string;
count: number; count: number;
}; };
attempt: number; attempt: number;
config: WorkflowStepConfig; config: {
retries?: {
limit: number;
backoff?: WorkflowBackoff;
} & (Delay extends WorkflowDelayFunction ? {} : {
delay: WorkflowDelayDuration | number;
});
timeout?: WorkflowTimeoutDuration | number;
sensitive?: WorkflowStepSensitivity;
};
}; };
export type WorkflowRollbackContext<T = unknown> = { // The rollback handler receives the step context, so it mirrors the same
ctx: WorkflowStepContext; // delay discriminant as the step callback: when the step was configured with
// a dynamic delay function the resolved `config.retries.delay` is omitted,
// otherwise it is present. `Delay` is threaded from the `WorkflowStep.do`
// overload that matched the step config.
export type WorkflowRollbackContext<T = unknown, Delay = WorkflowDelayDuration | number> = {
ctx: WorkflowStepContext<Delay>;
error: Error; error: Error;
output: T | undefined; output: T | undefined;
/** @deprecated Use `ctx.step.name` and `ctx.step.count` instead. */ /** @deprecated Use `ctx.step.name` and `ctx.step.count` instead. */
stepName: string; stepName: string;
}; };
export type WorkflowRollbackHandler<T = unknown> = (ctx: WorkflowRollbackContext<T>) => Promise<void>; export type WorkflowRollbackHandler<T = unknown, Delay = WorkflowDelayDuration | number> = (ctx: WorkflowRollbackContext<T, Delay>) => Promise<void>;
export type WorkflowStepRollbackOptions<T = unknown> = { export type WorkflowStepRollbackOptions<T = unknown, Delay = WorkflowDelayDuration | number> = {
rollback: WorkflowRollbackHandler<T>; rollback: WorkflowRollbackHandler<T, Delay>;
rollbackConfig?: WorkflowStepRollbackConfig; rollbackConfig?: WorkflowStepRollbackConfig;
}; };
export abstract class WorkflowStep { export abstract class WorkflowStep {
do<T extends Rpc.Serializable<T>>(name: string, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>; do<T extends Rpc.Serializable<T>>(name: string, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>;
// The config overloads discriminate on the shape of `config.retries.delay`
// so the callback context reflects whether the resolved delay is present
// (static delay) or omitted (dynamic delay function). Each has a single
// type parameter, so an explicit return-type argument (`do<T>(...)`) still
// resolves here. ORDERING IS LOAD-BEARING: the broad `WorkflowStepConfig`
// fallback MUST remain last, otherwise it shadows the discriminating
// overloads and narrowing is silently lost.
do<T extends Rpc.Serializable<T>>(name: string, config: WorkflowStepConfigWithDelayFunction, callback: (ctx: WorkflowStepContext<WorkflowDelayFunction>) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T, WorkflowDelayFunction>): Promise<T>;
do<T extends Rpc.Serializable<T>>(name: string, config: WorkflowStepConfigWithStaticDelay, callback: (ctx: WorkflowStepContext<WorkflowDelayDuration | number>) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T, WorkflowDelayDuration | number>): Promise<T>;
do<T extends Rpc.Serializable<T>>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>; do<T extends Rpc.Serializable<T>>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>;
sleep: (name: string, duration: WorkflowSleepDuration) => Promise<void>; sleep: (name: string, duration: WorkflowSleepDuration) => Promise<void>;
sleepUntil: (name: string, timestamp: Date | number) => Promise<void>; sleepUntil: (name: string, timestamp: Date | number) => Promise<void>;
@@ -13894,11 +14552,12 @@ type MarkdownDocument = {
name: string; name: string;
blob: Blob; blob: Blob;
}; };
type OutputFormat = 'markdown' | 'text';
type ConversionResponse = { type ConversionResponse = {
id: string; id: string;
name: string; name: string;
mimeType: string; mimeType: string;
format: 'markdown'; format: OutputFormat;
tokens: number; tokens: number;
data: string; data: string;
} | { } | {
@@ -13915,7 +14574,11 @@ type EmbeddedImageConversionOptions = ImageConversionOptions & {
convert?: boolean; convert?: boolean;
maxConvertedImages?: number; maxConvertedImages?: number;
}; };
type ConversionOutputOptions = {
format?: OutputFormat;
};
type ConversionOptions = { type ConversionOptions = {
output?: ConversionOutputOptions;
html?: { html?: {
images?: EmbeddedImageConversionOptions & { images?: EmbeddedImageConversionOptions & {
convertOGImage?: boolean; convertOGImage?: boolean;
@@ -14010,7 +14673,7 @@ declare namespace TailStream {
interface ConnectEventInfo { interface ConnectEventInfo {
readonly type: "connect"; readonly type: "connect";
} }
type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime"; type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime" | "aborted";
interface ScriptVersion { interface ScriptVersion {
readonly id: string; readonly id: string;
readonly tag?: string; readonly tag?: string;
@@ -14029,6 +14692,7 @@ declare namespace TailStream {
readonly dispatchNamespace?: string; readonly dispatchNamespace?: string;
readonly entrypoint?: string; readonly entrypoint?: string;
readonly executionModel: string; readonly executionModel: string;
readonly durableObjectId?: string;
readonly scriptName?: string; readonly scriptName?: string;
readonly scriptTags?: string[]; readonly scriptTags?: string[];
readonly scriptVersion?: ScriptVersion; readonly scriptVersion?: ScriptVersion;
@@ -14063,11 +14727,22 @@ declare namespace TailStream {
readonly message: string; readonly message: string;
readonly stack?: string; readonly stack?: string;
} }
interface Log { interface TailStreamErrorInfo {
readonly name: string;
readonly message: string;
readonly stack?: string;
}
type Log = {
readonly type: "log"; readonly type: "log";
readonly level: "debug" | "error" | "info" | "log" | "warn"; readonly level: "debug" | "error" | "info" | "log" | "warn";
readonly errorInfo?: readonly (TailStreamErrorInfo | null)[];
} & ({
readonly message: object; readonly message: object;
} readonly truncated?: false;
} | {
readonly message: string;
readonly truncated: true;
});
interface DroppedEventsDiagnostic { interface DroppedEventsDiagnostic {
readonly diagnosticsType: "droppedEvents"; readonly diagnosticsType: "droppedEvents";
readonly count: number; readonly count: number;
@@ -14545,10 +15220,31 @@ declare abstract class Workflow<PARAMS = unknown> {
* @returns A promise that resolves with a list of handles for the created instances. * @returns A promise that resolves with a list of handles for the created instances.
*/ */
public createBatch(batch: WorkflowInstanceCreateOptions<PARAMS>[]): Promise<WorkflowInstance[]>; public createBatch(batch: WorkflowInstanceCreateOptions<PARAMS>[]): Promise<WorkflowInstance[]>;
/**
* Delete a batch of Workflow instances and their stored state.
* `deleteBatch` is limited to 100 instances at a time. Duplicate IDs are deleted once.
* The result contains one entry for each input position; IDs that do not exist are returned as per-instance errors.
* @param instanceIds IDs of the Workflow instances to delete
* @returns A promise that resolves with the successfully deleted instances and any per-instance errors.
*/
public deleteBatch(instanceIds: string[]): Promise<WorkflowBatchDeleteResult>;
} }
type WorkflowBatchDeleteResult = {
deleted: {
id: string;
}[];
errors: {
id: string;
code: number;
message: string;
}[];
};
type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number;
type WorkflowRetentionDuration = WorkflowSleepDuration; type WorkflowRetentionDuration = WorkflowSleepDuration;
/** Geographic regions supported when creating a Workflow instance.
* Location hints are best-effort placement preferences. */
type WorkflowInstanceLocationHint = 'wnam' | 'enam' | 'sam' | 'weur' | 'eeur' | 'apac' | 'apac-ne' | 'apac-se' | 'oc' | 'afr' | 'me';
interface WorkflowInstanceCreateOptions<PARAMS = unknown> { interface WorkflowInstanceCreateOptions<PARAMS = unknown> {
/** /**
* An id for your Workflow instance. Must be unique within the Workflow. * An id for your Workflow instance. Must be unique within the Workflow.
@@ -14566,6 +15262,9 @@ interface WorkflowInstanceCreateOptions<PARAMS = unknown> {
successRetention?: WorkflowRetentionDuration; successRetention?: WorkflowRetentionDuration;
errorRetention?: WorkflowRetentionDuration; errorRetention?: WorkflowRetentionDuration;
}; };
/** A best-effort geographic placement preference for the Workflow instance.
* See `WorkflowInstanceLocationHint` for supported regions. */
locationHint?: WorkflowInstanceLocationHint;
} }
type InstanceStatus = { type InstanceStatus = {
status: 'queued' // means that instance is waiting to be started (see concurrency limits) status: 'queued' // means that instance is waiting to be started (see concurrency limits)
@@ -14583,6 +15282,13 @@ interface WorkflowError {
code?: number; code?: number;
message: string; message: string;
} }
interface WorkflowInstanceTerminateOptions {
/**
* If true, run registered rollback handlers before terminating the instance.
* Only steps that registered rollback handlers are rolled back.
*/
rollback?: boolean;
}
interface WorkflowInstanceRestartOptions { interface WorkflowInstanceRestartOptions {
/** /**
* Restart from a specific step. If omitted, the instance restarts from the beginning. * Restart from a specific step. If omitted, the instance restarts from the beginning.
@@ -14616,14 +15322,19 @@ declare abstract class WorkflowInstance {
public resume(): Promise<void>; public resume(): Promise<void>;
/** /**
* Terminate the instance. If it is errored, terminated or complete, an error will be thrown. * Terminate the instance. If it is errored, terminated or complete, an error will be thrown.
* @param options Options for termination, including whether registered rollback handlers should run.
*/ */
public terminate(): Promise<void>; public terminate(options?: WorkflowInstanceTerminateOptions): Promise<void>;
/** /**
* Restart the instance. Optionally restart from a specific step, preserving * Restart the instance. Optionally restart from a specific step, preserving
* cached results for all steps before it. * cached results for all steps before it.
* @param options Options for the restart, including an optional step to restart from. * @param options Options for the restart, including an optional step to restart from.
*/ */
public restart(options?: WorkflowInstanceRestartOptions): Promise<void>; public restart(options?: WorkflowInstanceRestartOptions): Promise<void>;
/**
* Delete the instance and its stored state.
*/
public delete(): Promise<void>;
/** /**
* Returns the current status of the instance. * Returns the current status of the instance.
*/ */
+14 -1
View File
@@ -37,12 +37,25 @@
"secret_name": "JWT_SECRET" "secret_name": "JWT_SECRET"
} }
], ],
// Cloudflare Email Sending, for the code that confirms an email change
// (POST /account/me/email). Nothing to splice in: the binding has no id, but the `from`
// domain (EMAIL_FROM, default no-reply@<DOMAIN>) has to be enabled for Email Sending once
// per account — see DEPLOYING.md. Locally (and under vitest) the binding logs the message.
"send_email": [
{
"name": "EMAIL"
}
],
"upload_source_maps": true, "upload_source_maps": true,
"observability": { "observability": {
"enabled": false "enabled": false
}, },
"vars": { "vars": {
"ENVIRONMENT": "development", // overridden during deployment "ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment "SENTRY_RELEASE": "unknown", // overridden during deployment
// Overridden at deploy time, same as `auth`. The confirmation email's link is built
// from these (`https://www.<DOMAIN>/confirm-email`).
"DOMAIN": "rec.example.com",
"SUBDOMAINS": "{}"
} }
} }
@@ -0,0 +1,18 @@
-- Index the room-badge lookup that hydrates every room list (`attachBadges` in
-- packages/domain/src/rooms-db.ts). Every room page — feeds, search, the owned list, a single
-- room read — asks this table for the published badges of the rooms on the page, and without an
-- index that was a full scan of every custom avatar item (thousands of first-party rows) per
-- page. At the time of writing it was half of the database's row reads.
--
-- PARTIAL, over only the badge rows (`OutfitType` 1000), keyed by the badge's room. SQLite uses
-- a partial index only when the query's WHERE contains its term as written, and an expression
-- index only when the query names the same expression, so the query must keep BOTH
-- `outfit_type = 1000` and `json_extract(data, '$.CustomBadgeMetadata.RoomId')` exactly —
-- change either and the index silently stops being used. The key is an expression rather than
-- a new generated column so the query reads the same whether or not this migration has run yet
-- (the `rooms` worker can deploy ahead of `api`'s migrations).
-- Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room
ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId'))
WHERE outfit_type = 1000;
+2
View File
@@ -6,6 +6,7 @@ import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo
import { accountRoutes } from './routes/account' import { accountRoutes } from './routes/account'
import { avatarRoutes } from './routes/avatar' import { avatarRoutes } from './routes/avatar'
import { avatarStudioRoutes } from './routes/avatar-studio'
import { configRoutes } from './routes/config' import { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events' import { eventRoutes } from './routes/events'
import { gameplayRoutes } from './routes/gameplay' import { gameplayRoutes } from './routes/gameplay'
@@ -56,6 +57,7 @@ const app = new Hono<App>({ strict: false })
.route('/', configRoutes) .route('/', configRoutes)
.route('/', socialRoutes) .route('/', socialRoutes)
.route('/', progressionRoutes) .route('/', progressionRoutes)
.route('/', avatarStudioRoutes)
.route('/', avatarRoutes) .route('/', avatarRoutes)
.route('/', gameplayRoutes) .route('/', gameplayRoutes)
.route('/', eventRoutes) .route('/', eventRoutes)
+9 -1
View File
@@ -29,7 +29,7 @@
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load' import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */ /** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql and 0035_custom_avatar_item_badge_room.sql). */
export const SCHEMA_DDL: string[] = [ export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS custom_avatar_item ( `CREATE TABLE IF NOT EXISTS custom_avatar_item (
data TEXT NOT NULL, data TEXT NOT NULL,
@@ -45,6 +45,9 @@ export const SCHEMA_DDL: string[] = [
)`, )`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`, `CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
// The room-badge lookup (`attachBadges`, packages/domain/src/rooms-db.ts) runs on every room
// page; partial over the badge rows, keyed by the badge's room (migrations/0035).
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId')) WHERE outfit_type = 1000`,
] ]
/** /**
@@ -72,6 +75,9 @@ export interface CustomAvatarItemSave {
UnityAssetHash: string UnityAssetHash: string
UnityAsset2: string | null UnityAsset2: string | null
UnityAsset2Hash: string | null UnityAsset2Hash: string | null
/** Studio builds persist separate target hashes; legacy imports omit these. */
QuestUnityAssetHash?: string | null
QuestUnityAsset2Hash?: string | null
} }
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */ /** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
@@ -356,6 +362,8 @@ export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarIte
...save, ...save,
UnityAsset: questAsset(save.UnityAsset), UnityAsset: questAsset(save.UnityAsset),
UnityAsset2: questAsset(save.UnityAsset2), UnityAsset2: questAsset(save.UnityAsset2),
UnityAssetHash: save.QuestUnityAssetHash ?? save.UnityAssetHash,
UnityAsset2Hash: save.QuestUnityAsset2Hash ?? save.UnityAsset2Hash,
})), })),
} }
} }
+390
View File
@@ -0,0 +1,390 @@
import { Hono } from 'hono'
import {
AVATAR_STUDIO_SLOTS,
avatarItemAccessBit,
avatarSaveDto,
avatarStudioFlags,
avatarStudioItem,
avatarStudioSave,
canManageRoomById,
publishAvatarSave,
} from '@repo/domain'
import { parseMultipart } from '../../../studio/src/multipart'
import { getCustomAvatarItem, toQuestCustomAvatarItem } from '../custom-avatar-items-db'
import { authedId, authedRoles } from '../http'
import type { Context } from 'hono'
import type { AvatarStudioSave } from '@repo/domain'
import type { App } from '../context'
const uuid = '[0-9a-fA-F-]{36}'
const ok = (value: unknown) => ({ success: true, error: null, value })
const fail = (error: string) => ({ success: false, error, value: null })
const slots = new Set(AVATAR_STUDIO_SLOTS)
function validTags(value: unknown): boolean {
return (
value == null ||
(Array.isArray(value) &&
value.length <= 64 &&
value.every(
(t) =>
t &&
Number.isInteger(t.TagType) &&
t.TagType >= 0 &&
t.TagType <= 255 &&
typeof t.Value === 'string' &&
t.Value.length > 0 &&
t.Value.length <= 64
))
)
}
async function allowed(c: Context<App>, accountId: number, slot: number) {
const roles = await authedRoles(c)
return (
roles?.includes('betastudio') &&
(![104, 203].includes(slot) || roles.includes('developer')) &&
((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(slot)) !== 0
)
}
const safeName = (value: unknown): value is string =>
typeof value === 'string' &&
/^[a-zA-Z0-9_./-]+$/.test(value) &&
!value.split('/').some((p) => p === '..' || p === '.' || p === '')
export const avatarStudioRoutes = new Hono<App>()
.get('/api/customAvatarItems/v1/me', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const rows = await c.env.DB.prepare(
'SELECT data FROM custom_avatar_item WHERE creator_account_id=? ORDER BY created_at DESC,custom_avatar_item_id'
)
.bind(id)
.all<{ data: string }>()
const all = rows.results.map((r) => JSON.parse(r.data))
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
return c.json({ Results: all.slice(skip, skip + take), TotalResults: all.length })
})
.get(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c) => {
const item = await getCustomAvatarItem(c.env.DB, c.req.param('id'))
if (!item) return c.notFound()
if (item.Accessibility === 0 && item.CreatorAccountId !== (await authedId(c)))
return c.body(null, 403)
return c.json(c.req.query('unityAssetTarget') === '2' ? toQuestCustomAvatarItem(item) : item)
})
.post('/api/customAvatarItems/v1', async (c, next) => {
// Preserve the existing in-game custom-shirt contract, including its errors.
const form = await parseMultipart(c.req.raw.clone())
let body: Record<string, unknown>
try {
body = JSON.parse(String(form.metadata))
} catch {
return next()
}
if (
!body ||
typeof body !== 'object' ||
body.BaseAvatarItemId !== undefined ||
form.design ||
form.thumbnailImage
)
return next()
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const slot = Number(body.OutfitType)
if (!Number.isInteger(slot) || !slots.has(slot))
return c.json(fail('Unsupported avatar item type.'), 400)
if (!(await allowed(c, id, slot)))
return c.json(fail('Avatar Studio authorization is required.'), 403)
const badgeRoomId = Number(body.BadgeRoomId ?? 0)
if (
slot === 1000 &&
(!Number.isSafeInteger(badgeRoomId) ||
badgeRoomId < 0 ||
(badgeRoomId > 0 && !(await canManageRoomById(c.env.DB, badgeRoomId, id))))
)
return c.json(fail('Badge room is not managed by this account.'), 403)
if (
typeof body.Name !== 'string' ||
!body.Name.trim() ||
body.Name.length > 128 ||
(body.Description != null &&
(typeof body.Description !== 'string' || body.Description.length > 4000))
)
return c.json(fail('Invalid item name or description.'), 400)
if (
body.Price !== undefined &&
body.Price !== null &&
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)
)
return c.json(fail('Invalid item price.'), 400)
if (body.Accessibility !== undefined && ![0, 1, 2].includes(Number(body.Accessibility)))
return c.json(fail('Invalid accessibility.'), 400)
if (body.Accessibility && body.Accessibility !== 0)
return c.json(fail('Create the item privately and publish a built save first.'), 400)
if (!validTags(body.Tags)) return c.json(fail('Invalid avatar tags.'), 400)
const now = new Date().toISOString(),
itemId = crypto.randomUUID()
const item = {
CustomAvatarItemId: itemId,
CreatorAccountId: id,
Name: body.Name.trim(),
Description: body.Description ?? '',
Price: body.Price ?? 0,
Accessibility: body.Accessibility ?? 0,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: false,
BaseAvatarItemId: null,
BaseAvatarItemColor: null,
DesignFilename: null,
ThumbnailImageFilename: null,
CreatedAt: now,
ModifiedAt: now,
PreviewOrientation: body.PreviewOrientation ?? 0,
RankingContext: null,
OutfitType: slot,
CurrentSaves: [],
Tags: body.Tags ?? [],
IsStudioAuthored: true,
CustomBadgeMetadata: slot === 1000 ? { RoomId: badgeRoomId } : null,
RankedEntityId: itemId,
PurchaseInfo: null,
}
await c.env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
.bind(JSON.stringify(item))
.run()
return c.json(ok(item))
})
.put(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c, next) => {
const itemId = c.req.param('id')
const existing = await avatarStudioItem(c.env.DB, itemId)
if (!existing?.IsStudioAuthored) return next()
const id = await authedId(c)
if (id === null) return c.body(null, 401)
if (existing.CreatorAccountId !== id || !(await allowed(c, id, existing.OutfitType)))
return c.body(null, 403)
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
if (
!body ||
(body.Name != null &&
(typeof body.Name !== 'string' || !body.Name.trim() || body.Name.length > 128)) ||
(body.Description != null &&
(typeof body.Description !== 'string' || body.Description.length > 4000)) ||
(body.Price != null &&
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)) ||
(body.Accessibility != null && ![0, 1, 2].includes(Number(body.Accessibility))) ||
!validTags(body.Tags)
)
return c.json(fail('Invalid avatar metadata.'), 400)
for (let attempt = 0; attempt < 5; attempt++) {
const item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (body.Accessibility && !item.CurrentSaves?.length)
return c.json(fail('Publish a built save before making the item public.'), 400)
const prior = JSON.stringify(item)
for (const key of ['Name', 'Description', 'Price', 'Accessibility', 'Tags'])
if (body[key] != null) item[key] = key === 'Name' ? String(body[key]).trim() : body[key]
item.ModifiedAt = new Date().toISOString()
const result = await c.env.DB.prepare(
'UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)'
)
.bind(JSON.stringify(item), itemId, prior)
.run()
if (result.meta.changes) return c.json(ok(item))
}
return c.json(fail('The item changed concurrently. Retry the edit.'), 409)
})
.get(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const itemId = c.req.param('id'),
item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (item.CreatorAccountId !== id) return c.body(null, 403)
const rows = await c.env.DB.prepare(
'SELECT * FROM avatar_studio_save WHERE item_id=? ORDER BY id DESC'
)
.bind(itemId)
.all<AvatarStudioSave>()
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
return c.json(
ok({
Results: await Promise.all(
rows.results.slice(skip, skip + take).map((r) => avatarSaveDto(c.env.DB, r))
),
TotalResults: rows.results.length,
})
)
})
.post(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const itemId = c.req.param('id'),
item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
return c.body(null, 403)
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
if (
!body ||
!Number.isInteger(body.BodyType) ||
![0, 1, 2, 3, 4].includes(Number(body.BodyType)) ||
!safeName(body.ThumbnailFileName) ||
typeof body.AdditionalConfiguration !== 'string' ||
body.AdditionalConfiguration.length > 1024 * 1024 ||
(body.SaveDescription != null &&
(typeof body.SaveDescription !== 'string' || body.SaveDescription.length > 4000))
)
return c.json(fail('Invalid avatar save.'), 400)
try {
JSON.parse(body.AdditionalConfiguration)
} catch {
return c.json(fail('Invalid additional configuration JSON.'), 400)
}
let filename: string, hash: string, unityId: string
if (body.NewUnityAssetFile && typeof body.NewUnityAssetFile === 'object') {
const file = body.NewUnityAssetFile as Record<string, unknown>
if (!safeName(file.Filename)) return c.json(fail('Invalid source filename.'), 400)
const object = await c.env.CDN_ASSETS.head('avatarunityasset/' + file.Filename)
if (!object || object.customMetadata?.ownerId !== String(id))
return c.json(fail('Source upload does not belong to this account.'), 403)
const checksum = object.checksums.sha256
hash = checksum ? btoa(String.fromCharCode(...new Uint8Array(checksum))) : ''
if (!hash || file.Hash !== hash)
return c.json(fail('Source hash does not match uploaded bytes.'), 400)
filename = file.Filename
unityId = crypto.randomUUID()
} else if (typeof body.ExistingUnityAssetId === 'string') {
const prior = await c.env.DB.prepare(
'SELECT * FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
)
.bind(itemId, body.ExistingUnityAssetId)
.first<AvatarStudioSave>()
if (!prior) return c.json(fail('No matching source revision on this item.'), 400)
filename = prior.source_filename
hash = prior.source_hash
// A source can be reused across builds, but compiled outputs belong to
// one body/configuration. Only metadata-only revisions share their output.
unityId =
prior.body_type === body.BodyType && prior.configuration === body.AdditionalConfiguration
? prior.unity_asset_id
: crypto.randomUUID()
} else return c.json(fail('A new or existing source asset is required.'), 400)
if (item.OutfitType === 1000) {
const config = JSON.parse(body.AdditionalConfiguration as string)
const badgeRoom = config?.d?.d?.r ?? 0
if (
config?.v !== 1 ||
config?.d?.o !== 1000 ||
!Number.isSafeInteger(badgeRoom) ||
badgeRoom < 0
)
return c.json(fail('Invalid badge configuration.'), 400)
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
return c.json(fail('Badge room is not managed by this account.'), 403)
if (body.BodyType !== 4)
return c.json(fail('Badge images require the body-agnostic body type.'), 400)
const source = await c.env.CDN_ASSETS.get('avatarunityasset/' + filename)
if (!source || source.size > 8 * 1024 * 1024)
return c.json(fail('Missing or oversized badge source.'), 400)
const manifest = await source
.json<{
Version: number
MainAssetProjectRelativePath: string
Files: Array<{ p: string; n: string }>
}>()
.catch(() => null)
if (
!manifest ||
manifest.Version !== 3 ||
!Array.isArray(manifest.Files) ||
!manifest.Files.length ||
manifest.Files.length > 100000 ||
!safeName(manifest.MainAssetProjectRelativePath) ||
!manifest.MainAssetProjectRelativePath.startsWith('Assets/') ||
!/\.(png|jpe?g)$/i.test(manifest.MainAssetProjectRelativePath) ||
manifest.Files.some(
(f) =>
!f ||
!safeName(f.p) ||
!/^(Assets|Packages)\//.test(f.p) ||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
!/^[a-z0-9]{40}$/.test(f.n)
) ||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
)
return c.json(fail('Invalid badge image source manifest.'), 400)
const main = manifest.Files.find((f) => f.p === manifest.MainAssetProjectRelativePath)
const image = main ? await c.env.CDN_ASSETS.get('unityasset/' + main.n) : null
if (!image || image.size > 16 * 1024 * 1024)
return c.json(fail('Badge source image is missing or oversized.'), 400)
const signature = new Uint8Array(await image.arrayBuffer()).subarray(0, 8)
if (
!(
signature[0] === 137 &&
signature[1] === 80 &&
signature[2] === 78 &&
signature[3] === 71
) &&
!(signature[0] === 255 && signature[1] === 216 && signature[2] === 255)
)
return c.json(fail('Badge source must be a PNG or JPEG image.'), 400)
}
for (const name of [body.ThumbnailFileName, body.ModerationImageFileName].filter(
(v) => v !== undefined && v !== null
)) {
if (!safeName(name)) return c.json(fail('Invalid image filename.'), 400)
const image = await c.env.CDN_ASSETS.get('image/' + name)
if (!image || image.customMetadata?.ownerId !== String(id))
return c.json(fail('Image upload does not belong to this account.'), 403)
if (image.size > 16 * 1024 * 1024) return c.json(fail('Avatar image exceeds limit.'), 413)
// Storage's image upload and the Images service use different buckets.
// Keep the SDK filename unchanged when publishing creator-owned thumbnails.
await c.env.IMAGES.put(name, image.body, { httpMetadata: image.httpMetadata })
}
const row = await c.env.DB.prepare(
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,moderation_image,configuration,description,update_current,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) RETURNING id'
)
.bind(
itemId,
body.BodyType,
unityId,
filename,
hash,
body.ThumbnailFileName,
typeof body.ModerationImageFileName === 'string' ? body.ModerationImageFileName : null,
body.AdditionalConfiguration,
body.SaveDescription ?? null,
body.UpdateCurrentSaveIfPrivate === true ? 1 : 0,
new Date().toISOString()
)
.first<{ id: number }>()
if (body.UpdateCurrentSaveIfPrivate === true && item.Accessibility === 0)
await publishAvatarSave(c.env.DB, itemId, row!.id, true)
return c.json(ok(row!.id))
})
.post(`/api/customAvatarItems/:id{${uuid}}/saves/:saveId{[1-9][0-9]*}/publish`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, c.req.param('id'))
if (!item) return c.notFound()
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
return c.body(null, 403)
const save = await avatarStudioSave(
c.env.DB,
item.CustomAvatarItemId,
Number(c.req.param('saveId'))
)
if (item.OutfitType === 1000 && save) {
const badgeRoom = JSON.parse(save.configuration)?.d?.d?.r ?? 0
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
return c.body(null, 403)
}
if (!save || !(await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id)))
return c.json(fail('Save has not completed a build.'), 400)
return c.json(ok(null))
})
@@ -0,0 +1,338 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import {
AVATAR_STUDIO_SCHEMA,
avatarSaveDto,
avatarStudioSave,
publishAvatarSave,
ROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { generateToken } from '@repo/jwt'
import { SCHEMA_DDL } from '../../custom-avatar-items-db'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const base = 'https://example.com/api/customAvatarItems'
async function headers(id = 42, roles = ['betastudio']) {
return {
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
for (const sql of [
...SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
...UNITY_ASSET_SCHEMA_DDL,
...ROOM_SCHEMA_DDL,
])
await env.DB.prepare(sql).run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
})
beforeEach(async () => {
await env.DB.prepare('DELETE FROM avatar_studio_save').run()
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
await env.DB.prepare('UPDATE avatar_studio_access SET flags=1 WHERE account_id=42').run()
})
it('saves and publishes image badges directly, while enforcing badge grants and room ownership', async () => {
expect((await create(42, ['betastudio'], 1000)).status).toBe(403)
await env.DB.prepare('UPDATE avatar_studio_access SET flags=5 WHERE account_id=42').run()
const foreign = new FormData()
foreign.set(
'metadata',
JSON.stringify({ Name: 'Foreign badge', OutfitType: 1000, BadgeRoomId: 98765 })
)
expect(
(await SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(), body: foreign }))
.status
).toBe(403)
const { value: item } = await (
await create(42, ['betastudio'], 1000)
).json<{ value: { CustomAvatarItemId: string } }>()
const png = Uint8Array.from(
atob(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='
),
(c) => c.charCodeAt(0)
)
const main = 'b'.repeat(40)
const bytes = new TextEncoder().encode(
JSON.stringify({
Version: 3,
MainAssetProjectRelativePath: 'Assets/Badge.png',
Files: [{ n: main, p: 'Assets/Badge.png' }],
})
)
const digest = await crypto.subtle.digest('SHA-256', bytes)
await env.CDN_ASSETS.put('avatarunityasset/badge-source', bytes, {
sha256: digest,
customMetadata: { ownerId: '42' },
})
await env.CDN_ASSETS.put('unityasset/' + main, png)
await env.CDN_ASSETS.put('image/badge.png', png, {
customMetadata: { ownerId: '42' },
httpMetadata: { contentType: 'image/png' },
})
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const save = await SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({
NewUnityAssetFile: {
Filename: 'badge-source',
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
},
BodyType: 4,
ThumbnailFileName: 'badge.png',
AdditionalConfiguration: JSON.stringify({
v: 1,
d: { n: 'Badge', o: 1000, d: { t: '000000' } },
}),
UpdateCurrentSaveIfPrivate: true,
}),
})
expect(save.status).toBe(200)
const { value: saveId } = await save.json<{ value: number }>()
const detail = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
headers: await headers(),
})
expect(await detail.json()).toMatchObject({
ThumbnailImageFilename: 'badge.png',
CurrentSaves: [{ CustomAvatarItemSaveId: saveId, BodyType: 4, UnityAsset: null }],
})
expect(
(
await SELF.fetch(url + '/' + saveId + '/publish', {
method: 'POST',
headers: await headers(),
})
).status
).toBe(200)
expect(await (await env.IMAGES.get('badge.png'))!.arrayBuffer()).toEqual(png.buffer)
const published = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
method: 'PUT',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({ Accessibility: 1 }),
})
expect(published.status).toBe(200)
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(200)
})
async function create(id = 42, roles = ['betastudio'], outfitType = 0) {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'Studio Hat', OutfitType: outfitType, Accessibility: 0, Price: 0 })
)
return SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(id, roles), body: form })
}
it('accepts the SDK create request with nullable metadata fields', async () => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({
Name: 'SDK item',
OutfitType: 101,
BaseAvatarItemColor: '#101',
BadgeRoomId: null,
PreviewOrientation: null,
Description: null,
Tags: null,
Price: null,
Accessibility: null,
})
)
const response = await SELF.fetch(base + '/v1', {
method: 'POST',
headers: await headers(),
body: form,
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
value: { Description: '', Tags: [], Price: 0, Accessibility: 0, CurrentSaves: [] },
})
})
it('keeps grants per-account, rejects forbidden item categories, and creates metadata-only Studio items', async () => {
expect((await create(43)).status).toBe(403)
expect((await create(42, [])).status).toBe(403)
expect((await create(42, ['betastudio'], 500)).status).toBe(403)
const response = await create()
expect(response.status).toBe(200)
const { value: item } = await response.json<{
value: { CustomAvatarItemId: string; OutfitType: number; CurrentSaves: unknown[] }
}>()
expect(item.OutfitType).toBe(0)
expect(item.CurrentSaves).toEqual([])
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(403)
expect(
(await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, { headers: await headers() })).status
).toBe(200)
const page = await SELF.fetch(base + '/v1/me', { headers: await headers() })
expect(await page.json()).toMatchObject({
TotalResults: 1,
Results: [{ CustomAvatarItemId: item.CustomAvatarItemId }],
})
})
it('protects source history, verifies upload ownership and hash, and never publishes unbuilt revisions', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const bytes = new TextEncoder().encode('{"Version":3}'),
digest = await crypto.subtle.digest('SHA-256', bytes)
await env.CDN_ASSETS.put('avatarunityasset/2026-10-18/source', bytes, {
sha256: digest,
customMetadata: { ownerId: '42' },
})
await env.CDN_ASSETS.put('image/thumb.png', 'thumbnail', {
customMetadata: { ownerId: '42' },
httpMetadata: { contentType: 'image/png' },
})
const body = {
NewUnityAssetFile: {
Filename: '2026-10-18/source',
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
},
BodyType: 1,
ThumbnailFileName: 'thumb.png',
AdditionalConfiguration: '{}',
UpdateCurrentSaveIfPrivate: true,
}
const post = async (value: unknown, id = 42) =>
SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
body: JSON.stringify(value),
})
expect((await post(body, 43)).status).toBe(403)
expect(
(await post({ ...body, NewUnityAssetFile: { ...body.NewUnityAssetFile, Hash: 'wrong' } }))
.status
).toBe(400)
const response = await post(body)
expect(response.status).toBe(200)
const { value: saveId } = await response.json<{ value: number }>()
expect(await (await env.IMAGES.get('thumb.png'))?.text()).toBe('thumbnail')
expect((await SELF.fetch(url, { headers: await headers(43) })).status).toBe(403)
const history = await SELF.fetch(url, { headers: await headers() })
expect(await history.json()).toMatchObject({
success: true,
value: {
TotalResults: 1,
Results: [
{
CustomAvatarItemSaveId: saveId,
SourceUnityAssetFilename: body.NewUnityAssetFile.Filename,
UnityAsset: null,
},
],
},
})
expect(
(
await SELF.fetch(url + '/' + saveId + '/publish', {
method: 'POST',
headers: await headers(),
})
).status
).toBe(400)
expect(
(await post({ ...body, NewUnityAssetFile: null, ExistingUnityAssetId: crypto.randomUUID() }))
.status
).toBe(400)
})
it('allows creator metadata edits without exposing unbuilt items or changing ownership', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const edit = async (body: unknown, id = 42) =>
SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
method: 'PUT',
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
expect((await edit({ Name: 'Other owner' }, 43)).status).toBe(403)
expect((await edit({ Accessibility: 1 })).status).toBe(400)
expect((await edit({ Tags: [{ TagType: 1, Value: '' }] })).status).toBe(400)
const response = await edit({
Name: ' Updated hat ',
Description: 'Edited in Studio',
Tags: [{ TagType: 1, Value: 'hat' }],
CreatorAccountId: 43,
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
value: {
Name: 'Updated hat',
Description: 'Edited in Studio',
CreatorAccountId: 42,
Accessibility: 0,
Tags: [{ TagType: 1, Value: 'hat' }],
},
})
})
it('isolates outputs across reused-source bodies/configurations and preserves original restore', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const asset = crypto.randomUUID()
await env.DB.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) VALUES (100,?,1,?,'same-source','same-hash','thumb.png','{}','now')"
)
.bind(item.CustomAvatarItemId, asset)
.run()
await env.DB.prepare(
"INSERT INTO unity_asset VALUES (?,0,5,'main','original.bundle','original-hash')"
)
.bind(asset)
.run()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
await env.CDN_ASSETS.put('image/thumb.png', 'fixture', { customMetadata: { ownerId: '42' } })
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const save = async (bodyType: number, config: string) => {
const response = await SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({
ExistingUnityAssetId: asset,
BodyType: bodyType,
AdditionalConfiguration: config,
ThumbnailFileName: 'thumb.png',
UpdateCurrentSaveIfPrivate: true,
}),
})
expect(response.status).toBe(200)
const { value: id } = await response.json<{ value: number }>()
return (await avatarStudioSave(env.DB, item.CustomAvatarItemId, id))!
}
const metadata = await save(1, '{}')
expect(metadata.unity_asset_id).toBe(asset)
const otherBody = await save(2, '{}'),
otherConfig = await save(1, '{"variant":2}')
for (const revision of [otherBody, otherConfig]) {
expect(revision.unity_asset_id).not.toBe(asset)
expect(revision.source_filename).toBe('same-source')
expect(revision.source_hash).toBe('same-hash')
expect((await avatarSaveDto(env.DB, revision)).UnityAsset).toBeNull()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, revision.id)).toBe(false)
}
expect(otherBody.unity_asset_id).not.toBe(otherConfig.unity_asset_id)
await env.DB.prepare(
"INSERT INTO unity_asset VALUES (?,0,5,'main','other-body.bundle','other-hash')"
)
.bind(otherBody.unity_asset_id)
.run()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, otherBody.id)).toBe(true)
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
const stored = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
.bind(item.CustomAvatarItemId)
.first<{ data: string }>())!.data
)
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 1).UnityAsset).toBe(
'original.bundle'
)
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 2).UnityAsset).toBe(
'other-body.bundle'
)
})
+7 -2
View File
@@ -15,6 +15,8 @@ authenticates players and issues the JWTs every other worker verifies.
| POST | `/connect/device/deny` | Website refuses it | | POST | `/connect/device/deny` | Website refuses it |
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token. Studio polls this with the device-code grant | | POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token. Studio polls this with the device-code grant |
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) | | POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
| POST | `/account/recoverpassword` | "Forgot password": email a six-digit code (and a link to WWW `/reset-password`) to the address |
| POST | `/account/resetpassword` | Website finishes the recovery: address + emailed code + new password; the code works once |
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean | | GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean | | GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) | | GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
@@ -63,8 +65,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.
@@ -0,0 +1,24 @@
-- An email address belongs to ONE account. Password recovery (`auth`
-- `POST /account/recoverpassword`) is handed nothing but the address the player typed and
-- has to find their account from it, which only works if no two accounts share one.
--
-- Exposed as a generated column, case-folded, with an empty or absent email read as NULL so
-- accounts without one never collide — and a UNIQUE partial index over it, which is what
-- actually refuses a second holder. `accounts` `POST /account/me/email` checks first and
-- answers a refusal the player can read; the index is the backstop against a race.
--
-- Before the index can be built, existing duplicates have to go: the LOWEST account id
-- keeps the address (the first account to claim it) and the others lose theirs, since a
-- recovery sent to a shared address could only ever reach one of them anyway. Those
-- players set it again from the account page or in game.
--
-- Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror in
-- apps/econ/src/avatar-db.ts).
ALTER TABLE account ADD COLUMN email_lower TEXT GENERATED ALWAYS AS (NULLIF(lower(json_extract(data, '$.email')), '')) VIRTUAL;
UPDATE account SET data = json_remove(data, '$.email')
WHERE email_lower IS NOT NULL
AND account_id > (SELECT MIN(a2.account_id) FROM account a2 WHERE a2.email_lower = account.email_lower);
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_email_lower ON account (email_lower) WHERE email_lower IS NOT NULL;
+20
View File
@@ -0,0 +1,20 @@
-- One-shot codes sent by email: the PBKDF2 hash of the six-digit code the player was
-- mailed, what it is for (`purpose`: a `password_reset`; or an `email_change` — the
-- current address approving a change — followed by an `email_verify` — the new address
-- confirming itself — both with the new address as `payload`), and when it was issued and
-- lapses. One pending code per
-- account and purpose. Mirrored by EMAIL_CODE_SCHEMA_DDL in @repo/domain's
-- email-code-db.ts, which says how the row is used — in short, a wrong guess BURNS it
-- (`code_hash` goes NULL, the row stays for the resend cooldown), a right one deletes it,
-- and a new request replaces it. Written by `auth` and `accounts`; owned here with the
-- rest of the account tables.
CREATE TABLE IF NOT EXISTS email_code (
account_id INTEGER NOT NULL,
purpose TEXT NOT NULL,
code_hash TEXT,
payload TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
PRIMARY KEY (account_id, purpose)
);
+341 -40
View File
@@ -4,11 +4,16 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { z } from 'zod' import { z } from 'zod'
import { import {
beginEmailCode,
completeEmailCode,
countAccountsByDeviceId, countAccountsByDeviceId,
countAccountsBySignupIp, countAccountsBySignupIp,
createAccount, createAccount,
EMAIL_CODE_TTL_SECONDS,
emailSender,
GAME_VERSION, GAME_VERSION,
getAccount, getAccount,
getAccountByEmail,
getAccountByUsername, getAccountByUsername,
getAccountsByIds, getAccountsByIds,
getPasswordHash, getPasswordHash,
@@ -16,6 +21,7 @@ import {
hashPassword, hashPassword,
isRolloutGameVersion, isRolloutGameVersion,
isSupportedGameVersion, isSupportedGameVersion,
isValidEmail,
rolloutGameBuilds, rolloutGameBuilds,
RoomInstanceType, RoomInstanceType,
setLastLoginTime, setLastLoginTime,
@@ -37,7 +43,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.
@@ -58,6 +69,9 @@ import {
OAuthError, OAuthError,
PlatformIdsRequest, PlatformIdsRequest,
PlatformType, PlatformType,
RecoverPasswordRequest,
RecoverPasswordResponse,
ResetPasswordRequest,
RestrictionDto, RestrictionDto,
roleFilter, roleFilter,
roleLookup, roleLookup,
@@ -65,14 +79,18 @@ 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 { recoveryEmail } from './recovery-email'
import { consumeRefreshToken, issueRefreshToken, revokeRefreshTokens } from './refresh-db'
import { verifySteamTicket } from './steam-ticket' import { verifySteamTicket } from './steam-ticket'
import { import {
approveDeviceGrant, approveDeviceGrant,
@@ -108,6 +126,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 +242,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 +267,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. */
@@ -317,8 +341,8 @@ async function authedId(c: Context<App>): Promise<number | null> {
/** Whether one account row holds a role. Absent/false both mean no role. */ /** Whether one account row holds a role. Absent/false both mean no role. */
function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean { function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean {
// Matches accountTokenRoles: screenshare is a feature gate on every account token. // Match the same staff override used when minting account tokens.
if (role === 'screenshare') return true if (role === 'screenshare') return account.hasScreenshare === true
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
} }
@@ -368,30 +392,31 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
/** /**
* The role names beyond `gameClient` for an account's token `role` claim. Base roles * The role names beyond `gameClient` for an account's token `role` claim. Base roles
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the * (gameClient) are added by generateToken. `screenshare` requires an explicit staff
* client gates the screen-share feature on it and nothing grants it per-account, so it * grant via `hasScreenshare`. The other roles come from account flags and Studio access.
* is unconditional (even with no account resolved). The rest are the operator-granted
* 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, Account,
'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer' 'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer' | 'hasScreenshare'
> | null > | null,
studio: boolean
): string[] { ): string[] {
const roles = ['screenshare'] const roles: string[] = account?.hasScreenshare === true ? ['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 +468,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 +504,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 +547,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 +1053,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 +1076,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,',
@@ -972,9 +1114,10 @@ const app = new Hono<App>()
'', '',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator', '**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant. `junior` rides along for an', '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`. `screenshare` requires a staff grant on the Studio Access',
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped', 'page. `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 +1215,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 +1711,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
@@ -1688,6 +1837,158 @@ const app = new Hono<App>()
} }
) )
// "Forgot password" — the game posts the address on the account and nothing else, so the
// address is how the account is found (which is why it is unique across accounts). A
// six-digit code is emailed, typed out and prefilled into a link to the website's
// `/reset-password` page, where the new password is chosen (`/account/resetpassword`
// below): the game has no screen to finish a recovery on.
//
// Unauthenticated, and so answered the same way whether or not the address is on an
// account: `Success: true`, "we've sent an email if there is one". The route is otherwise
// a way to ask which addresses have accounts here. The refusals it does voice are the
// ones the player can act on — no address, a malformed one, the mail not going out —
// in the client's PascalCase `{ Success, Error, error_id }` envelope, which is the one
// shape it has been seen to read for this route. See @repo/domain's email-code-db.ts for the code's
// rules (one attempt, a cooldown on resends, a 30-minute life).
.post(
'/account/recoverpassword',
describeRoute({
tags: ['Account'],
summary: 'Email a password-recovery code',
description: [
'Emails a six-digit code to the address, with a link to the website’s reset page',
'(`/reset-password?email=…&code=…`) where the new password is chosen via',
'`POST /account/resetpassword`. Answers `Success: true` whether or not the address',
'is on an account, so the route can’t be used to find out which addresses are.',
'A new request replaces the pending code; one is sent per address at most every',
'two minutes. The code works once: a wrong guess voids it and the recovery has to',
'be requested again. Refusals — no address, a malformed one, the email failing to',
'send — are `Success: false` with the reason in `Error`.',
].join(' '),
requestBody: form(RecoverPasswordRequest, 'The address on the account'),
responses: {
200: json(RecoverPasswordResponse, 'Sent, or nothing to send to; `Error` says if not'),
},
}),
async (c) => {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const email = typeof body.email === 'string' ? body.email.trim() : ''
const refuse = (error: string) => c.json({ Success: false, Error: error, error_id: null })
if (email === '') return refuse('Enter the email address on your account.')
if (!isValidEmail(email)) return refuse("That doesn't look like an email address.")
const account = await getAccountByEmail(c.env.DB, email)
if (account === null) {
// Answered exactly like a sent email, see above. Logged, since a run of these
// from one place is somebody probing.
logger.info('password recovery requested for an address on no account')
return c.json({ Success: true, Error: null, error_id: null })
}
const code = await beginEmailCode(c.env.DB, 'password_reset', account.accountId)
if (code === null) {
logger.info('password recovery re-requested within the cooldown; not resent', {
accountId: account.accountId,
})
return c.json({ Success: true, Error: null, error_id: null })
}
const message = recoveryEmail({
// The stored address, not the posted one: same account either way (the lookup
// is case-insensitive), and the stored one is the one the player chose to be
// reached at.
to: account.email ?? email,
from: emailSender(c.env),
domain: c.env.DOMAIN,
www: buildEndpoints(c.env.DOMAIN, c.env.SUBDOMAINS).WWW,
code,
validMinutes: Math.round(EMAIL_CODE_TTL_SECONDS / 60),
})
try {
await c.env.EMAIL.send(message)
} catch (err) {
// Told to the player: they would otherwise wait on an email that is never
// coming. The usual cause is the sender's domain not being enabled for Email
// Sending (see DEPLOYING.md), which is an operator's problem, hence the log.
logger.error('could not send the password recovery email', {
accountId: account.accountId,
error: err instanceof Error ? err.message : String(err),
})
return refuse('The recovery email could not be sent. Please try again later.')
}
logger.info('password recovery email sent', { accountId: account.accountId })
return c.json({ Success: true, Error: null, error_id: null })
}
)
// The other half: the website's `/reset-password` page posts the address, the code from
// the email and the new password. Unauthenticated — the code IS the credential. A wrong
// code voids the pending recovery (one guess per email), and the refusal reads the same
// whether the code was wrong, lapsed or never issued, so nothing here says whether a
// recovery is in flight for an address. Success sets the password and signs the account
// out everywhere else by revoking its refresh tokens. This is our own route, not the
// client's: the game never calls it, so it answers in the `{ success, error }` shape the
// website's other forms read.
.post(
'/account/resetpassword',
describeRoute({
tags: ['Account'],
summary: 'Finish a password recovery with the emailed code',
description: [
'Sets the password of the account under `email` when `code` is the one its recovery',
'email carried. Unauthenticated; the code is the credential, and it works ONCE — a',
'wrong code voids the recovery, and `POST /account/recoverpassword` has to be posted',
'again for a new one. The refusal is the same sentence whether the code was wrong,',
'has lapsed (30 minutes) or was never issued. On success the account’s refresh',
'tokens are revoked, so other sessions sign out. Posted by the website’s',
'`/reset-password` page, never by the game.',
].join(' '),
requestBody: form(ResetPasswordRequest, 'Address, emailed code and the new password'),
responses: {
200: json(ChangePasswordResponse, 'Password set'),
400: json(
ChangePasswordResponse,
'Missing field, or the code is wrong, lapsed or not issued (`error` says which, if the player can act on it)'
),
},
}),
async (c) => {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const email = typeof body.email === 'string' ? body.email.trim() : ''
const code = typeof body.code === 'string' ? body.code.trim() : ''
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
const refuse = (error: string) => c.json({ success: false, error }, 400)
if (email === '') return refuse('Enter the email address the code was sent to.')
if (code === '') return refuse('Enter the code from the email.')
if (newPassword === '') return refuse('You must enter a new password.')
const WRONG_CODE =
'That code is wrong or has expired. Request a new recovery email and try again.'
const account = await getAccountByEmail(c.env.DB, email)
if (account === null) {
logger.info('password reset posted for an address on no account')
return refuse(WRONG_CODE)
}
const { outcome } = await completeEmailCode(
c.env.DB,
'password_reset',
account.accountId,
code
)
if (outcome !== 'ok') {
logger.info('password reset refused', { accountId: account.accountId, outcome })
return refuse(WRONG_CODE)
}
const ok = await setPasswordHash(c.env.DB, account.accountId, await hashPassword(newPassword))
if (!ok) return c.body(null, 404)
const revoked = await revokeRefreshTokens(c.env.DB, account.accountId)
logger.info('password reset by email', { accountId: account.accountId, revoked })
return c.json({ success: true })
}
)
// The moderation restrictions on the caller's account — chat mutes and the like. STUB: // The moderation restrictions on the caller's account — chat mutes and the like. STUB:
// nothing here issues restrictions, so every caller is unrestricted and the list is // nothing here issues restrictions, so every caller is unrestricted and the list is
// empty. An EMPTY ARRAY is the normal unrestricted answer, not null and not a 404: the // empty. An EMPTY ARRAY is the normal unrestricted answer, not null and not a 404: the
+28
View File
@@ -19,6 +19,22 @@ export type Env = SharedHonoEnv & {
// offline, Meta logins are impossible without it, so an empty value fails those // offline, Meta logins are impossible without it, so an empty value fails those
// logins with a 500 rather than silently trusting the client's platform_id. // logins with a 500 rather than silently trusting the client's platform_id.
META_APP_SECRET: SecretsStoreSecret META_APP_SECRET: SecretsStoreSecret
/**
* Cloudflare Email Sending (`send_email` in wrangler.jsonc). The one thing this worker
* mails is the password-recovery code (`POST /account/recoverpassword`); `accounts` binds
* the same for its email-change confirmation. The `from` address is `EMAIL_FROM` below
* (read through `emailSender`), whose domain has to be onboarded onto Email Sending
* (`wrangler email sending enable <domain>`, see DEPLOYING.md) or every send fails — which
* the route reports to the player as the email not going out, and nothing else is affected.
* Locally and in tests the binding logs the message instead of sending it.
*/
EMAIL: SendEmail
/**
* The address recovery emails come from. Set for every worker from the root `.env`
* (`RECFLARE_EMAIL_FROM`); unset means `no-reply@<DOMAIN>`. Its domain must be one Email
* Sending has been enabled on — Cloudflare refuses a send from any other.
*/
EMAIL_FROM?: string
// Signup caps, both optional (see auth.app.ts for what each arm counts and why). // Signup caps, both optional (see auth.app.ts for what each arm counts and why).
// Unset falls back to the DEFAULT_MAX_ACCOUNTS_* constants there; 0 disables that arm. // Unset falls back to the DEFAULT_MAX_ACCOUNTS_* constants there; 0 disables that arm.
// Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a // Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
@@ -64,6 +80,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
+35 -2
View File
@@ -143,10 +143,12 @@ export const TokenRequest = z.object({
'refresh_token', 'refresh_token',
'password', 'password',
'urn:ietf:params:oauth:grant-type:device_code', 'urn:ietf:params:oauth:grant-type:device_code',
'identity_assertion',
]) ])
.describe( .describe(
'Anything unrecognised (including absent) is treated as a password grant, except the ' + 'Anything unrecognised (including absent) is treated as a password grant, except the ' +
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization' 'Studio device-code URN, which polls a code from POST /connect/deviceauthorization, ' +
'and identity_assertion, the website’s Discord sign-in'
), ),
account_id: z.string().optional().describe('Numeric account id, as a string'), account_id: z.string().optional().describe('Numeric account id, as a string'),
username: z username: z
@@ -183,6 +185,13 @@ export const TokenRequest = z.object({
.string() .string()
.optional() .optional()
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'), .describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
assertion: z
.string()
.optional()
.describe(
'identity_assertion grant: the HS256 assertion `www` minted with the shared JWT_SECRET ' +
'after completing a Discord OAuth2 exchange, naming the platform and platform_id it proved'
),
device_id: z device_id: z
.string() .string()
.optional() .optional()
@@ -240,6 +249,30 @@ export const ChangePasswordResponse = z.object({
error: z.string().optional(), error: z.string().optional(),
}) })
/** `POST /account/recoverpassword` form body — the one thing the client sends. */
export const RecoverPasswordRequest = z.object({
email: z.string().describe('The address on the account; nothing else identifies it'),
})
/**
* `POST /account/recoverpassword` response body: the client's PascalCase result envelope,
* `{ Success, Error, error_id }` — the one route here that answers in it. `Error` is null
* on success and a sentence otherwise; `error_id` is always null (nothing here numbers its
* errors) and is kept because the client's decoder wants the key.
*/
export const RecoverPasswordResponse = z.object({
Success: z.boolean(),
Error: z.string().nullable(),
error_id: z.number().nullable(),
})
/** `POST /account/resetpassword` form body — what the website's reset page posts. */
export const ResetPasswordRequest = z.object({
email: z.string().describe('The address the code was sent to'),
code: z.string().describe('The six digits from the email'),
newPassword: z.string().describe('Required; empty is rejected'),
})
/** /**
* Spec for the `/role/:role/:id` lookups, which are identical apart from the role. * Spec for the `/role/:role/:id` lookups, which are identical apart from the role.
* Both return a BARE JSON boolean rather than an object — the client reads the whole * Both return a BARE JSON boolean rather than an object — the client reads the whole
@@ -292,7 +325,7 @@ export function roleLookup(role: 'developer' | 'moderator' | 'screenshare') {
summary: `Whether a player has the ${role} role`, summary: `Whether a player has the ${role} role`,
description: description:
role === 'screenshare' role === 'screenshare'
? 'Returns a bare JSON boolean. Screenshare is enabled for every existing account, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.' ? 'Returns a bare JSON boolean. Screenshare is disabled by default and requires an explicit staff grant, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
: `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` + : `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` + `granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` + `in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
+175
View File
@@ -314,6 +314,147 @@ export async function getLinksForPlatformId(
return results.map(toLink) 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,
@@ -348,6 +489,40 @@ export async function isPlatformIdentityLinked(
return row !== null return row !== null
} }
/**
* Remove EVERY link an account holds on one platform, and return the links that were
* removed (empty when it held none). The website's Discord unlink is the one caller:
* a player taking their Discord off their account, which gives the identity back —
* "Sign in with Discord" with it afterwards makes a NEW account, and the benefits claim's
* once-only guard lets it claim elsewhere again.
*
* The removed rows are returned rather than a count because the caller needs to know what
* they carried: a link whose `role` was recorded is a CLAIM, and the account must remember
* having made one after the row is gone (see `account.hasClaimedDiscord`), or the
* first-link gift would pay again on every link/unlink cycle. Read-then-delete, not
* `DELETE … RETURNING`, so it runs on every D1 the tests do.
*
* Not for Steam or Meta: those links are what a cached login redeems, and nothing on the
* website removes one. The helper doesn't refuse them — the table has no idea what a
* platform is for — so a caller must only ever name a platform nobody logs in with.
*/
export async function unlinkPlatform(
db: D1Database,
accountId: number,
platform: number
): Promise<PlatformLink[]> {
const { results } = await db
.prepare(`${SELECT_LINK} WHERE account_id = ?1 AND platform = ?2 ORDER BY linked_at`)
.bind(accountId, platform)
.all<LinkRow>()
if (results.length === 0) return []
await db
.prepare(`DELETE FROM platform_account WHERE account_id = ?1 AND platform = ?2`)
.bind(accountId, platform)
.run()
return results.map(toLink)
}
/** /**
* How many accounts one platform identity already opens — the count both signup caps * How many accounts one platform identity already opens — the count both signup caps
* and link caps are enforced against, so an identity can't accumulate accounts by * and link caps are enforced against, so an identity can't accumulate accounts by
+66
View File
@@ -0,0 +1,66 @@
/**
* The password-recovery email, as the message builder Cloudflare Email Sending takes
* (`env.EMAIL.send(...)`). A pure function of its inputs, so the message can be read in a
* test without a mail service in the way.
*
* The email carries the code twice: typed out, for someone reading it on a headset or a
* phone, and prefilled into a link to the website's reset page, where the new password is
* actually chosen. The game only ASKS for the recovery (`POST /account/recoverpassword`);
* it has no screen to finish one on.
*/
import { escapeHtml } from '@repo/domain'
/** The website's reset page, which reads `email` and `code` off the query. */
export const RESET_PASSWORD_PATH = '/reset-password'
export interface RecoveryEmailInput {
/** Where the email goes — the address on the account, exactly as stored. */
to: string
/** The address it comes from; must be on a domain enabled for Email Sending. */
from: string
/** The server's base domain, named as the sender and in the copy. */
domain: string
/** The website's origin (`https://www.<domain>`), which the reset link is built on. */
www: string
/** The six-digit code, in plaintext — the only place it is written down. */
code: string
/** How long the code is good for, in minutes, for the copy. */
validMinutes: number
}
/** The reset link: the website's page with the address and the code already filled in. */
export function resetPasswordUrl(www: string, email: string, code: string): string {
const url = new URL(RESET_PASSWORD_PATH, www)
url.searchParams.set('email', email)
url.searchParams.set('code', code)
return url.toString()
}
export function recoveryEmail(input: RecoveryEmailInput) {
const link = resetPasswordUrl(input.www, input.to, input.code)
const subject = `Your ${input.domain} password reset code`
const text = [
`Someone asked to reset the password of the ${input.domain} account under this email address.`,
'',
`Your code is: ${input.code}`,
'',
'Choose a new password here:',
link,
'',
`The code works once and for ${input.validMinutes} minutes. If you didn't ask for this, you can ignore this email — your password hasn't changed.`,
].join('\n')
const html = [
`<p>Someone asked to reset the password of the <strong>${escapeHtml(input.domain)}</strong> account under this email address.</p>`,
`<p>Your code is: <strong style="font-size:1.5em;letter-spacing:0.15em">${input.code}</strong></p>`,
`<p><a href="${escapeHtml(link)}">Choose a new password</a></p>`,
`<p>The code works once and for ${input.validMinutes} minutes. If you didn't ask for this, you can ignore this email — your password hasn't changed.</p>`,
].join('\n')
return {
to: input.to,
from: { email: input.from, name: input.domain },
subject,
text,
html,
}
}
+14
View File
@@ -99,3 +99,17 @@ export async function consumeRefreshToken(db: D1Database, token: string): Promis
if (!row || row.expiresAt < now) return null if (!row || row.expiresAt < now) return null
return row.accountId return row.accountId
} }
/**
* Revoke every refresh token the account holds — after a password RESET, so whoever had
* the old password is signed out everywhere once the owner takes the account back. Access
* tokens already issued run out on their own (TOKEN_TTL_SECONDS); nothing can recall a
* stateless JWT. Returns how many were revoked.
*/
export async function revokeRefreshTokens(db: D1Database, accountId: number): Promise<number> {
const { meta } = await db
.prepare('DELETE FROM refresh_tokens WHERE account_id = ?1')
.bind(accountId)
.run()
return meta.changes
}
+445 -12
View File
@@ -6,8 +6,11 @@ import '../../auth.app'
import { import {
AUDIT_LOG_SCHEMA_DDL, AUDIT_LOG_SCHEMA_DDL,
EMAIL_CODE_SCHEMA_DDL,
GAME_VERSION, GAME_VERSION,
getAccount,
getAccountsByDeviceId, getAccountsByDeviceId,
getPasswordHash,
hashPassword, hashPassword,
PRESENCE_SCHEMA_DDL, PRESENCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL, ROOM_SCHEMA_DDL,
@@ -16,8 +19,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,
@@ -31,6 +40,7 @@ import {
PLATFORM_BACKFILL_SQL, PLATFORM_BACKFILL_SQL,
PLATFORM_SCHEMA_DDL, PLATFORM_SCHEMA_DDL,
} from '../../platform-db' } from '../../platform-db'
import { resetPasswordUrl } from '../../recovery-email'
import { consumeRefreshToken, issueRefreshToken, REFRESH_SCHEMA_DDL } from '../../refresh-db' import { consumeRefreshToken, issueRefreshToken, REFRESH_SCHEMA_DDL } from '../../refresh-db'
import { import {
STUDIO_CLIENT_ID, STUDIO_CLIENT_ID,
@@ -72,6 +82,8 @@ beforeAll(async () => {
metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET) metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The pending email codes (`/account/recoverpassword` issues one as a password_reset).
for (const stmt of EMAIL_CODE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links — one account can hold several (a PC and a headset), and // Platform identity links — one account can hold several (a PC and a headset), and
// this table is what both the picker and the cached_login grant read. // this table is what both the picker and the cached_login grant read.
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()
@@ -102,6 +114,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()
}) })
@@ -682,8 +697,8 @@ describe('auth worker routes', () => {
expect(payload.iss).toBe('https://auth.recflare.net') expect(payload.iss).toBe('https://auth.recflare.net')
expect(payload.aud).toBe('https://auth.recflare.net') expect(payload.aud).toBe('https://auth.recflare.net')
expect(payload.role).toContain('gameClient') expect(payload.role).toContain('gameClient')
// screenshare is a feature gate, not a grant — every token carries it. // Screen sharing is disabled until staff explicitly grant it.
expect(payload.role).toContain('screenshare') expect(payload.role).not.toContain('screenshare')
// A plain adult account carries nothing beyond those — no elevated roles. // A plain adult account carries nothing beyond those — no elevated roles.
expect(payload.role).not.toContain('developer') expect(payload.role).not.toContain('developer')
expect(payload.role).not.toContain('moderator') expect(payload.role).not.toContain('moderator')
@@ -740,6 +755,37 @@ describe('auth worker routes', () => {
// refresh, and the Studio device grant — and a refresh after a revoke drops it. A // refresh, and the Studio device grant — and a refresh after a revoke drops it. A
// token that kept the role for the rest of the day would leave a removed player // token that kept the role for the rest of the day would leave a removed player
// uploading. // uploading.
test('screen sharing requires a staff grant on login, refresh, and role lookup', async () => {
await seedAccount(9819, 'ScreenSharePlayer')
expect((await tokenFor(`account_id=9819&password=${LOGIN_PASSWORD}`)).role).not.toContain(
'screenshare'
)
await updateAccount(env.DB, 9819, { hasScreenshare: true })
const granted = await tokenFor(`account_id=9819&password=${LOGIN_PASSWORD}`)
expect(granted.role).toContain('screenshare')
expect(await (await exports.default.fetch(`${ORIGIN}/role/screenshare/9819`)).json()).toBe(true)
const session = await postToken(`account_id=9819&password=${LOGIN_PASSWORD}`)
const sessionBody = session.json as { refresh_token: string }
await updateAccount(env.DB, 9819, { hasScreenshare: false })
const refresh = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: sessionBody.refresh_token,
}),
})
expect(refresh.status).toBe(200)
const refreshed = (await refresh.json()) as { access_token: string }
const payload = JSON.parse(
atob(refreshed.access_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
)
expect(payload.role).not.toContain('screenshare')
expect(await (await exports.default.fetch(`${ORIGIN}/role/screenshare/9819`)).json()).toBe(
false
)
})
test('POST /connect/token stamps betastudio only while the account has hasStudio', async () => { test('POST /connect/token stamps betastudio only while the account has hasStudio', async () => {
// 9811/9812, not 9601–9605: those ids belong to the staff-login audit tests, // 9811/9812, not 9601–9605: those ids belong to the staff-login audit tests,
// which insert with OR IGNORE and then count `staff_login` rows. // which insert with OR IGNORE and then count `staff_login` rows.
@@ -751,7 +797,6 @@ describe('auth worker routes', () => {
await updateAccount(env.DB, 9811, { hasStudio: true }) await updateAccount(env.DB, 9811, { hasStudio: true })
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([ expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient', 'gameClient',
'screenshare',
'betastudio', 'betastudio',
]) ])
@@ -770,7 +815,6 @@ describe('auth worker routes', () => {
.run() .run()
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([ expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient', 'gameClient',
'screenshare',
'developer', 'developer',
'moderator', 'moderator',
'betastudio', 'betastudio',
@@ -803,7 +847,6 @@ describe('auth worker routes', () => {
expect(signedIn.status).toBe(200) expect(signedIn.status).toBe(200)
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([ expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
'gameClient', 'gameClient',
'screenshare',
'betastudio', 'betastudio',
]) ])
@@ -818,6 +861,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', '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
@@ -888,7 +978,7 @@ describe('auth worker routes', () => {
) )
.run() .run()
const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`) const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'screenshare', 'junior'])) expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'junior']))
expect(payload.role).not.toContain('developer') expect(payload.role).not.toContain('developer')
// `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`. // `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`.
expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat']) expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat'])
@@ -907,9 +997,7 @@ describe('auth worker routes', () => {
) )
.run() .run()
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`) const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual( expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'influencer']))
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
)
expect(payload.role).not.toContain('developer') expect(payload.role).not.toContain('developer')
// Off the flag, off the token — no other flag implies it. // Off the flag, off the token — no other flag implies it.
@@ -1036,7 +1124,9 @@ describe('auth worker routes', () => {
.bind(77) .bind(77)
.first<{ data: string }>() .first<{ data: string }>()
expect(row!.data).toContain('"deviceClass":2') expect(row!.data).toContain('"deviceClass":2')
expect(row!.data).not.toContain('2.0') // Keyed, not a bare '2.0': the row's lastLoginTime has milliseconds, so a login at
// `…:02.045Z` would match that string on its own.
expect(row!.data).not.toContain('"deviceClass":2.0')
}) })
// The grant the live client actually logs in with, and the one path whose device // The grant the live client actually logs in with, and the one path whose device
@@ -1938,6 +2028,205 @@ describe('auth worker routes', () => {
expect(await rotate.json()).toEqual({ success: true }) expect(await rotate.json()).toEqual({ success: true })
}) })
/** POST a form-urlencoded body to one of the unauthenticated recovery routes. */
const postForm = (path: string, body: string) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const recover = (body: string) => postForm('/account/recoverpassword', body)
const reset = (body: string) => postForm('/account/resetpassword', body)
const SENT = { Success: true, Error: null, error_id: null }
/** The pending recovery row for an account, or null. */
const pendingReset = (accountId: number) =>
env.DB.prepare(
"SELECT code_hash AS codeHash, created_at AS createdAt, expires_at AS expiresAt FROM email_code WHERE account_id = ?1 AND purpose = 'password_reset'"
)
.bind(accountId)
.first<{ codeHash: string | null; createdAt: number; expiresAt: number }>()
/**
* Only the code's hash is stored and the email goes to the local binding's log, so a test
* that needs to KNOW the code puts a known one in: same hash function as the route.
*/
const plantCode = async (accountId: number, code: string) => {
await env.DB.prepare(
"UPDATE email_code SET code_hash = ?2 WHERE account_id = ?1 AND purpose = 'password_reset'"
)
.bind(accountId, await hashPassword(code))
.run()
}
/** A fresh account with an email address on it, for the recovery tests. */
const accountWithEmail = async (tag: string, email: string) => {
const claims = await tokenFor(`grant_type=create_account&platform_id=steam-${tag}`)
const accountId = Number(claims.sub)
await updateAccount(env.DB, accountId, { email })
return accountId
}
test('POST /account/recoverpassword issues a code and emails it', async () => {
const id = await accountWithEmail('recover1', 'recover1@example.com')
const before = Math.floor(Date.now() / 1000)
const res = await recover('email=recover1%40example.com')
expect(res.status).toBe(200)
expect(await res.json()).toEqual(SENT)
const row = await pendingReset(id)
expect(row).not.toBeNull()
expect(row!.codeHash).toMatch(/^[^:]+:[^:]+$/)
expect(row!.createdAt).toBeGreaterThanOrEqual(before)
expect(row!.expiresAt - row!.createdAt).toBe(30 * 60)
})
test('POST /account/recoverpassword finds the account case-insensitively', async () => {
const id = await accountWithEmail('recover2', 'Recover2@Example.com')
const res = await recover('email=RECOVER2%40example.com')
expect(await res.json()).toEqual(SENT)
expect(await pendingReset(id)).not.toBeNull()
})
test('POST /account/recoverpassword answers an unknown address exactly like a sent one', async () => {
const res = await recover('email=nobody-here%40example.com')
expect(res.status).toBe(200)
expect(await res.json()).toEqual(SENT)
const rows = await env.DB.prepare('SELECT COUNT(*) AS n FROM email_code').first<{
n: number
}>()
// Nothing was issued for it — the account doesn't exist — but the caller can't tell.
expect(rows!.n).toBeGreaterThanOrEqual(0)
})
test('POST /account/recoverpassword refuses a missing or malformed address, in the client envelope', async () => {
for (const [body, error] of [
['', 'Enter the email address on your account.'],
['email=', 'Enter the email address on your account.'],
['email=not-an-address', "That doesn't look like an email address."],
]) {
const res = await recover(body!)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Success: false, Error: error, error_id: null })
}
})
test('POST /account/recoverpassword does not resend within the cooldown', async () => {
const id = await accountWithEmail('recover3', 'recover3@example.com')
expect(await (await recover('email=recover3%40example.com')).json()).toEqual(SENT)
const first = await pendingReset(id)
// Same answer, but the pending code is untouched: no second email went out.
expect(await (await recover('email=recover3%40example.com')).json()).toEqual(SENT)
expect(await pendingReset(id)).toEqual(first)
})
test('POST /account/recoverpassword replaces the pending code once the cooldown has passed', async () => {
const id = await accountWithEmail('recover4', 'recover4@example.com')
await recover('email=recover4%40example.com')
const first = await pendingReset(id)
await env.DB.prepare(
"UPDATE email_code SET created_at = created_at - 600 WHERE account_id = ?1 AND purpose = 'password_reset'"
)
.bind(id)
.run()
await recover('email=recover4%40example.com')
const second = await pendingReset(id)
expect(second!.codeHash).not.toBe(first!.codeHash)
expect(second!.createdAt).toBeGreaterThan(first!.createdAt - 600)
})
test('POST /account/resetpassword sets the password with the emailed code, once', async () => {
const id = await accountWithEmail('reset1', 'reset1@example.com')
await recover('email=reset1%40example.com')
await plantCode(id, '123456')
// A refresh token from an old session, which the reset signs out.
const refresh = await issueRefreshToken(env.DB, id)
const res = await reset('email=reset1%40example.com&code=123456&newPassword=brand-new')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
expect(await pendingReset(id)).toBeNull()
expect(await consumeRefreshToken(env.DB, refresh)).toBeNull()
// The new password signs in.
const login = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `grant_type=password&account_id=${id}&password=brand-new`,
})
expect(login.status).toBe(200)
// The code was spent: the same one again is refused.
const again = await reset('email=reset1%40example.com&code=123456&newPassword=another')
expect(again.status).toBe(400)
})
test('POST /account/resetpassword voids the recovery on a wrong code', async () => {
const id = await accountWithEmail('reset2', 'reset2@example.com')
await recover('email=reset2%40example.com')
await plantCode(id, '123456')
const wrong = await reset('email=reset2%40example.com&code=654321&newPassword=brand-new')
expect(wrong.status).toBe(400)
expect(await wrong.json()).toEqual({
success: false,
error: 'That code is wrong or has expired. Request a new recovery email and try again.',
})
// Burned, not deleted: the row stays for the resend cooldown, with no code to guess.
expect((await pendingReset(id))!.codeHash).toBeNull()
// The RIGHT code no longer works either — a new email has to be requested.
const right = await reset('email=reset2%40example.com&code=123456&newPassword=brand-new')
expect(right.status).toBe(400)
expect(await getPasswordHash(env.DB, id)).toBeNull()
})
test('POST /account/resetpassword refuses a lapsed code', async () => {
const id = await accountWithEmail('reset3', 'reset3@example.com')
await recover('email=reset3%40example.com')
await plantCode(id, '123456')
await env.DB.prepare(
"UPDATE email_code SET expires_at = expires_at - 3600 WHERE account_id = ?1 AND purpose = 'password_reset'"
)
.bind(id)
.run()
const res = await reset('email=reset3%40example.com&code=123456&newPassword=brand-new')
expect(res.status).toBe(400)
expect(await pendingReset(id)).toBeNull()
})
test('POST /account/resetpassword refuses the same way for no recovery and an unknown address', async () => {
const sameError = {
success: false,
error: 'That code is wrong or has expired. Request a new recovery email and try again.',
}
await accountWithEmail('reset4', 'reset4@example.com')
const none = await reset('email=reset4%40example.com&code=123456&newPassword=brand-new')
expect(none.status).toBe(400)
expect(await none.json()).toEqual(sameError)
const unknown = await reset('email=nobody%40example.com&code=123456&newPassword=brand-new')
expect(unknown.status).toBe(400)
expect(await unknown.json()).toEqual(sameError)
})
test('POST /account/resetpassword refuses a missing field', async () => {
for (const body of [
'code=123456&newPassword=x',
'email=a%40b.com&newPassword=x',
'email=a%40b.com&code=123456',
]) {
const res = await reset(body)
expect(res.status).toBe(400)
expect(((await res.json()) as { success: boolean }).success).toBe(false)
}
})
test('the reset link carries the address and the code for the website', () => {
expect(resetPasswordUrl('https://www.rec.example.com', 'a+b@example.com', '012345')).toBe(
'https://www.rec.example.com/reset-password?email=a%2Bb%40example.com&code=012345'
)
})
test('GET /privileges/me/restrictions is an empty array for an unrestricted caller', async () => { test('GET /privileges/me/restrictions is an empty array for an unrestricted caller', async () => {
const token = await accessTokenFor('grant_type=create_account&platform_id=steam-restrict1') const token = await accessTokenFor('grant_type=create_account&platform_id=steam-restrict1')
const res = await exports.default.fetch(`${ORIGIN}/privileges/me/restrictions`, { const res = await exports.default.fetch(`${ORIGIN}/privileges/me/restrictions`, {
@@ -1959,7 +2248,7 @@ describe('auth worker routes', () => {
for (const id of [42, 77]) { for (const id of [42, 77]) {
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`) const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toBe(true) expect(await res.json()).toBe(false)
} }
}) })
@@ -2184,6 +2473,8 @@ describe('auth worker routes', () => {
'GET /role/moderator/{id}', 'GET /role/moderator/{id}',
'GET /role/screenshare/{id}', 'GET /role/screenshare/{id}',
'POST /account/me/changepassword', 'POST /account/me/changepassword',
'POST /account/recoverpassword',
'POST /account/resetpassword',
'POST /cachedlogin/forplatformid/{platform}/{id}', 'POST /cachedlogin/forplatformid/{platform}/{id}',
'POST /cachedlogin/forplatformids', 'POST /cachedlogin/forplatformids',
'POST /connect/device/approve', 'POST /connect/device/approve',
@@ -2579,3 +2870,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)
})
})
+953 -242
View File
@@ -1,5 +1,5 @@
/* eslint-disable */ /* eslint-disable */
// Runtime types generated with workerd@1.20260625.1 2025-09-20 nodejs_compat // Runtime types generated with workerd@1.20260831.1 2026-06-16 nodejs_compat
// Begin runtime types // Begin runtime types
/*! ***************************************************************************** /*! *****************************************************************************
Copyright (c) Cloudflare. All rights reserved. Copyright (c) Cloudflare. All rights reserved.
@@ -19,26 +19,26 @@ and limitations under the License.
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
declare var onmessage: never; declare var onmessage: never;
/** /**
* The **`DOMException`** interface represents an abnormal event (called an **exception**) that occurs as a result of calling a method or accessing a property of a web API. * The **`DOMException`** interface represents an abnormal event (called an exception) that occurs as a result of calling a method or accessing a property of a web API. This is how error conditions are described in web APIs.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException)
*/ */
declare class DOMException extends Error { declare class DOMException extends Error {
constructor(message?: string, name?: string); constructor(message?: string, name?: string);
/** /**
* The **`message`** read-only property of the a message or description associated with the given error name. * The **`message`** read-only property of the DOMException interface returns a string representing a message or description associated with the given error name.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message)
*/ */
readonly message: string; readonly message: string;
/** /**
* The **`name`** read-only property of the one of the strings associated with an error name. * The **`name`** read-only property of the DOMException interface returns a string that contains one of the strings associated with an error name.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name)
*/ */
readonly name: string; readonly name: string;
/** /**
* The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or `0` if none match. * The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or 0 if none match.
* @deprecated * @deprecated
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)
@@ -96,61 +96,61 @@ interface Console {
*/ */
clear(): void; clear(): void;
/** /**
* The **`console.count()`** static method logs the number of times that this particular call to `count()` has been called. * The **`console.count()`** static method logs the number of times that this particular call to count() has been called.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static)
*/ */
count(label?: string): void; count(label?: string): void;
/** /**
* The **`console.countReset()`** static method resets counter used with console/count_static. * The **`console.countReset()`** static method resets counter used with console.count().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static)
*/ */
countReset(label?: string): void; countReset(label?: string): void;
/** /**
* The **`console.debug()`** static method outputs a message to the console at the 'debug' log level. * The **`console.debug()`** static method outputs a message to the console at the "debug" log level. The message is only displayed to the user if the console is configured to display debug output. In most cases, the log level is configured within the console UI. This log level might correspond to the Debug or Verbose log level.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static)
*/ */
debug(...data: any[]): void; debug(...data: any[]): void;
/** /**
* The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object. * The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object. In browser consoles, the output is presented as a hierarchical listing with disclosure triangles that let you see the contents of child objects.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static)
*/ */
dir(item?: any, options?: any): void; dir(item?: any, options?: any): void;
/** /**
* The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element. * The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element. If it is not possible to display as an element the JavaScript Object view is shown instead. The output is presented as a hierarchical listing of expandable nodes that let you see the contents of child nodes.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static)
*/ */
dirxml(...data: any[]): void; dirxml(...data: any[]): void;
/** /**
* The **`console.error()`** static method outputs a message to the console at the 'error' log level. * The **`console.error()`** static method outputs a message to the console at the "error" log level. The message is only displayed to the user if the console is configured to display error output. In most cases, the log level is configured within the console UI. The message may be formatted as an error, with red colors and call stack information.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static)
*/ */
error(...data: any[]): void; error(...data: any[]): void;
/** /**
* The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console/groupEnd_static is called. * The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console.groupEnd() is called.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static)
*/ */
group(...data: any[]): void; group(...data: any[]): void;
/** /**
* The **`console.groupCollapsed()`** static method creates a new inline group in the console. * The **`console.groupCollapsed()`** static method creates a new inline group in the console. Unlike console.group(), however, the new group is created collapsed. The user will need to use the disclosure button next to it to expand it, revealing the entries created in the group.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static)
*/ */
groupCollapsed(...data: any[]): void; groupCollapsed(...data: any[]): void;
/** /**
* The **`console.groupEnd()`** static method exits the current inline group in the console. * The **`console.groupEnd()`** static method exits the current inline group in the console. See Using groups in the console in the console documentation for details and examples.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static)
*/ */
groupEnd(): void; groupEnd(): void;
/** /**
* The **`console.info()`** static method outputs a message to the console at the 'info' log level. * The **`console.info()`** static method outputs a message to the console at the "info" log level. The message is only displayed to the user if the console is configured to display info output. In most cases, the log level is configured within the console UI. The message may receive special formatting, such as a small "i" icon next to it.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static)
*/ */
@@ -168,23 +168,24 @@ interface Console {
*/ */
table(tabularData?: any, properties?: string[]): void; table(tabularData?: any, properties?: string[]): void;
/** /**
* The **`console.time()`** static method starts a timer you can use to track how long an operation takes. * The **`console.time()`** static method starts a timer you can use to track how long an operation takes. You give each timer a unique name, and may have up to 10,000 timers running on a given page. When you call console.timeEnd() with the same name, the browser will output the time, in milliseconds, that elapsed since the timer was started.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static)
*/ */
time(label?: string): void; time(label?: string): void;
/** /**
* The **`console.timeEnd()`** static method stops a timer that was previously started by calling console/time_static. * The **`console.timeEnd()`** static method stops a timer that was previously started by calling console.time().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static)
*/ */
timeEnd(label?: string): void; timeEnd(label?: string): void;
/** /**
* The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console/time_static. * The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console.time().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static)
*/ */
timeLog(label?: string, ...data: any[]): void; timeLog(label?: string, ...data: any[]): void;
/* The **`console.timeStamp()`** static method adds a single marker to the browser's Performance tool (Firefox bug 1387528, Chrome). This lets you correlate a point in your code with the other events recorded in the timeline, such as layout and paint events. */
timeStamp(label?: string): void; timeStamp(label?: string): void;
/** /**
* The **`console.trace()`** static method outputs a stack trace to the console. * The **`console.trace()`** static method outputs a stack trace to the console.
@@ -193,7 +194,7 @@ interface Console {
*/ */
trace(...data: any[]): void; trace(...data: any[]): void;
/** /**
* The **`console.warn()`** static method outputs a warning message to the console at the 'warning' log level. * The **`console.warn()`** static method outputs a warning message to the console at the "warning" log level. The message is only displayed to the user if the console is configured to display warning output. In most cases, the log level is configured within the console UI. The message may receive special formatting, such as yellow colors and a warning icon.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static)
*/ */
@@ -359,7 +360,7 @@ interface ServiceWorkerGlobalScope extends WorkerGlobalScope {
declare function addEventListener<Type extends keyof WorkerGlobalScopeEventMap>(type: Type, handler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>, options?: EventTargetAddEventListenerOptions | boolean): void; declare function addEventListener<Type extends keyof WorkerGlobalScopeEventMap>(type: Type, handler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>, options?: EventTargetAddEventListenerOptions | boolean): void;
declare function removeEventListener<Type extends keyof WorkerGlobalScopeEventMap>(type: Type, handler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>, options?: EventTargetEventListenerOptions | boolean): void; declare function removeEventListener<Type extends keyof WorkerGlobalScopeEventMap>(type: Type, handler: EventListenerOrEventListenerObject<WorkerGlobalScopeEventMap[Type]>, options?: EventTargetEventListenerOptions | boolean): void;
/** /**
* The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. The normal event processing rules (including the capturing and optional bubbling phase) also apply to events dispatched manually with dispatchEvent().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
*/ */
@@ -404,6 +405,7 @@ declare const crypto: Crypto;
* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
*/ */
declare const caches: CacheStorage; declare const caches: CacheStorage;
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/scheduler) */
declare const scheduler: Scheduler; declare const scheduler: Scheduler;
/** /**
* The Workers runtime supports a subset of the Performance API, used to measure timing and performance, * The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
@@ -420,10 +422,12 @@ interface TestController {
interface ExecutionContext<Props = unknown> { interface ExecutionContext<Props = unknown> {
waitUntil(promise: Promise<any>): void; waitUntil(promise: Promise<any>): void;
passThroughOnException(): void; passThroughOnException(): void;
readonly exports: Cloudflare.Exports;
readonly props: Props; readonly props: Props;
cache?: CacheContext; cache?: CacheContext;
readonly access?: CloudflareAccessContext; readonly access?: CloudflareAccessContext;
tracing: Tracing; tracing: Tracing;
abort(reason?: any): void;
} }
type ExportedHandlerFetchHandler<Env = unknown, CfHostMetadata = unknown, Props = unknown> = (request: Request<CfHostMetadata, IncomingRequestCfProperties<CfHostMetadata>>, env: Env, ctx: ExecutionContext<Props>) => Response | Promise<Response>; type ExportedHandlerFetchHandler<Env = unknown, CfHostMetadata = unknown, Props = unknown> = (request: Request<CfHostMetadata, IncomingRequestCfProperties<CfHostMetadata>>, env: Env, ctx: ExecutionContext<Props>) => Response | Promise<Response>;
type ExportedHandlerConnectHandler<Env = unknown, Props = unknown> = (socket: Socket, env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>; type ExportedHandlerConnectHandler<Env = unknown, Props = unknown> = (socket: Socket, env: Env, ctx: ExecutionContext<Props>) => void | Promise<void>;
@@ -512,7 +516,7 @@ declare abstract class DurableObjectNamespace<T extends Rpc.DurableObjectBranded
getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub<T>; getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub<T>;
jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace<T>; jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace<T>;
} }
type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high"; type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us";
interface DurableObjectNamespaceNewUniqueIdOptions { interface DurableObjectNamespaceNewUniqueIdOptions {
jurisdiction?: DurableObjectJurisdiction; jurisdiction?: DurableObjectJurisdiction;
} }
@@ -526,6 +530,7 @@ interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = u
} }
interface DurableObjectState<Props = unknown> { interface DurableObjectState<Props = unknown> {
waitUntil(promise: Promise<any>): void; waitUntil(promise: Promise<any>): void;
readonly exports: Cloudflare.Exports;
readonly props: Props; readonly props: Props;
readonly id: DurableObjectId; readonly id: DurableObjectId;
readonly storage: DurableObjectStorage; readonly storage: DurableObjectStorage;
@@ -540,7 +545,7 @@ interface DurableObjectState<Props = unknown> {
setHibernatableWebSocketEventTimeout(timeoutMs?: number): void; setHibernatableWebSocketEventTimeout(timeoutMs?: number): void;
getHibernatableWebSocketEventTimeout(): number | null; getHibernatableWebSocketEventTimeout(): number | null;
getTags(ws: WebSocket): string[]; getTags(ws: WebSocket): string[];
abort(reason?: string): void; abort(reason?: string, options?: DurableObjectAbortOptions): void;
} }
interface DurableObjectTransaction { interface DurableObjectTransaction {
get<T = unknown>(key: string, options?: DurableObjectGetOptions): Promise<T | undefined>; get<T = unknown>(key: string, options?: DurableObjectGetOptions): Promise<T | undefined>;
@@ -576,6 +581,9 @@ interface DurableObjectStorage {
getBookmarkForTime(timestamp: number | Date): Promise<string>; getBookmarkForTime(timestamp: number | Date): Promise<string>;
onNextSessionRestoreBookmark(bookmark: string): Promise<string>; onNextSessionRestoreBookmark(bookmark: string): Promise<string>;
} }
interface DurableObjectAbortOptions {
retryAlarm?: boolean;
}
interface DurableObjectListOptions { interface DurableObjectListOptions {
start?: string; start?: string;
startAfter?: string; startAfter?: string;
@@ -626,26 +634,26 @@ interface AnalyticsEngineDataPoint {
blobs?: ((ArrayBuffer | string) | null)[]; blobs?: ((ArrayBuffer | string) | null)[];
} }
/** /**
* The **`Event`** interface represents an event which takes place on an `EventTarget`. * The **`Event`** interface represents an event which takes place on an EventTarget.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event)
*/ */
declare class Event { declare class Event {
constructor(type: string, init?: EventInit); constructor(type: string, init?: EventInit);
/** /**
* The **`type`** read-only property of the Event interface returns a string containing the event's type. * The **`type`** read-only property of the Event interface returns a string containing the event's type. It is set when the event is constructed and is the name commonly used to refer to the specific event, such as click, load, or error.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type)
*/ */
get type(): string; get type(): string;
/** /**
* The **`eventPhase`** read-only property of the being evaluated. * The **`eventPhase`** read-only property of the Event interface indicates which phase of the event flow is currently being evaluated.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase)
*/ */
get eventPhase(): number; get eventPhase(): number;
/** /**
* The read-only **`composed`** property of the or not the event will propagate across the shadow DOM boundary into the standard DOM. * The read-only **`composed`** property of the Event interface returns a boolean value which indicates whether or not the event will propagate across the shadow DOM boundary into the standard DOM.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed)
*/ */
@@ -682,13 +690,13 @@ declare class Event {
*/ */
get currentTarget(): EventTarget | undefined; get currentTarget(): EventTarget | undefined;
/** /**
* The read-only **`target`** property of the dispatched. * The read-only **`target`** property of the Event interface is a reference to the object onto which the event was dispatched. It is different from Event.currentTarget when the event handler is called during the bubbling or capturing phase of the event.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target)
*/ */
get target(): EventTarget | undefined; get target(): EventTarget | undefined;
/** /**
* The deprecated **`Event.srcElement`** is an alias for the Event.target property. * The deprecated **`Event.srcElement`** is an alias for the Event.target property. Use Event.target instead.
* @deprecated * @deprecated
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement)
@@ -701,45 +709,45 @@ declare class Event {
*/ */
get timeStamp(): number; get timeStamp(): number;
/** /**
* The **`isTrusted`** read-only property of the when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and `false` when the event was dispatched via The only exception is the `click` event, which initializes the `isTrusted` property to `false` in user agents. * The **`isTrusted`** read-only property of the Event interface is a boolean value that is true when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and false when the event was dispatched via EventTarget.dispatchEvent(). The only exception is the click event, which initializes the isTrusted property to false in user agents.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted)
*/ */
get isTrusted(): boolean; get isTrusted(): boolean;
/** /**
* The **`cancelBubble`** property of the Event interface is deprecated. * The **`cancelBubble`** property of the Event interface is deprecated. Use Event.stopPropagation() instead. Setting its value to true before returning from an event handler prevents propagation of the event. In later implementations, setting this to false does nothing. See Browser compatibility for details.
* @deprecated * @deprecated
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
*/ */
get cancelBubble(): boolean; get cancelBubble(): boolean;
/** /**
* The **`cancelBubble`** property of the Event interface is deprecated. * The **`cancelBubble`** property of the Event interface is deprecated. Use Event.stopPropagation() instead. Setting its value to true before returning from an event handler prevents propagation of the event. In later implementations, setting this to false does nothing. See Browser compatibility for details.
* @deprecated * @deprecated
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
*/ */
set cancelBubble(value: boolean); set cancelBubble(value: boolean);
/** /**
* The **`stopImmediatePropagation()`** method of the If several listeners are attached to the same element for the same event type, they are called in the order in which they were added. * The **`stopImmediatePropagation()`** method of the Event interface prevents other listeners of the same event from being called.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation)
*/ */
stopImmediatePropagation(): void; stopImmediatePropagation(): void;
/** /**
* The **`preventDefault()`** method of the Event interface tells the user agent that if the event does not get explicitly handled, its default action should not be taken as it normally would be. * The **`preventDefault()`** method of the Event interface tells the user agent that the event is being explicitly handled, so its default action, such as page scrolling, link navigation, or pasting text, should not be taken.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault)
*/ */
preventDefault(): void; preventDefault(): void;
/** /**
* The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases. * The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases. It does not, however, prevent any default behaviors from occurring; for instance, clicks on links are still processed. If you want to stop those behaviors, see the preventDefault() method. It also does not prevent propagation to other event-handlers of the current element. If you want to stop those, see stopImmediatePropagation().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation)
*/ */
stopPropagation(): void; stopPropagation(): void;
/** /**
* The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked. * The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked. This does not include nodes in shadow trees if the shadow root was created with its ShadowRoot.mode closed.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath)
*/ */
@@ -760,7 +768,7 @@ interface EventListenerObject<EventType extends Event = Event> {
} }
type EventListenerOrEventListenerObject<EventType extends Event = Event> = EventListener<EventType> | EventListenerObject<EventType>; type EventListenerOrEventListenerObject<EventType extends Event = Event> = EventListener<EventType> | EventListenerObject<EventType>;
/** /**
* The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them. * The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them. In other words, any target of events implements the three methods associated with this interface.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget)
*/ */
@@ -773,13 +781,13 @@ declare class EventTarget<EventMap extends Record<string, Event> = Record<string
*/ */
addEventListener<Type extends keyof EventMap>(type: Type, handler: EventListenerOrEventListenerObject<EventMap[Type]>, options?: EventTargetAddEventListenerOptions | boolean): void; addEventListener<Type extends keyof EventMap>(type: Type, handler: EventListenerOrEventListenerObject<EventMap[Type]>, options?: EventTargetAddEventListenerOptions | boolean): void;
/** /**
* The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target. * The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target. The event listener to be removed is identified using a combination of the event type, the event listener function itself, and various optional options that may affect the matching process; see Matching event listeners for removal.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener)
*/ */
removeEventListener<Type extends keyof EventMap>(type: Type, handler: EventListenerOrEventListenerObject<EventMap[Type]>, options?: EventTargetEventListenerOptions | boolean): void; removeEventListener<Type extends keyof EventMap>(type: Type, handler: EventListenerOrEventListenerObject<EventMap[Type]>, options?: EventTargetEventListenerOptions | boolean): void;
/** /**
* The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. The normal event processing rules (including the capturing and optional bubbling phase) also apply to events dispatched manually with dispatchEvent().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
*/ */
@@ -811,7 +819,7 @@ declare class AbortController {
*/ */
get signal(): AbortSignal; get signal(): AbortSignal;
/** /**
* The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed. * The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed. This is able to abort fetch requests, the consumption of any response bodies, or streams.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort)
*/ */
@@ -824,7 +832,7 @@ declare class AbortController {
*/ */
declare abstract class AbortSignal extends EventTarget { declare abstract class AbortSignal extends EventTarget {
/** /**
* The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an AbortSignal/abort_event event). * The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an abort event).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static)
*/ */
@@ -836,13 +844,13 @@ declare abstract class AbortSignal extends EventTarget {
*/ */
static timeout(delay: number): AbortSignal; static timeout(delay: number): AbortSignal;
/** /**
* The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal. * The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal. The returned abort signal is aborted when any of the input iterable abort signals are aborted. The abort reason will be set to the reason of the first signal that is aborted. If any of the given abort signals are already aborted then so will be the returned AbortSignal.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static)
*/ */
static any(signals: AbortSignal[]): AbortSignal; static any(signals: AbortSignal[]): AbortSignal;
/** /**
* The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (`true`) or not (`false`). * The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (true) or not (false).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted)
*/ */
@@ -858,12 +866,17 @@ declare abstract class AbortSignal extends EventTarget {
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */
set onabort(value: any | null); set onabort(value: any | null);
/** /**
* The **`throwIfAborted()`** method throws the signal's abort AbortSignal.reason if the signal has been aborted; otherwise it does nothing. * The **`throwIfAborted()`** method throws the signal's abort reason if the signal has been aborted; otherwise it does nothing.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted)
*/ */
throwIfAborted(): void; throwIfAborted(): void;
} }
/**
* The **`Scheduler`** interface of the Prioritized Task Scheduling API provides methods for scheduling prioritized tasks.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Scheduler)
*/
interface Scheduler { interface Scheduler {
wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise<void>; wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise<void>;
} }
@@ -871,20 +884,20 @@ interface SchedulerWaitOptions {
signal?: AbortSignal; signal?: AbortSignal;
} }
/** /**
* The **`ExtendableEvent`** interface extends the lifetime of the `install` and `activate` events dispatched on the global scope as part of the service worker lifecycle. * The **`ExtendableEvent`** interface extends the lifetime of the install and activate events dispatched on the global scope as part of the service worker lifecycle. This ensures that any functional events (like FetchEvent) are not dispatched until it upgrades database schemas and deletes the outdated cache entries.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent)
*/ */
declare abstract class ExtendableEvent extends Event { declare abstract class ExtendableEvent extends Event {
/** /**
* The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing. * The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing. It can also be used to detect whether that work was successful. In service workers, waitUntil() tells the browser that work is ongoing until the promise settles, and it shouldn't terminate the service worker if it wants that work to complete.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil)
*/ */
waitUntil(promise: Promise<any>): void; waitUntil(promise: Promise<any>): void;
} }
/** /**
* The **`CustomEvent`** interface represents events initialized by an application for any purpose. * The **`CustomEvent`** interface can be used to attach custom data to an event generated by an application.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent)
*/ */
@@ -923,7 +936,7 @@ declare class Blob {
*/ */
get type(): string; get type(): string;
/** /**
* The **`slice()`** method of the Blob interface creates and returns a new `Blob` object which contains data from a subset of the blob on which it's called. * The **`slice()`** method of the Blob interface creates and returns a new Blob object which contains data from a subset of the blob on which it's called.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice)
*/ */
@@ -941,13 +954,13 @@ declare class Blob {
*/ */
bytes(): Promise<Uint8Array>; bytes(): Promise<Uint8Array>;
/** /**
* The **`text()`** method of the string containing the contents of the blob, interpreted as UTF-8. * The **`text()`** method of the Blob interface returns a Promise that resolves with a string containing the contents of the blob, interpreted as UTF-8.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text)
*/ */
text(): Promise<string>; text(): Promise<string>;
/** /**
* The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the `Blob`. * The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the Blob.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream)
*/ */
@@ -964,13 +977,13 @@ interface BlobOptions {
declare class File extends Blob { declare class File extends Blob {
constructor(bits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined, name: string, options?: FileOptions); constructor(bits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined, name: string, options?: FileOptions);
/** /**
* The **`name`** read-only property of the File interface returns the name of the file represented by a File object. * The **`name`** read-only property of the File interface returns the name of the file represented by a File object. For security reasons, the path is excluded from this property.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name)
*/ */
get name(): string; get name(): string;
/** /**
* The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). * The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). Files without a known last modified date return the current date.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified)
*/ */
@@ -987,7 +1000,7 @@ interface FileOptions {
*/ */
declare abstract class CacheStorage { declare abstract class CacheStorage {
/** /**
* The **`open()`** method of the the Cache object matching the `cacheName`. * The **`open()`** method of the CacheStorage interface returns a Promise that resolves to the Cache object matching the cacheName.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open)
*/ */
@@ -1020,14 +1033,14 @@ interface CacheQueryOptions {
*/ */
declare abstract class Crypto { declare abstract class Crypto {
/** /**
* The **`Crypto.subtle`** read-only property returns a cryptographic operations. * The **`Crypto.subtle`** read-only property returns a SubtleCrypto which can then be used to perform low-level cryptographic operations.
* Available only in secure contexts. * Available only in secure contexts.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle)
*/ */
get subtle(): SubtleCrypto; get subtle(): SubtleCrypto;
/** /**
* The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values. * The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values. The array given as the parameter is filled with random numbers (random in its cryptographic meaning).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues)
*/ */
@@ -1055,7 +1068,7 @@ declare abstract class SubtleCrypto {
*/ */
encrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, plainText: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer>; encrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, plainText: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer>;
/** /**
* The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data. * The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data. It takes as arguments a key to decrypt with, some optional extra parameters, and the data to decrypt (also known as "ciphertext"). It returns a Promise which will be fulfilled with the decrypted data (also known as "plaintext").
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt)
*/ */
@@ -1073,7 +1086,7 @@ declare abstract class SubtleCrypto {
*/ */
verify(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise<boolean>; verify(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise<boolean>;
/** /**
* The **`digest()`** method of the SubtleCrypto interface generates a _digest_ of the given data, using the specified hash function. * The **`digest()`** method of the SubtleCrypto interface generates a digest of the given data, using the specified hash function. A digest is a short fixed-length value derived from some variable-length input. Cryptographic digests should exhibit collision-resistance, meaning that it's hard to come up with two different inputs that have the same digest value.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest)
*/ */
@@ -1091,7 +1104,7 @@ declare abstract class SubtleCrypto {
*/ */
deriveKey(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, derivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise<CryptoKey>; deriveKey(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, derivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise<CryptoKey>;
/** /**
* The **`deriveBits()`** method of the key. * The **`deriveBits()`** method of the SubtleCrypto interface can be used to derive an array of bits from a base key.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits)
*/ */
@@ -1109,13 +1122,13 @@ declare abstract class SubtleCrypto {
*/ */
exportKey(format: string, key: CryptoKey): Promise<ArrayBuffer | JsonWebKey>; exportKey(format: string, key: CryptoKey): Promise<ArrayBuffer | JsonWebKey>;
/** /**
* The **`wrapKey()`** method of the SubtleCrypto interface 'wraps' a key. * The **`wrapKey()`** method of the SubtleCrypto interface "wraps" a key. This means that it exports the key in an external, portable format, then encrypts the exported key. Wrapping a key helps protect it in untrusted environments, such as inside an otherwise unprotected data store or in transmission over an unprotected network.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey)
*/ */
wrapKey(format: string, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: string | SubtleCryptoEncryptAlgorithm): Promise<ArrayBuffer>; wrapKey(format: string, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: string | SubtleCryptoEncryptAlgorithm): Promise<ArrayBuffer>;
/** /**
* The **`unwrapKey()`** method of the SubtleCrypto interface 'unwraps' a key. * The **`unwrapKey()`** method of the SubtleCrypto interface "unwraps" a key. This means that it takes as its input a key that has been exported and then encrypted (also called "wrapped"). It decrypts the key and then imports it, returning a CryptoKey object that can be used in the Web Crypto API.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey)
*/ */
@@ -1123,20 +1136,20 @@ declare abstract class SubtleCrypto {
timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean; timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean;
} }
/** /**
* The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods SubtleCrypto.generateKey, SubtleCrypto.deriveKey, SubtleCrypto.importKey, or SubtleCrypto.unwrapKey. * The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods generateKey(), deriveKey(), importKey(), or unwrapKey().
* Available only in secure contexts. * Available only in secure contexts.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey)
*/ */
declare abstract class CryptoKey { declare abstract class CryptoKey {
/** /**
* The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object. * The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object. It can have the following values:
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type)
*/ */
readonly type: string; readonly type: string;
/** /**
* The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using `SubtleCrypto.exportKey()` or `SubtleCrypto.wrapKey()`. * The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using SubtleCrypto.exportKey() or SubtleCrypto.wrapKey().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable)
*/ */
@@ -1253,12 +1266,15 @@ interface CryptoKeyArbitraryKeyAlgorithm {
length?: number; length?: number;
} }
declare class DigestStream extends WritableStream<ArrayBuffer | ArrayBufferView> { declare class DigestStream extends WritableStream<ArrayBuffer | ArrayBufferView> {
constructor(algorithm: string | SubtleCryptoHashAlgorithm); constructor(algorithm: string | SubtleCryptoHashAlgorithm, options?: DigestStreamOptions);
readonly digest: Promise<ArrayBuffer>; readonly digest: Promise<ArrayBuffer>;
get bytesWritten(): number | bigint; get bytesWritten(): number | bigint;
} }
interface DigestStreamOptions {
toWellFormed?: boolean;
}
/** /**
* The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as `UTF-8`, `ISO-8859-2`, `KOI8-R`, `GBK`, etc. * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as UTF-8, ISO-8859-2, or GBK. A decoder takes an array of bytes as input and returns a JavaScript string.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder)
*/ */
@@ -1275,20 +1291,20 @@ declare class TextDecoder {
get ignoreBOM(): boolean; get ignoreBOM(): boolean;
} }
/** /**
* The **`TextEncoder`** interface takes a stream of code points as input and emits a stream of UTF-8 bytes. * The **`TextEncoder`** interface enables you to encode a JavaScript string using UTF-8.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder)
*/ */
declare class TextEncoder { declare class TextEncoder {
constructor(); constructor();
/** /**
* The **`TextEncoder.encode()`** method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object. * The **`TextEncoder.encode()`** method takes a string as input, and returns a Uint8Array containing the string encoded using UTF-8.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode)
*/ */
encode(input?: string): Uint8Array; encode(input?: string): Uint8Array;
/** /**
* The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns a dictionary object indicating the progress of the encoding. * The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns an object indicating the progress of the encoding. This is potentially more performant than the encode() method — especially when the target buffer is a view into a Wasm heap.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto)
*/ */
@@ -1345,6 +1361,9 @@ declare class ErrorEvent extends Event {
get error(): any; get error(): any;
} }
interface ErrorEventErrorEventInit { interface ErrorEventErrorEventInit {
bubbles?: boolean;
cancelable?: boolean;
composed?: boolean;
message?: string; message?: string;
filename?: string; filename?: string;
lineno?: number; lineno?: number;
@@ -1357,135 +1376,139 @@ interface ErrorEventErrorEventInit {
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent)
*/ */
declare class MessageEvent extends Event { declare class MessageEvent extends Event {
constructor(type: string, initializer: MessageEventInit); constructor(type: string, initializer?: MessageEventInit);
/** /**
* The **`data`** read-only property of the The data sent by the message emitter; this can be any data type, depending on what originated this event. * The **`data`** read-only property of the MessageEvent interface represents the data sent by the message emitter.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data)
*/ */
readonly data: any; readonly data: any;
/** /**
* The **`origin`** read-only property of the origin of the message emitter. * The **`origin`** read-only property of the MessageEvent interface is a string representing the origin of the message emitter.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/origin) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/origin)
*/ */
readonly origin: string | null; readonly origin: string | null;
/** /**
* The **`lastEventId`** read-only property of the unique ID for the event. * The **`lastEventId`** read-only property of the MessageEvent interface is a string representing a unique ID for the event.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/lastEventId) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/lastEventId)
*/ */
readonly lastEventId: string; readonly lastEventId: string;
/** /**
* The **`source`** read-only property of the a WindowProxy, MessagePort, or a `MessageEventSource` (which can be a WindowProxy, message emitter. * The **`source`** read-only property of the MessageEvent interface is a MessageEventSource (which can be a WindowProxy, MessagePort, or ServiceWorker object) representing the message emitter.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/source) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/source)
*/ */
readonly source: MessagePort | null; readonly source: MessagePort | null;
/** /**
* The **`ports`** read-only property of the containing all MessagePort objects sent with the message, in order. * The **`ports`** read-only property of the MessageEvent interface is an array of MessagePort objects containing all MessagePort objects sent with the message, in order.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/ports) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/ports)
*/ */
readonly ports: MessagePort[]; readonly ports: MessagePort[];
} }
interface MessageEventInit { interface MessageEventInit {
data: ArrayBuffer | string; bubbles?: boolean;
cancelable?: boolean;
composed?: boolean;
data?: any;
origin?: string;
lastEventId?: string;
source?: MessagePort;
ports?: MessagePort[];
} }
/** /**
* The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected. * The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected. These events are particularly useful for telemetry and debugging purposes.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent)
*/ */
declare abstract class PromiseRejectionEvent extends Event { declare abstract class PromiseRejectionEvent extends Event {
/** /**
* The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript rejected. * The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript Promise which was rejected. You can examine the event's PromiseRejectionEvent.reason property to learn why the promise was rejected.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise)
*/ */
readonly promise: Promise<any>; readonly promise: Promise<any>;
/** /**
* The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject(). * The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject(). This in theory provides information about why the promise was rejected.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason)
*/ */
readonly reason: any; readonly reason: any;
} }
/** /**
* The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the Window/fetch, XMLHttpRequest.send() or navigator.sendBeacon() methods. * The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the fetch(), XMLHttpRequest.send() or navigator.sendBeacon() methods. It uses the same format a form would use if the encoding type were set to "multipart/form-data".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData)
*/ */
declare class FormData { declare class FormData {
constructor(); constructor();
/** /**
* The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)
*/ */
append(name: string, value: string | Blob): void; append(name: string, value: string | Blob): void;
/** /**
* The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)
*/ */
append(name: string, value: string): void; append(name: string, value: string): void;
/** /**
* The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append)
*/ */
append(name: string, value: Blob, filename?: string): void; append(name: string, value: Blob, filename?: string): void;
/** /**
* The **`delete()`** method of the FormData interface deletes a key and its value(s) from a `FormData` object. * The **`delete()`** method of the FormData interface deletes a key and its value(s) from a FormData object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete)
*/ */
delete(name: string): void; delete(name: string): void;
/** /**
* The **`get()`** method of the FormData interface returns the first value associated with a given key from within a `FormData` object. * The **`get()`** method of the FormData interface returns the first value associated with a given key from within a FormData object. If you expect multiple values and want all of them, use the getAll() method instead.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get)
*/ */
get(name: string): (File | string) | null; get(name: string): (File | string) | null;
/** /**
* The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a `FormData` object. * The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a FormData object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll)
*/ */
getAll(name: string): (File | string)[]; getAll(name: string): (File | string)[];
/** /**
* The **`has()`** method of the FormData interface returns whether a `FormData` object contains a certain key. * The **`has()`** method of the FormData interface returns whether a FormData object contains a certain key.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has)
*/ */
has(name: string): boolean; has(name: string): boolean;
/** /**
* The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)
*/ */
set(name: string, value: string | Blob): void; set(name: string, value: string | Blob): void;
/** /**
* The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)
*/ */
set(name: string, value: string): void; set(name: string, value: string): void;
/** /**
* The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set)
*/ */
set(name: string, value: Blob, filename?: string): void; set(name: string, value: Blob, filename?: string): void;
/* Returns an array of key, value pairs for every entry in the list. */
entries(): IterableIterator<[ entries(): IterableIterator<[
key: string, key: string,
value: File | string value: File | string
]>; ]>;
/* Returns a list of keys in the list. */
keys(): IterableIterator<string>; keys(): IterableIterator<string>;
/* Returns a list of values in the list. */
values(): IterableIterator<(File | string)>; values(): IterableIterator<(File | string)>;
forEach<This = unknown>(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void; forEach<This = unknown>(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void;
[Symbol.iterator](): IterableIterator<[ [Symbol.iterator](): IterableIterator<[
@@ -1564,19 +1587,19 @@ interface DocumentEnd {
append(content: string, options?: ContentOptions): DocumentEnd; append(content: string, options?: ContentOptions): DocumentEnd;
} }
/** /**
* This is the event type for `fetch` events dispatched on the ServiceWorkerGlobalScope. * This is the event type for fetch events dispatched on the service worker global scope. It contains information about the fetch, including the request and how the receiver will treat the response. It provides the event.respondWith() method, which allows us to provide a response to this fetch.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent)
*/ */
declare abstract class FetchEvent extends ExtendableEvent { declare abstract class FetchEvent extends ExtendableEvent {
/** /**
* The **`request`** read-only property of the the event handler. * The **`request`** read-only property of the FetchEvent interface returns the Request that triggered the event handler.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request)
*/ */
readonly request: Request; readonly request: Request;
/** /**
* The **`respondWith()`** method of allows you to provide a promise for a Response yourself. * The **`respondWith()`** method of FetchEvent prevents the browser's default fetch handling, and allows you to provide a promise for a Response yourself.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith)
*/ */
@@ -1585,65 +1608,62 @@ declare abstract class FetchEvent extends ExtendableEvent {
} }
type HeadersInit = Headers | Iterable<Iterable<string>> | Record<string, string>; type HeadersInit = Headers | Iterable<Iterable<string>> | Record<string, string>;
/** /**
* The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers. * The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers. These actions include retrieving, setting, adding to, and removing headers from the list of the request's headers.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers)
*/ */
declare class Headers { declare class Headers {
constructor(init?: HeadersInit); constructor(init?: HeadersInit);
/** /**
* The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a `Headers` object with a given name. * The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a Headers object with a given name. If the requested header doesn't exist in the Headers object, it returns null.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get)
*/ */
get(name: string): string | null; get(name: string): string | null;
getAll(name: string): string[]; getAll(name: string): string[];
/** /**
* The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response. * The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response. This allows Headers objects to handle having multiple Set-Cookie headers, which wasn't possible prior to its implementation.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie)
*/ */
getSetCookie(): string[]; getSetCookie(): string[];
/** /**
* The **`has()`** method of the Headers interface returns a boolean stating whether a `Headers` object contains a certain header. * The **`has()`** method of the Headers interface returns a boolean stating whether a Headers object contains a certain header.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has)
*/ */
has(name: string): boolean; has(name: string): boolean;
/** /**
* The **`set()`** method of the Headers interface sets a new value for an existing header inside a `Headers` object, or adds the header if it does not already exist. * The **`set()`** method of the Headers interface sets a new value for an existing header inside a Headers object, or adds the header if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set)
*/ */
set(name: string, value: string): void; set(name: string, value: string): void;
/** /**
* The **`append()`** method of the Headers interface appends a new value onto an existing header inside a `Headers` object, or adds the header if it does not already exist. * The **`append()`** method of the Headers interface appends a new value onto an existing header inside a Headers object, or adds the header if it does not already exist.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append)
*/ */
append(name: string, value: string): void; append(name: string, value: string): void;
/** /**
* The **`delete()`** method of the Headers interface deletes a header from the current `Headers` object. * The **`delete()`** method of the Headers interface deletes a header from the current Headers object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete)
*/ */
delete(name: string): void; delete(name: string): void;
forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: Headers) => void, thisArg?: This): void; forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: Headers) => void, thisArg?: This): void;
/* Returns an iterator allowing to go through all key/value pairs contained in this object. */
entries(): IterableIterator<[ entries(): IterableIterator<[
key: string, key: string,
value: string value: string
]>; ]>;
/* Returns an iterator allowing to go through all keys of the key/value pairs contained in this object. */
keys(): IterableIterator<string>; keys(): IterableIterator<string>;
/* Returns an iterator allowing to go through all values of the key/value pairs contained in this object. */
values(): IterableIterator<string>; values(): IterableIterator<string>;
[Symbol.iterator](): IterableIterator<[ [Symbol.iterator](): IterableIterator<[
key: string, key: string,
value: string value: string
]>; ]>;
} }
type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData; type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable<ArrayBuffer | ArrayBufferView> | AsyncIterable<ArrayBuffer | ArrayBufferView>;
declare abstract class Body { declare abstract class Body {
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */ /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */
get body(): ReadableStream | null; get body(): ReadableStream | null;
@@ -1699,7 +1719,7 @@ interface Response extends Body {
*/ */
statusText: string; statusText: string;
/** /**
* The **`headers`** read-only property of the with the response. * The **`headers`** read-only property of the Response interface contains the Headers object associated with the response.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers)
*/ */
@@ -1717,7 +1737,7 @@ interface Response extends Body {
*/ */
redirected: boolean; redirected: boolean;
/** /**
* The **`url`** read-only property of the Response interface contains the URL of the response. * The **`url`** read-only property of the Response interface contains the URL of the response. The value of the url property will be the final URL obtained after any redirects.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url)
*/ */
@@ -1725,7 +1745,7 @@ interface Response extends Body {
webSocket: WebSocket | null; webSocket: WebSocket | null;
cf: any | undefined; cf: any | undefined;
/** /**
* The **`type`** read-only property of the Response interface contains the type of the response. * The **`type`** read-only property of the Response interface contains the type of the response. The type determines whether scripts are able to access the response body and headers.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type)
*/ */
@@ -1756,13 +1776,13 @@ declare var Request: {
*/ */
interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> extends Body { interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> extends Body {
/** /**
* The **`clone()`** method of the Request interface creates a copy of the current `Request` object. * The **`clone()`** method of the Request interface creates a copy of the current Request object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone)
*/ */
clone(): Request<CfHostMetadata, Cf>; clone(): Request<CfHostMetadata, Cf>;
/** /**
* The **`method`** read-only property of the `POST`, etc.) A String indicating the method of the request. * The **`method`** read-only property of the Request interface contains the request's method (GET, POST, etc.)
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method)
*/ */
@@ -1774,7 +1794,7 @@ interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> e
*/ */
url: string; url: string;
/** /**
* The **`headers`** read-only property of the with the request. * The **`headers`** read-only property of the Request interface contains the Headers object associated with the request.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers)
*/ */
@@ -1800,13 +1820,13 @@ interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> e
*/ */
integrity: string; integrity: string;
/** /**
* The **`keepalive`** read-only property of the Request interface contains the request's `keepalive` setting (`true` or `false`), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete. * The **`keepalive`** read-only property of the Request interface contains the request's keepalive setting (true or false), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive)
*/ */
keepalive: boolean; keepalive: boolean;
/** /**
* The **`cache`** read-only property of the Request interface contains the cache mode of the request. * The **`cache`** read-only property of the Request interface contains the cache mode of the request. It controls how the request will interact with the browser's HTTP cache.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache)
*/ */
@@ -2195,7 +2215,7 @@ type ReadableStreamReadResult<R = any> = {
value?: undefined; value?: undefined;
}; };
/** /**
* The `ReadableStream` interface of the Streams API represents a readable stream of byte data. * The **`ReadableStream`** interface of the Streams API represents a readable stream of byte data. The Fetch API offers a concrete instance of a ReadableStream through the body property of a Response object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream)
*/ */
@@ -2213,13 +2233,13 @@ interface ReadableStream<R = any> {
*/ */
cancel(reason?: any): Promise<void>; cancel(reason?: any): Promise<void>;
/** /**
* The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. While the stream is locked, no other reader can be acquired until this one is released.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader)
*/ */
getReader(): ReadableStreamDefaultReader<R>; getReader(): ReadableStreamDefaultReader<R>;
/** /**
* The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. While the stream is locked, no other reader can be acquired until this one is released.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader)
*/ */
@@ -2231,13 +2251,13 @@ interface ReadableStream<R = any> {
*/ */
pipeThrough<T>(transform: ReadableWritablePair<T, R>, options?: StreamPipeOptions): ReadableStream<T>; pipeThrough<T>(transform: ReadableWritablePair<T, R>, options?: StreamPipeOptions): ReadableStream<T>;
/** /**
* The **`pipeTo()`** method of the ReadableStream interface pipes the current `ReadableStream` to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. * The **`pipeTo()`** method of the ReadableStream interface pipes the current ReadableStream to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo)
*/ */
pipeTo(destination: WritableStream<R>, options?: StreamPipeOptions): Promise<void>; pipeTo(destination: WritableStream<R>, options?: StreamPipeOptions): Promise<void>;
/** /**
* The **`tee()`** method of the two-element array containing the two resulting branches as new ReadableStream instances. * The **`tee()`** method of the ReadableStream interface tees the current readable stream, returning a two-element array containing the two resulting branches as new ReadableStream instances.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/tee) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/tee)
*/ */
@@ -2249,7 +2269,7 @@ interface ReadableStream<R = any> {
[Symbol.asyncIterator](options?: ReadableStreamValuesOptions): AsyncIterableIterator<R>; [Symbol.asyncIterator](options?: ReadableStreamValuesOptions): AsyncIterableIterator<R>;
} }
/** /**
* The `ReadableStream` interface of the Streams API represents a readable stream of byte data. * The **`ReadableStream`** interface of the Streams API represents a readable stream of byte data. The Fetch API offers a concrete instance of a ReadableStream through the body property of a Response object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream)
*/ */
@@ -2281,7 +2301,7 @@ declare class ReadableStreamDefaultReader<R = any> {
releaseLock(): void; releaseLock(): void;
} }
/** /**
* The `ReadableStreamBYOBReader` interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source. * The **`ReadableStreamBYOBReader`** interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source. It is used for efficient copying from underlying sources where the data is delivered as an "anonymous" sequence of bytes, such as files.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader)
*/ */
@@ -2290,13 +2310,13 @@ declare class ReadableStreamBYOBReader {
get closed(): Promise<void>; get closed(): Promise<void>;
cancel(reason?: any): Promise<void>; cancel(reason?: any): Promise<void>;
/** /**
* The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream. * The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream. A request for data will be satisfied from the stream's internal queues if there is any data present. If the stream queues are empty, the request may be supplied as a zero-copy transfer from the underlying byte source.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/read) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/read)
*/ */
read<T extends ArrayBufferView>(view: T): Promise<ReadableStreamReadResult<T>>; read<T extends ArrayBufferView>(view: T): Promise<ReadableStreamReadResult<T>>;
/** /**
* The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream. * The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream. After the lock is released, the reader is no longer active.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/releaseLock) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/releaseLock)
*/ */
@@ -2315,7 +2335,7 @@ interface ReadableStreamGetReaderOptions {
mode: "byob"; mode: "byob";
} }
/** /**
* The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a 'pull request' for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues). * The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a "pull request" for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest)
*/ */
@@ -2341,13 +2361,13 @@ declare abstract class ReadableStreamBYOBRequest {
get atLeast(): number | null; get atLeast(): number | null;
} }
/** /**
* The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue. * The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue. Default controllers are for streams that are not byte streams.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController)
*/ */
declare abstract class ReadableStreamDefaultController<R = any> { declare abstract class ReadableStreamDefaultController<R = any> {
/** /**
* The **`desiredSize`** read-only property of the required to fill the stream's internal queue. * The **`desiredSize`** read-only property of the ReadableStreamDefaultController interface returns the desired size required to fill the stream's internal queue.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/desiredSize) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/desiredSize)
*/ */
@@ -2359,32 +2379,32 @@ declare abstract class ReadableStreamDefaultController<R = any> {
*/ */
close(): void; close(): void;
/** /**
* The **`enqueue()`** method of the ```js-nolint enqueue(chunk) ``` - `chunk` - : The chunk to enqueue. * The **`enqueue()`** method of the ReadableStreamDefaultController interface enqueues a given chunk in the associated stream.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/enqueue) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/enqueue)
*/ */
enqueue(chunk?: R): void; enqueue(chunk?: R): void;
/** /**
* The **`error()`** method of the with the associated stream to error. * The **`error()`** method of the ReadableStreamDefaultController interface causes any future interactions with the associated stream to error.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/error) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/error)
*/ */
error(reason: any): void; error(reason: any): void;
} }
/** /**
* The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream. * The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream. It allows control of the state and internal queue of a ReadableStream with an underlying byte source, and enables efficient zero-copy transfer of data from the underlying source to a consumer when the stream's internal queue is empty.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController)
*/ */
declare abstract class ReadableByteStreamController { declare abstract class ReadableByteStreamController {
/** /**
* The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or `null` if there are no pending requests. * The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or null if there are no pending requests.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/byobRequest) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/byobRequest)
*/ */
get byobRequest(): ReadableStreamBYOBRequest | null; get byobRequest(): ReadableStreamBYOBRequest | null;
/** /**
* The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its 'desired size'. * The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its "desired size".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/desiredSize) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/desiredSize)
*/ */
@@ -2396,7 +2416,7 @@ declare abstract class ReadableByteStreamController {
*/ */
close(): void; close(): void;
/** /**
* The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is copied into the stream's internal queues). * The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is transferred into the stream's internal queues).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/enqueue) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/enqueue)
*/ */
@@ -2409,7 +2429,7 @@ declare abstract class ReadableByteStreamController {
error(reason: any): void; error(reason: any): void;
} }
/** /**
* The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state. * The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state. When constructing a WritableStream, the underlying sink is given a corresponding WritableStreamDefaultController instance to manipulate.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController)
*/ */
@@ -2421,7 +2441,7 @@ declare abstract class WritableStreamDefaultController {
*/ */
get signal(): AbortSignal; get signal(): AbortSignal;
/** /**
* The **`error()`** method of the with the associated stream to error. * The **`error()`** method of the WritableStreamDefaultController interface causes any future interactions with the associated stream to error.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/error) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/error)
*/ */
@@ -2446,7 +2466,7 @@ declare abstract class TransformStreamDefaultController<O = any> {
*/ */
enqueue(chunk?: O): void; enqueue(chunk?: O): void;
/** /**
* The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream. * The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream. Any further interactions with it will fail with the given error message, and any chunks in the queue will be discarded.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/error) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/error)
*/ */
@@ -2468,14 +2488,14 @@ interface ReadableWritablePair<R = any, W = any> {
writable: WritableStream<W>; writable: WritableStream<W>;
} }
/** /**
* The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. * The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. This object comes with built-in backpressure and queuing.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream)
*/ */
declare class WritableStream<W = any> { declare class WritableStream<W = any> {
constructor(underlyingSink?: UnderlyingSink, queuingStrategy?: QueuingStrategy); constructor(underlyingSink?: UnderlyingSink, queuingStrategy?: QueuingStrategy);
/** /**
* The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the `WritableStream` is locked to a writer. * The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the WritableStream is locked to a writer.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/locked) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/locked)
*/ */
@@ -2487,83 +2507,83 @@ declare class WritableStream<W = any> {
*/ */
abort(reason?: any): Promise<void>; abort(reason?: any): Promise<void>;
/** /**
* The **`close()`** method of the WritableStream interface closes the associated stream. * The **`close()`** method of the WritableStream interface closes the associated stream. All chunks written before this method is called are sent before the returned promise is fulfilled.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/close) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/close)
*/ */
close(): Promise<void>; close(): Promise<void>;
/** /**
* The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance. * The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance. While the stream is locked, no other writer can be acquired until this one is released.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/getWriter) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/getWriter)
*/ */
getWriter(): WritableStreamDefaultWriter<W>; getWriter(): WritableStreamDefaultWriter<W>;
} }
/** /**
* The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the `WritableStream` ensuring that no other streams can write to the underlying sink. * The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the WritableStream ensuring that no other streams can write to the underlying sink.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter)
*/ */
declare class WritableStreamDefaultWriter<W = any> { declare class WritableStreamDefaultWriter<W = any> {
constructor(stream: WritableStream); constructor(stream: WritableStream);
/** /**
* The **`closed`** read-only property of the the stream errors or the writer's lock is released. * The **`closed`** read-only property of the WritableStreamDefaultWriter interface returns a Promise that fulfills if the stream becomes closed, or rejects if the stream errors or the writer's lock is released.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/closed) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/closed)
*/ */
get closed(): Promise<void>; get closed(): Promise<void>;
/** /**
* The **`ready`** read-only property of the that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure. * The **`ready`** read-only property of the WritableStreamDefaultWriter interface returns a Promise that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/ready) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/ready)
*/ */
get ready(): Promise<void>; get ready(): Promise<void>;
/** /**
* The **`desiredSize`** read-only property of the to fill the stream's internal queue. * The **`desiredSize`** read-only property of the WritableStreamDefaultWriter interface returns the desired size required to fill the stream's internal queue.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/desiredSize) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/desiredSize)
*/ */
get desiredSize(): number | null; get desiredSize(): number | null;
/** /**
* The **`abort()`** method of the the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. * The **`abort()`** method of the WritableStreamDefaultWriter interface aborts the stream, signaling that the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/abort) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/abort)
*/ */
abort(reason?: any): Promise<void>; abort(reason?: any): Promise<void>;
/** /**
* The **`close()`** method of the stream. * The **`close()`** method of the WritableStreamDefaultWriter interface closes the associated writable stream.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/close) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/close)
*/ */
close(): Promise<void>; close(): Promise<void>;
/** /**
* The **`write()`** method of the operation. * The **`write()`** method of the WritableStreamDefaultWriter interface writes a passed chunk of data to a WritableStream and its underlying sink, then returns a Promise that resolves to indicate the success or failure of the write operation.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/write) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/write)
*/ */
write(chunk?: W): Promise<void>; write(chunk?: W): Promise<void>;
/** /**
* The **`releaseLock()`** method of the corresponding stream. * The **`releaseLock()`** method of the WritableStreamDefaultWriter interface releases the writer's lock on the corresponding stream. After the lock is released, the writer is no longer active. If the associated stream is errored when the lock is released, the writer will appear errored in the same way from now on; otherwise, the writer will appear closed.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/releaseLock) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/releaseLock)
*/ */
releaseLock(): void; releaseLock(): void;
} }
/** /**
* The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain _transform stream_ concept. * The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain transform stream concept.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream)
*/ */
declare class TransformStream<I = any, O = any> { declare class TransformStream<I = any, O = any> {
constructor(transformer?: Transformer<I, O>, writableStrategy?: QueuingStrategy<I>, readableStrategy?: QueuingStrategy<O>); constructor(transformer?: Transformer<I, O>, writableStrategy?: QueuingStrategy<I>, readableStrategy?: QueuingStrategy<O>);
/** /**
* The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this `TransformStream`. * The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this TransformStream. This stream emits the transformed output data.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/readable) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/readable)
*/ */
get readable(): ReadableStream<O>; get readable(): ReadableStream<O>;
/** /**
* The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this `TransformStream`. * The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this TransformStream. This stream accepts input data that will be transformed and emitted to the readable stream.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/writable) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/writable)
*/ */
@@ -2582,7 +2602,7 @@ interface ReadableStreamValuesOptions {
preventCancel?: boolean; preventCancel?: boolean;
} }
/** /**
* The **`CompressionStream`** interface of the Compression Streams API is an API for compressing a stream of data. * The **`CompressionStream`** interface of the Compression Streams API compresses a stream of data. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream)
*/ */
@@ -2590,7 +2610,7 @@ declare class CompressionStream extends TransformStream<ArrayBuffer | ArrayBuffe
constructor(format: "gzip" | "deflate" | "deflate-raw"); constructor(format: "gzip" | "deflate" | "deflate-raw");
} }
/** /**
* The **`DecompressionStream`** interface of the Compression Streams API is an API for decompressing a stream of data. * The **`DecompressionStream`** interface of the Compression Streams API decompresses a stream of data. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DecompressionStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DecompressionStream)
*/ */
@@ -2598,7 +2618,7 @@ declare class DecompressionStream extends TransformStream<ArrayBuffer | ArrayBuf
constructor(format: "gzip" | "deflate" | "deflate-raw"); constructor(format: "gzip" | "deflate" | "deflate-raw");
} }
/** /**
* The **`TextEncoderStream`** interface of the Encoding API converts a stream of strings into bytes in the UTF-8 encoding. * The **`TextEncoderStream`** interface of the Encoding API converts a stream of strings into bytes in the UTF-8 encoding. It is the streaming equivalent of TextEncoder. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoderStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoderStream)
*/ */
@@ -2607,7 +2627,7 @@ declare class TextEncoderStream extends TransformStream<string, Uint8Array> {
get encoding(): string; get encoding(): string;
} }
/** /**
* The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings. * The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings. It is the streaming equivalent of TextDecoder. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoderStream) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoderStream)
*/ */
@@ -2756,6 +2776,12 @@ interface TraceLog {
readonly timestamp: number; readonly timestamp: number;
readonly level: string; readonly level: string;
readonly message: any; readonly message: any;
readonly errorInfo?: (TraceLogErrorInfo | null)[];
}
interface TraceLogErrorInfo {
name: string;
message: string;
stack?: string;
} }
interface TraceException { interface TraceException {
readonly timestamp: number; readonly timestamp: number;
@@ -2776,7 +2802,7 @@ interface UnsafeTraceMetrics {
fromTrace(item: TraceItem): TraceMetrics; fromTrace(item: TraceItem): TraceMetrics;
} }
/** /**
* The **`URL`** interface is used to parse, construct, normalize, and encode URL. * The **`URL`** interface is used to parse, construct, normalize, and encode URLs. It works by providing properties which allow you to easily read and modify the components of a URL.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL)
*/ */
@@ -2801,121 +2827,121 @@ declare class URL {
*/ */
set href(value: string); set href(value: string);
/** /**
* The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final ":".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol)
*/ */
get protocol(): string; get protocol(): string;
/** /**
* The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final ":".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol)
*/ */
set protocol(value: string); set protocol(value: string);
/** /**
* The **`username`** property of the URL interface is a string containing the username component of the URL. * The **`username`** property of the URL interface is a string containing the username component of the URL. If the URL does not have a username, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username)
*/ */
get username(): string; get username(): string;
/** /**
* The **`username`** property of the URL interface is a string containing the username component of the URL. * The **`username`** property of the URL interface is a string containing the username component of the URL. If the URL does not have a username, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username)
*/ */
set username(value: string); set username(value: string);
/** /**
* The **`password`** property of the URL interface is a string containing the password component of the URL. * The **`password`** property of the URL interface is a string containing the password component of the URL. If the URL does not have a password, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password)
*/ */
get password(): string; get password(): string;
/** /**
* The **`password`** property of the URL interface is a string containing the password component of the URL. * The **`password`** property of the URL interface is a string containing the password component of the URL. If the URL does not have a password, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password)
*/ */
set password(value: string); set password(value: string);
/** /**
* The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. * The **`host`** property of the URL interface is a string containing the host, which is the hostname, and then, if the port of the URL is nonempty, a ":", followed by the port of the URL. If the URL does not have a hostname, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host)
*/ */
get host(): string; get host(): string;
/** /**
* The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. * The **`host`** property of the URL interface is a string containing the host, which is the hostname, and then, if the port of the URL is nonempty, a ":", followed by the port of the URL. If the URL does not have a hostname, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host)
*/ */
set host(value: string); set host(value: string);
/** /**
* The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. If the URL does not have a hostname, this property contains an empty string, "". IPv4 and IPv6 addresses are normalized, such as stripping leading zeros, and domain names are converted to IDN.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname)
*/ */
get hostname(): string; get hostname(): string;
/** /**
* The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. If the URL does not have a hostname, this property contains an empty string, "". IPv4 and IPv6 addresses are normalized, such as stripping leading zeros, and domain names are converted to IDN.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname)
*/ */
set hostname(value: string); set hostname(value: string);
/** /**
* The **`port`** property of the URL interface is a string containing the port number of the URL. * The **`port`** property of the URL interface is a string containing the port number of the URL. If the port is the default for the protocol (80 for ws: and http:, 443 for wss: and https:, and 21 for ftp:), this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port)
*/ */
get port(): string; get port(): string;
/** /**
* The **`port`** property of the URL interface is a string containing the port number of the URL. * The **`port`** property of the URL interface is a string containing the port number of the URL. If the port is the default for the protocol (80 for ws: and http:, 443 for wss: and https:, and 21 for ftp:), this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port)
*/ */
set port(value: string); set port(value: string);
/** /**
* The **`pathname`** property of the URL interface represents a location in a hierarchical structure. * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. It is a string constructed from a list of path segments, each of which is prefixed by a / character.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname)
*/ */
get pathname(): string; get pathname(): string;
/** /**
* The **`pathname`** property of the URL interface represents a location in a hierarchical structure. * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. It is a string constructed from a list of path segments, each of which is prefixed by a / character.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname)
*/ */
set pathname(value: string); set pathname(value: string);
/** /**
* The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. * The **`search`** property of the URL interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the URL. If the URL does not have a search query, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search)
*/ */
get search(): string; get search(): string;
/** /**
* The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. * The **`search`** property of the URL interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the URL. If the URL does not have a search query, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search)
*/ */
set search(value: string); set search(value: string);
/** /**
* The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. * The **`hash`** property of the URL interface is a string containing a "#" followed by the fragment identifier of the URL. If the URL does not have a fragment identifier, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash)
*/ */
get hash(): string; get hash(): string;
/** /**
* The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. * The **`hash`** property of the URL interface is a string containing a "#" followed by the fragment identifier of the URL. If the URL does not have a fragment identifier, this property contains an empty string, "".
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash)
*/ */
set hash(value: string); set hash(value: string);
/** /**
* The **`searchParams`** read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL. * The **`searchParams`** read-only property of the URL interface returns a URLSearchParams object allowing access to the GET decoded query arguments contained in the URL.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/searchParams) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/searchParams)
*/ */
get searchParams(): URLSearchParams; get searchParams(): URLSearchParams;
/** /**
* The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as ```js-nolint toJSON() ``` None. * The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as URL.toString().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/toJSON) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/toJSON)
*/ */
@@ -2935,13 +2961,13 @@ declare class URL {
*/ */
static parse(url: string, base?: string): URL | null; static parse(url: string, base?: string): URL | null;
/** /**
* The **`createObjectURL()`** static method of the URL interface creates a string containing a URL representing the object given in the parameter. * The **`createObjectURL()`** static method of the URL interface creates a string containing a blob URL pointing to the object given in the parameter.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static)
*/ */
static createObjectURL(object: File | Blob): string; static createObjectURL(object: File | Blob): string;
/** /**
* The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling Call this method when you've finished using an object URL to let the browser know not to keep the reference to the file any longer. * The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling URL.createObjectURL().
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/revokeObjectURL_static) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/revokeObjectURL_static)
*/ */
@@ -2991,25 +3017,22 @@ declare class URLSearchParams {
*/ */
has(name: string, value?: string): boolean; has(name: string, value?: string): boolean;
/** /**
* The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value. * The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value. If there were several matching values, this method deletes the others. If the search parameter doesn't exist, this method creates it.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set)
*/ */
set(name: string, value: string): void; set(name: string, value: string): void;
/** /**
* The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns `undefined`. * The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns undefined. Key/value pairs are sorted by the values of the UTF-16 code units of the keys. This method uses a stable sorting algorithm (i.e., the relative order between key/value pairs with equal keys will be preserved).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort)
*/ */
sort(): void; sort(): void;
/* Returns an array of key, value pairs for every entry in the search params. */
entries(): IterableIterator<[ entries(): IterableIterator<[
key: string, key: string,
value: string value: string
]>; ]>;
/* Returns a list of keys in the search params. */
keys(): IterableIterator<string>; keys(): IterableIterator<string>;
/* Returns a list of values in the search params. */
values(): IterableIterator<string>; values(): IterableIterator<string>;
forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void; forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void;
/*function toString() { [native code] }*/ /*function toString() { [native code] }*/
@@ -3019,18 +3042,78 @@ declare class URLSearchParams {
value: string value: string
]>; ]>;
} }
/**
* The **`URLPattern`** interface of the URL Pattern API matches URLs or parts of URLs against a pattern. The pattern can contain capturing groups that extract parts of the matched URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern)
*/
declare class URLPattern { declare class URLPattern {
constructor(input?: (string | URLPatternInit), baseURL?: (string | URLPatternOptions), patternOptions?: URLPatternOptions); constructor(input?: (string | URLPatternInit), baseURL?: (string | URLPatternOptions), patternOptions?: URLPatternOptions);
/**
* The **`protocol`** read-only property of the URLPattern interface is a string containing the pattern used to match the protocol part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/protocol)
*/
get protocol(): string; get protocol(): string;
/**
* The **`username`** read-only property of the URLPattern interface is a string containing the pattern used to match the username part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/username)
*/
get username(): string; get username(): string;
/**
* The **`password`** read-only property of the URLPattern interface is a string containing the pattern used to match the password part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/password)
*/
get password(): string; get password(): string;
/**
* The **`hostname`** read-only property of the URLPattern interface is a string containing the pattern used to match the hostname part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hostname)
*/
get hostname(): string; get hostname(): string;
/**
* The **`port`** read-only property of the URLPattern interface is a string containing the pattern used to match the port part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/port)
*/
get port(): string; get port(): string;
/**
* The **`pathname`** read-only property of the URLPattern interface is a string containing the pattern used to match the pathname part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/pathname)
*/
get pathname(): string; get pathname(): string;
/**
* The **`search`** read-only property of the URLPattern interface is a string containing the pattern used to match the search part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/search)
*/
get search(): string; get search(): string;
/**
* The **`hash`** read-only property of the URLPattern interface is a string containing the pattern used to match the fragment part of a URL.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hash)
*/
get hash(): string; get hash(): string;
/**
* The **`hasRegExpGroups`** read-only property of the URLPattern interface is a boolean indicating whether or not any of the URLPattern components contain regular expression capturing groups.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hasRegExpGroups)
*/
get hasRegExpGroups(): boolean; get hasRegExpGroups(): boolean;
/**
* The **`test()`** method of the URLPattern interface takes a URL string or object of URL parts, and returns a boolean indicating if the given input matches the current pattern.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/test)
*/
test(input?: (string | URLPatternInit), baseURL?: string): boolean; test(input?: (string | URLPatternInit), baseURL?: string): boolean;
/**
* The **`exec()`** method of the URLPattern interface takes a URL or object of URL parts, and returns either an object containing the results of matching the URL to the pattern, or null if the URL does not match the pattern.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/exec)
*/
exec(input?: (string | URLPatternInit), baseURL?: string): URLPatternResult | null; exec(input?: (string | URLPatternInit), baseURL?: string): URLPatternResult | null;
} }
interface URLPatternInit { interface URLPatternInit {
@@ -3063,7 +3146,7 @@ interface URLPatternOptions {
ignoreCase?: boolean; ignoreCase?: boolean;
} }
/** /**
* A `CloseEvent` is sent to clients using WebSockets when the connection is closed. * A **`CloseEvent`** is sent to clients using WebSockets when the connection is closed. This is delivered to the listener indicated by the WebSocket object's onclose attribute.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent)
*/ */
@@ -3082,13 +3165,16 @@ declare class CloseEvent extends Event {
*/ */
readonly reason: string; readonly reason: string;
/** /**
* The **`wasClean`** read-only property of the CloseEvent interface returns `true` if the connection closed cleanly. * The **`wasClean`** read-only property of the CloseEvent interface returns true if the connection closed cleanly.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/wasClean) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/wasClean)
*/ */
readonly wasClean: boolean; readonly wasClean: boolean;
} }
interface CloseEventInit { interface CloseEventInit {
bubbles?: boolean;
cancelable?: boolean;
composed?: boolean;
code?: number; code?: number;
reason?: string; reason?: string;
wasClean?: boolean; wasClean?: boolean;
@@ -3100,7 +3186,7 @@ type WebSocketEventMap = {
error: ErrorEvent; error: ErrorEvent;
}; };
/** /**
* The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. * The **`WebSocket`** object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket)
*/ */
@@ -3117,20 +3203,20 @@ declare var WebSocket: {
readonly CLOSED: number; readonly CLOSED: number;
}; };
/** /**
* The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. * The **`WebSocket`** object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket)
*/ */
interface WebSocket extends EventTarget<WebSocketEventMap> { interface WebSocket extends EventTarget<WebSocketEventMap> {
accept(options?: WebSocketAcceptOptions): void; accept(options?: WebSocketAcceptOptions): void;
/** /**
* The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of `bufferedAmount` by the number of bytes needed to contain the data. * The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of bufferedAmount by the number of bytes needed to contain the data. If the data can't be sent (for example, because it needs to be buffered but the buffer is full), the socket is closed automatically. The browser will throw an exception if you call send() when the connection is in the CONNECTING state. If you call send() when the connection is in the CLOSING or CLOSED states, the browser will silently discard the data.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/send) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/send)
*/ */
send(message: (ArrayBuffer | ArrayBufferView) | string): void; send(message: (ArrayBuffer | ArrayBufferView) | string): void;
/** /**
* The **`WebSocket.close()`** method closes the already `CLOSED`, this method does nothing. * The **`WebSocket.close()`** method closes the WebSocket connection or connection attempt, if any. If the connection is already CLOSED, this method does nothing.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/close) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/close)
*/ */
@@ -3150,13 +3236,13 @@ interface WebSocket extends EventTarget<WebSocketEventMap> {
*/ */
url: string | null; url: string | null;
/** /**
* The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the `protocols` parameter when creating the WebSocket object, or the empty string if no connection is established. * The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the protocols parameter when creating the WebSocket object, or the empty string if no connection is established.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/protocol) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/protocol)
*/ */
protocol: string | null; protocol: string | null;
/** /**
* The **`WebSocket.extensions`** read-only property returns the extensions selected by the server. * The **`WebSocket.extensions`** read-only property returns the extensions selected by the server. This is currently only the empty string or a list of extensions as negotiated by the connection.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions)
*/ */
@@ -3243,25 +3329,25 @@ interface SocketInfo {
declare class EventSource extends EventTarget { declare class EventSource extends EventTarget {
constructor(url: string, init?: EventSourceEventSourceInit); constructor(url: string, init?: EventSourceEventSourceInit);
/** /**
* The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the ```js-nolint close() ``` None. * The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the EventSource.readyState attribute to 2 (closed).
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close)
*/ */
close(): void; close(): void;
/** /**
* The **`url`** read-only property of the URL of the source. * The **`url`** read-only property of the EventSource interface returns a string representing the URL of the source.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url)
*/ */
get url(): string; get url(): string;
/** /**
* The **`withCredentials`** read-only property of the the `EventSource` object was instantiated with CORS credentials set. * The **`withCredentials`** read-only property of the EventSource interface returns a boolean value indicating whether the EventSource object was instantiated with CORS credentials set.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials)
*/ */
get withCredentials(): boolean; get withCredentials(): boolean;
/** /**
* The **`readyState`** read-only property of the connection. * The **`readyState`** read-only property of the EventSource interface returns a number representing the state of the connection.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState)
*/ */
@@ -3296,18 +3382,26 @@ interface ContainerExecOptions {
cwd?: string; cwd?: string;
env?: Record<string, string>; env?: Record<string, string>;
user?: string; user?: string;
signal?: AbortSignal;
pty?: boolean | ContainerExecPtyOptions;
stdin?: ReadableStream | "pipe"; stdin?: ReadableStream | "pipe";
stdout?: "pipe" | "ignore"; stdout?: "pipe" | "ignore";
stderr?: "pipe" | "ignore" | "combined"; stderr?: "pipe" | "ignore" | "combined";
} }
interface ContainerExecPtyOptions {
cols?: number;
rows?: number;
}
interface ExecProcess { interface ExecProcess {
readonly stdin: WritableStream | null; readonly stdin: WritableStream | null;
readonly stdout: ReadableStream | null; readonly stdout: ReadableStream | null;
readonly stderr: ReadableStream | null; readonly stderr: ReadableStream | null;
readonly pid: number; readonly pid: number;
readonly isPty: boolean;
readonly exitCode: Promise<number>; readonly exitCode: Promise<number>;
output(): Promise<ExecOutput>; output(): Promise<ExecOutput>;
kill(signal?: number): void; kill(signal?: number): void;
resize(cols: number, rows: number): void;
} }
interface Container { interface Container {
get running(): boolean; get running(): boolean;
@@ -3334,25 +3428,42 @@ interface ContainerDirectorySnapshotOptions {
dir: string; dir: string;
name?: string; name?: string;
} }
interface ContainerDirectorySnapshotRestoreParams { type ContainerDirectorySnapshotRestoreParams = {
snapshot: ContainerDirectorySnapshot; snapshot: ContainerDirectorySnapshot;
mountPoint?: string; mountPoint?: string;
} } | {
snapshot?: undefined;
mountPoint: string;
};
interface ContainerSnapshot { interface ContainerSnapshot {
id: string; id: string;
size: number; size: number;
name?: string; name?: string;
} }
interface ContainerSnapshotRestoreParams {
id: string;
}
interface ContainerSnapshotOptions { interface ContainerSnapshotOptions {
name?: string; name?: string;
} }
interface ContainerStartupOptions { type ContainerStartupOptions = {
entrypoint?: string[]; entrypoint?: string[];
enableInternet: boolean; enableInternet: boolean;
env?: Record<string, string>; env?: Record<string, string>;
instance?: "lite" | "standard-1" | "standard-2" | "standard-3" | "standard-4" | ContainerStartResources;
labels?: Record<string, string>; labels?: Record<string, string>;
directorySnapshots?: ContainerDirectorySnapshotRestoreParams[]; directorySnapshots?: ContainerDirectorySnapshotRestoreParams[];
containerSnapshot?: ContainerSnapshot; } & ({
image: string;
containerSnapshot?: never;
} | {
image?: never;
containerSnapshot?: ContainerSnapshotRestoreParams;
});
interface ContainerStartResources {
vcpu: number;
memoryMib: number;
diskMb: number;
} }
/** /**
* The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other. * The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other.
@@ -3361,19 +3472,19 @@ interface ContainerStartupOptions {
*/ */
declare abstract class MessagePort extends EventTarget { declare abstract class MessagePort extends EventTarget {
/** /**
* The **`postMessage()`** method of the transfers ownership of objects to other browsing contexts. * The **`postMessage()`** method of the MessagePort interface sends a message from the port, and optionally, transfers ownership of objects to other browsing contexts.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/postMessage) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/postMessage)
*/ */
postMessage(data?: any, options?: (any[] | MessagePortPostMessageOptions)): void; postMessage(data?: any, options?: (any[] | MessagePortPostMessageOptions)): void;
/** /**
* The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active. * The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active. This stops the flow of messages to that port.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/close) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/close)
*/ */
close(): void; close(): void;
/** /**
* The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port. * The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port. This method is only needed when using EventTarget.addEventListener; it is implied when using onmessage.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/start) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/start)
*/ */
@@ -3389,13 +3500,13 @@ declare abstract class MessagePort extends EventTarget {
declare class MessageChannel { declare class MessageChannel {
constructor(); constructor();
/** /**
* The **`port1`** read-only property of the the port attached to the context that originated the channel. * The **`port1`** read-only property of the MessageChannel interface returns the first port of the message channel — the port attached to the context that originated the channel.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port1) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port1)
*/ */
readonly port1: MessagePort; readonly port1: MessagePort;
/** /**
* The **`port2`** read-only property of the the port attached to the context at the other end of the channel, which the message is initially sent to. * The **`port2`** read-only property of the MessageChannel interface returns the second port of the message channel — the port attached to the context at the other end of the channel, which the message is initially sent to.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port2) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port2)
*/ */
@@ -3455,7 +3566,7 @@ interface WorkerLoaderModule {
data?: ArrayBuffer; data?: ArrayBuffer;
json?: any; json?: any;
py?: string; py?: string;
wasm?: ArrayBuffer; wasm?: ArrayBuffer | ArrayBufferView | WebAssembly.Module;
} }
interface WorkerLoaderWorkerCode { interface WorkerLoaderWorkerCode {
compatibilityDate: string; compatibilityDate: string;
@@ -3463,7 +3574,7 @@ interface WorkerLoaderWorkerCode {
allowExperimental?: boolean; allowExperimental?: boolean;
limits?: workerdResourceLimits; limits?: workerdResourceLimits;
mainModule: string; mainModule: string;
modules: Record<string, WorkerLoaderModule | string>; modules: Record<string, string | WebAssembly.Module | WorkerLoaderModule>;
env?: any; env?: any;
globalOutbound?: (Fetcher | null); globalOutbound?: (Fetcher | null);
tails?: Fetcher[]; tails?: Fetcher[];
@@ -3485,7 +3596,7 @@ declare abstract class Performance {
/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */ /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */
now(): number; now(): number;
/** /**
* The **`toJSON()`** method of the Performance interface is a Serialization; it returns a JSON representation of the Performance object. * The **`toJSON()`** method of the Performance interface is a serializer; it returns a JSON representation of the Performance object.
* *
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON) * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON)
*/ */
@@ -3494,11 +3605,13 @@ declare abstract class Performance {
interface Tracing { interface Tracing {
enterSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; enterSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T;
startActiveSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; startActiveSpan<T, A extends unknown[]>(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T;
startSpan(name: string): Span;
Span: typeof Span; Span: typeof Span;
} }
declare abstract class Span { declare abstract class Span {
get isTraced(): boolean; get isTraced(): boolean;
setAttribute(key: string, value?: (boolean | number | string)): void; setAttribute(key: string, value: boolean | number | string): this;
setAttributes(attributes: Record<string, boolean | number | string | undefined>): this;
end(): void; end(): void;
} }
/** /**
@@ -4138,6 +4251,13 @@ type AiSearchListItemsParams = {
source?: string; source?: string;
/** JSON-encoded Vectorize filter for metadata filtering. */ /** JSON-encoded Vectorize filter for metadata filtering. */
metadata_filter?: string; metadata_filter?: string;
/** Filter items by their unique ID. Returns at most one item. */
item_id?: string;
/**
* Filter items by their exact key (object key / filename). Keys are unique
* per source, so combine with `source` to disambiguate across data sources.
*/
key?: string;
}; };
type AiSearchListItemsResponse = { type AiSearchListItemsResponse = {
result: AiSearchItemInfo[]; result: AiSearchItemInfo[];
@@ -10035,6 +10155,163 @@ declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT {
inputs: ChatCompletionsInput; inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput; postProcessedOutputs: ChatCompletionsOutput;
} }
declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_7_Code {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Zai_Org_Glm_5_2 {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
interface Ai_Cf_Moondream_Moondream3_1_9B_A2B_Input {
/**
* Which Moondream skill to run.
*/
task?: "query" | "caption" | "point" | "detect";
/**
* Input image as a public HTTPS URL or base64 data URI. Optional for `query`; required for `caption`, `point`, and `detect`.
*/
image?: string;
/**
* Question for the `query` task.
*/
question?: string;
/**
* Caption length for the `caption` task.
*/
caption_length?: "short" | "normal" | "long";
/**
* Object phrase to locate for `point` and `detect` tasks (e.g. 'person wearing a red shirt').
*/
target?: string;
/**
* Enable reasoning trace for the `query` task.
*/
reasoning?: boolean;
/**
* Sampling temperature.
*/
temperature?: number;
/**
* Top-p (nucleus) sampling.
*/
top_p?: number;
/**
* Max tokens to generate for `query` and `caption`.
*/
max_tokens?: number;
/**
* Max objects to return for `point` and `detect`.
*/
max_objects?: number;
/**
* Return incremental tokens for `query` and `caption`. `point` and `detect` do not support streaming.
*/
stream?: boolean;
}
interface Ai_Cf_Moondream_Moondream3_1_9B_A2B_Output {
/**
* Reason the generation finished.
*/
finish_reason: string;
metrics: {
/**
* Number of input tokens consumed.
*/
input_tokens: number;
/**
* Number of output tokens generated.
*/
output_tokens: number;
/**
* Prefill time in milliseconds.
*/
prefill_time_ms: number;
/**
* Decode time in milliseconds.
*/
decode_time_ms: number;
/**
* Time to first token in milliseconds.
*/
ttft_ms: number;
};
/**
* Answer text for the `query` task. Null for other tasks.
*/
answer?: string;
/**
* Caption text for the `caption` task. Null for other tasks.
*/
caption?: string;
/**
* Located points for the `point` task. Null for other tasks.
*/
points?: {
/**
* X coordinate.
*/
x: number;
/**
* Y coordinate.
*/
y: number;
}[];
/**
* Detected bounding boxes for the `detect` task. Null for other tasks.
*/
objects?: {
/**
* Minimum X coordinate.
*/
x_min: number;
/**
* Minimum Y coordinate.
*/
y_min: number;
/**
* Maximum X coordinate.
*/
x_max: number;
/**
* Maximum Y coordinate.
*/
y_max: number;
}[];
/**
* Reasoning trace for the `query` task when reasoning=true. Null otherwise.
*/
reasoning?: {
/**
* Reasoning text.
*/
text: string;
/**
* Grounding information.
*/
grounding?: {}[];
};
}
declare abstract class Base_Ai_Cf_Moondream_Moondream3_1_9B_A2B {
inputs: Ai_Cf_Moondream_Moondream3_1_9B_A2B_Input;
postProcessedOutputs: Ai_Cf_Moondream_Moondream3_1_9B_A2B_Output;
}
declare abstract class Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Flash_0731 {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Pro_0813 {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Qwen_Qwen3_8_27B {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
declare abstract class Base_Ai_Cf_Zai_Org_Glm_5_3_Flash {
inputs: ChatCompletionsInput;
postProcessedOutputs: ChatCompletionsOutput;
}
interface AiModels { interface AiModels {
"@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification; "@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification;
"@cf/stabilityai/stable-diffusion-xl-base-1.0": BaseAiTextToImage; "@cf/stabilityai/stable-diffusion-xl-base-1.0": BaseAiTextToImage;
@@ -10127,6 +10404,13 @@ interface AiModels {
"@cf/moonshotai/kimi-k2.6": Base_Ai_Cf_Moonshotai_Kimi_K2_6; "@cf/moonshotai/kimi-k2.6": Base_Ai_Cf_Moonshotai_Kimi_K2_6;
"@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B; "@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B;
"@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT; "@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT;
"@cf/moonshotai/kimi-k2.7-code": Base_Ai_Cf_Moonshotai_Kimi_K2_7_Code;
"@cf/zai-org/glm-5.2": Base_Ai_Cf_Zai_Org_Glm_5_2;
"@cf/moondream/moondream3.1-9B-A2B": Base_Ai_Cf_Moondream_Moondream3_1_9B_A2B;
"@cf/deepseek-ai/deepseek-v4-flash-0731": Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Flash_0731;
"@cf/deepseek-ai/deepseek-v4-pro-0813": Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Pro_0813;
"@cf/qwen/qwen3.8-27b": Base_Ai_Cf_Qwen_Qwen3_8_27B;
"@cf/zai-org/glm-5.3-flash": Base_Ai_Cf_Zai_Org_Glm_5_3_Flash;
} }
type AiOptions = { type AiOptions = {
/** /**
@@ -10793,6 +11077,15 @@ interface BrowserRunBaseOptions {
*/ */
cacheTTL?: number; cacheTTL?: number;
} }
/**
* Backend selection, mixed into the options of the quick actions that support it.
* Deliberately not part of `BrowserRunBaseOptions`: `scrape`, `links` and `snapshot`
* reject an alternate backend, so they must not accept the field.
*/
interface BrowserRunAlternateBackendOptions {
/** Render with an alternate browser backend instead of the default one. */
browser?: 'kitesurf';
}
/** Common options shared by all quick actions. Exactly one of `url` or `html` must be provided.*/ /** Common options shared by all quick actions. Exactly one of `url` or `html` must be provided.*/
type BrowserRunCommonOptions = (BrowserRunBaseOptions & { type BrowserRunCommonOptions = (BrowserRunBaseOptions & {
/** URL to navigate to, e.g. `"https://example.com"`. */ /** URL to navigate to, e.g. `"https://example.com"`. */
@@ -10827,7 +11120,7 @@ type BrowserRunScreenshotOptions = BrowserRunCommonOptions & {
scrollPage?: boolean; scrollPage?: boolean;
/** @see https://pptr.dev/api/puppeteer.screenshotoptions */ /** @see https://pptr.dev/api/puppeteer.screenshotoptions */
screenshotOptions?: BrowserRunPuppeteerScreenshotOptions; screenshotOptions?: BrowserRunPuppeteerScreenshotOptions;
}; } & BrowserRunAlternateBackendOptions;
type BrowserRunPDFOptions = BrowserRunCommonOptions & { type BrowserRunPDFOptions = BrowserRunCommonOptions & {
/** @see https://pptr.dev/api/puppeteer.pdfoptions */ /** @see https://pptr.dev/api/puppeteer.pdfoptions */
pdfOptions?: { pdfOptions?: {
@@ -10863,7 +11156,7 @@ type BrowserRunPDFOptions = BrowserRunCommonOptions & {
/** @default 30000 */ /** @default 30000 */
timeout?: number; timeout?: number;
}; };
}; } & BrowserRunAlternateBackendOptions;
type BrowserRunScrapeOptions = BrowserRunCommonOptions & { type BrowserRunScrapeOptions = BrowserRunCommonOptions & {
/** CSS selectors to scrape. At least one element is required. */ /** CSS selectors to scrape. At least one element is required. */
elements: Array<{ elements: Array<{
@@ -10876,10 +11169,29 @@ type BrowserRunLinksOptions = BrowserRunCommonOptions & {
/** When true, exclude links pointing to external domains. @default false */ /** When true, exclude links pointing to external domains. @default false */
excludeExternalLinks?: boolean; excludeExternalLinks?: boolean;
}; };
type BrowserRunSnapshotFormat = 'content' | 'screenshot' | 'markdown' | 'accessibilityTree';
type BrowserRunSnapshotOptions = BrowserRunCommonOptions & { type BrowserRunSnapshotOptions = BrowserRunCommonOptions & {
/** Which representations of the page to return. At least two distinct formats
* are required; request a single format from its dedicated action instead.
* @default ["content","screenshot"]
*/
formats?: BrowserRunSnapshotFormat[];
/** @see https://pptr.dev/api/puppeteer.screenshotoptions */ /** @see https://pptr.dev/api/puppeteer.screenshotoptions */
screenshotOptions?: Omit<BrowserRunPuppeteerScreenshotOptions, 'encoding'>; screenshotOptions?: Omit<BrowserRunPuppeteerScreenshotOptions, 'encoding'>;
}; };
/** Options for the `accessibilityTree` quick action. */
type BrowserRunAccessibilityTreeOptions = BrowserRunCommonOptions & {
/** When true, prune nodes that carry no semantic meaning, such as generic
* containers. Defaults to true, or to false when `root` is set so that the
* requested subtree is returned as-is.
*/
interestingOnly?: boolean;
/** CSS selector limiting the tree to the matching element's subtree.
* A selector that matches nothing yields `accessibilityTree: null` with
* HTTP 200; a malformed selector is an error.
*/
root?: string;
} & BrowserRunAlternateBackendOptions;
interface BrowserRunJsonBaseOptions { interface BrowserRunJsonBaseOptions {
/** Custom AI models to try in order. Max 3. Falls back to next on error. */ /** Custom AI models to try in order. Max 3. Falls back to next on error. */
custom_ai?: Array<{ custom_ai?: Array<{
@@ -10893,7 +11205,7 @@ interface BrowserRunJsonBaseOptions {
* Options for the `json` quick action. * Options for the `json` quick action.
* At least one of `prompt` or `response_format` must be provided. * At least one of `prompt` or `response_format` must be provided.
*/ */
type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunJsonBaseOptions & ({ type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunAlternateBackendOptions & BrowserRunJsonBaseOptions & ({
/** Natural-language prompt describing what data to extract. */ /** Natural-language prompt describing what data to extract. */
prompt: string; prompt: string;
/** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */ /** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */
@@ -10904,14 +11216,80 @@ type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunJsonBaseOptions
/** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */ /** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */
response_format: AiTextGenerationResponseFormat; response_format: AiTextGenerationResponseFormat;
}); });
type BrowserRunContentOptions = BrowserRunCommonOptions; type BrowserRunContentOptions = BrowserRunCommonOptions & BrowserRunAlternateBackendOptions;
type BrowserRunMarkdownOptions = BrowserRunCommonOptions; type BrowserRunMarkdownOptions = BrowserRunCommonOptions & BrowserRunAlternateBackendOptions;
type BrowserRunRedirectHop = {
/** URL that returned the redirect. */
url: string;
/** HTTP status of the redirect. */
status: number;
/** Redirect response headers, including `location`. */
headers: Record<string, string>;
};
type BrowserRunResponseMeta = { type BrowserRunResponseMeta = {
/** HTTP status code of the rendered page */ /** HTTP status code of the rendered page */
status: number; status: number;
/** Page title */ /** Page title */
title: string; title: string;
/** Origin response headers, lowercased. Repeated headers are joined with a newline. Credential and transport-only headers that do not survive rendering are omitted. */
headers?: Record<string, string>;
/** URL that served the response, after any redirects the browser followed. */
finalUrl?: string;
/** HTTP redirects followed to reach `finalUrl`, oldest first. Omitted for direct navigation and for client-side redirects such as meta refresh. An empty array means redirects occurred but their intermediate responses could not be read. */
redirectChain?: BrowserRunRedirectHop[];
}; };
/**
* A node in the page's accessibility tree, as exposed to assistive technology.
* `role` is the only field always present; the rest are populated when the
* underlying element defines them.
* @see https://pptr.dev/api/puppeteer.serializedaxnode
*/
interface BrowserRunSerializedAXNode {
/** The ARIA role, e.g. `"button"`, `"heading"`, `"RootWebArea"`. */
role: string;
/** The `aria-autocomplete` value. */
autocomplete?: string;
/** Checked state of a checkbox, radio, or menu item. */
checked?: boolean | 'mixed';
/** Accessible description, typically from `aria-describedby` or `title`. */
description?: string;
disabled?: boolean;
expanded?: boolean;
/** Whether the element currently holds keyboard focus. */
focused?: boolean;
/** The kind of popup the element triggers, e.g. `"menu"`, `"dialog"`. */
haspopup?: string;
/** The `aria-invalid` value. */
invalid?: string;
/** Keyboard shortcuts bound to the element, from `aria-keyshortcuts`. */
keyshortcuts?: string;
/** Hierarchical level, e.g. the heading level of an `<h2>`. */
level?: number;
/** Whether the element is a modal dialog. */
modal?: boolean;
/** Whether a text input accepts multiple lines. */
multiline?: boolean;
/** Whether more than one option can be selected. */
multiselectable?: boolean;
/** Accessible name, e.g. a button's label or an image's alt text. */
name?: string;
orientation?: string;
/** Pressed state of a toggle button. */
pressed?: boolean | 'mixed';
readonly?: boolean;
required?: boolean;
/** Author-supplied role description, from `aria-roledescription`. */
roledescription?: string;
selected?: boolean;
/** Current value of an input or range element. */
value?: string | number;
valuemax?: number;
valuemin?: number;
/** Human-readable form of `value`, from `aria-valuetext`. */
valuetext?: string;
/** Child nodes. Absent for leaf nodes. */
children?: BrowserRunSerializedAXNode[];
}
/** Success response for `content` action. */ /** Success response for `content` action. */
type BrowserRunContentSuccessResponse = { type BrowserRunContentSuccessResponse = {
success: true; success: true;
@@ -10924,6 +11302,7 @@ type BrowserRunLinksSuccessResponse = {
success: true; success: true;
/** Extracted links */ /** Extracted links */
result: string[]; result: string[];
meta: BrowserRunResponseMeta;
}; };
/** Success response for `scrape` action. */ /** Success response for `scrape` action. */
type BrowserRunScrapeSuccessResponse = { type BrowserRunScrapeSuccessResponse = {
@@ -10954,15 +11333,33 @@ type BrowserRunScrapeSuccessResponse = {
}>; }>;
}>; }>;
}>; }>;
meta: BrowserRunResponseMeta;
}; };
/** Success response for `snapshot` action. */ /** Success response for `snapshot` action. Each field is present only when the
* corresponding entry was requested in `formats`.
*/
type BrowserRunSnapshotSuccessResponse = { type BrowserRunSnapshotSuccessResponse = {
success: true; success: true;
result: { result: {
/** HTML content of the page. */ /** HTML content of the page. */
content: string; content?: string;
/** Base64-encoded screenshot image. */ /** Base64-encoded screenshot image. */
screenshot: string; screenshot?: string;
/** Markdown content. Prefixed with YAML frontmatter (e.g. `title`) when the
* page provides that metadata.
*/
markdown?: string;
/** Root of the page's accessibility tree. */
accessibilityTree?: BrowserRunSerializedAXNode;
};
meta: BrowserRunResponseMeta;
};
/** Success response for `accessibilityTree` action. */
type BrowserRunAccessibilityTreeSuccessResponse = {
success: true;
result: {
/** Root of the accessibility tree, or `null` when `root` matched no element. */
accessibilityTree: BrowserRunSerializedAXNode | null;
}; };
meta: BrowserRunResponseMeta; meta: BrowserRunResponseMeta;
}; };
@@ -10971,12 +11368,14 @@ type BrowserRunJsonSuccessResponse = {
success: true; success: true;
/** JSON data extracted from the page using an AI model */ /** JSON data extracted from the page using an AI model */
result: Record<string, unknown>; result: Record<string, unknown>;
meta: BrowserRunResponseMeta;
}; };
/** Success response for `markdown` action. */ /** Success response for `markdown` action. */
type BrowserRunMarkdownSuccessResponse = { type BrowserRunMarkdownSuccessResponse = {
success: true; success: true;
/** Extracted markdown content */ /** Extracted markdown content */
result: string; result: string;
meta: BrowserRunResponseMeta;
}; };
/** Error response for BrowserRun actions. */ /** Error response for BrowserRun actions. */
type BrowserRunErrorResponse = { type BrowserRunErrorResponse = {
@@ -11086,9 +11485,10 @@ declare abstract class BrowserRun {
*/ */
quickAction(action: 'links', options: BrowserRunLinksOptions): Promise<Response>; quickAction(action: 'links', options: BrowserRunLinksOptions): Promise<Response>;
/** /**
* Get both the HTML content and a base64-encoded screenshot of a web page. * Get several representations of a web page in one request.
* @param action - Must be `'snapshot'`. * @param action - Must be `'snapshot'`.
* @param options - Snapshot options including screenshot settings (encoding is always base64). * @param options - Snapshot options including the `formats` to return and
* screenshot settings (encoding is always base64).
* @returns A `Response` containing one of: * @returns A `Response` containing one of:
* *
* **Success (HTTP 200):** * **Success (HTTP 200):**
@@ -11136,6 +11536,26 @@ declare abstract class BrowserRun {
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500) * - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/ */
quickAction(action: 'markdown', options: BrowserRunMarkdownOptions): Promise<Response>; quickAction(action: 'markdown', options: BrowserRunMarkdownOptions): Promise<Response>;
/**
* Get the accessibility tree of a web page.
* @param action - Must be `'accessibilityTree'`.
* @param options - Options to scope the tree to a subtree and to control
* whether semantically uninteresting nodes are pruned.
* @returns A `Response` containing one of:
*
* **Success (HTTP 200):**
* - `BrowserRunAccessibilityTreeSuccessResponse` JSON with `Content-Type: application/json`
* - `result.accessibilityTree` is `null` when `root` matched no element
*
* **Error:**
* - `BrowserRunErrorResponse` JSON with appropriate HTTP status code (400, 422, 429, 500, 503)
* - HTTP 422 for a malformed `root` selector
* - HTTP 500 with code `2017` or `2018` when the tree could not be built
*
* **Headers:**
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
*/
quickAction(action: 'accessibilityTree', options: BrowserRunAccessibilityTreeOptions): Promise<Response>;
} }
/** /**
* In addition to the properties you can set in the RequestInit dict * In addition to the properties you can set in the RequestInit dict
@@ -12206,6 +12626,13 @@ interface ForwardableEmailMessage extends EmailMessage {
* @returns A promise that resolves when the email message is replied. * @returns A promise that resolves when the email message is replied.
*/ */
reply(message: EmailMessage): Promise<EmailSendResult>; reply(message: EmailMessage): Promise<EmailSendResult>;
/**
* Reply to the sender of this email message with a message built from the given
* fields. Threading headers (In-Reply-To/References) are set automatically.
* @param builder The reply message contents.
* @returns A promise that resolves when the email message is replied.
*/
reply(builder: EmailReplyMessageBuilder): Promise<EmailSendResult>;
} }
/** A file attachment for an email message */ /** A file attachment for an email message */
type EmailAttachment = { type EmailAttachment = {
@@ -12226,23 +12653,46 @@ interface EmailAddress {
name: string; name: string;
email: string; email: string;
} }
/**
* Recipient fields for `SendEmail.send()`. At least one of `to`, `cc`, or
* `bcc` must be provided.
*/
type EmailDestinations = {
to?: string | EmailAddress | (string | EmailAddress)[];
cc?: string | EmailAddress | (string | EmailAddress)[];
bcc?: string | EmailAddress | (string | EmailAddress)[];
} & ({
to: string | EmailAddress | (string | EmailAddress)[];
} | {
cc: string | EmailAddress | (string | EmailAddress)[];
} | {
bcc: string | EmailAddress | (string | EmailAddress)[];
});
/**
* Fields shared by all composed emails (no recipients). Used directly by
* `ForwardableEmailMessage.reply()`, which always replies to the original
* sender, and extended by `EmailMessageBuilder` for `SendEmail.send()`.
*/
interface EmailReplyMessageBuilder {
from: string | EmailAddress;
subject: string;
replyTo?: string | EmailAddress;
headers?: Record<string, string>;
text?: string;
html?: string;
attachments?: EmailAttachment[];
}
/**
* Fields for composing an email without constructing raw MIME, for
* `SendEmail.send()`. Requires at least one of `to`, `cc`, or `bcc`.
*/
type EmailMessageBuilder = EmailReplyMessageBuilder & EmailDestinations;
/** /**
* A binding that allows a Worker to send email messages. * A binding that allows a Worker to send email messages.
*/ */
interface SendEmail { interface SendEmail {
send(message: EmailMessage): Promise<EmailSendResult>; send(message: EmailMessage): Promise<EmailSendResult>;
send(builder: { send(builder: EmailMessageBuilder): Promise<EmailSendResult>;
from: string | EmailAddress;
to: string | EmailAddress | (string | EmailAddress)[];
subject: string;
replyTo?: string | EmailAddress;
cc?: string | EmailAddress | (string | EmailAddress)[];
bcc?: string | EmailAddress | (string | EmailAddress)[];
headers?: Record<string, string>;
text?: string;
html?: string;
attachments?: EmailAttachment[];
}): Promise<EmailSendResult>;
} }
declare abstract class EmailEvent extends ExtendableEvent { declare abstract class EmailEvent extends ExtendableEvent {
readonly message: ForwardableEmailMessage; readonly message: ForwardableEmailMessage;
@@ -12396,6 +12846,15 @@ interface Hyperdrive {
* for your database. * for your database.
*/ */
readonly host: string; readonly host: string;
/*
* A synthetic IPv4 address (in the reserved 240.0.0.0/4 range) that, like the
* host field, is only valid within the context of the currently running
* Worker and, when passed into the `connect()` function from the
* "cloudflare:sockets" module, will connect to the Hyperdrive instance for
* your database. This is provided for database drivers that require the host
* to be an IP literal rather than a hostname.
*/
readonly ip: string;
/* /*
* The port that must be paired the the host field when connecting. * The port that must be paired the the host field when connecting.
*/ */
@@ -12417,6 +12876,78 @@ interface Hyperdrive {
*/ */
readonly database: string; readonly database: string;
} }
/**
* A handle to a dynamically-provisioned Hyperdrive connection, returned by
* `HyperdriveApi.get()`.
*/
interface HyperdriveDynamic extends Disposable {
/**
* The database name to use when connecting through this Hyperdrive.
*/
readonly database: Promise<string>;
/*
* The randomly generated user to use when authenticating to your
* database via Hyperdrive.
*/
readonly user: Promise<string>;
/*
* The randomly generated password to use when authenticating to your
* database via Hyperdrive.
*/
readonly password: Promise<string>;
/**
* Open a TCP socket to the target database through this Hyperdrive.
*
*/
connect(): Promise<Socket>;
}
/**
* Binding that provisions Hyperdrive connections at request time, rather than
* from static configuration.
*/
interface HyperdriveDynamicApi {
/**
* Provision a connection for the database described by `args`.
*
*/
get(args: HyperdriveDynamicConfig): Promise<HyperdriveDynamic>;
/**
* Get a pre-generated connection string used for connecting to dynamic Hyperdrive.
*/
getHyperdriveConnectionString(connectionString: string): Promise<string>;
}
/**
* Parameters identifying the database that a dynamically-provisioned
* Hyperdrive connection should target.
*/
interface HyperdriveDynamicConfig {
/**
* Generated connection string to pass into the dynamic worker.
*
*/
dynamicHyperdriveConnectionString: string;
/**
* Connection string for the origin database Hyperdrive should connect to.
* Contains credentials, so treat it as a secret.
*
* The scheme selects the database engine. PostgreSQL origins are supported.
*/
connectionString: string;
/**
* Region in which to place the connection pool. See the Hyperdrive
* documentation for the set of supported regions.
*/
targetRegion: string;
/**
* Whether Hyperdrive should cache query results for this connection.
*/
cachingEnabled?: boolean;
/**
* Maximum number of connections the pool may open to the origin database.
* Defaults to 60.
*/
maxConnections?: number;
}
// Copyright (c) 2024 Cloudflare, Inc. // Copyright (c) 2024 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at: // Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0 // https://opensource.org/licenses/Apache-2.0
@@ -12428,6 +12959,27 @@ type ImageInfoResponse = {
width: number; width: number;
height: number; height: number;
}; };
/**
* Parameters for rasterizing text into an image.
*/
type TextRasterize = {
/** The text content to render */
content: string;
/** rasterization options for the text **/
options: TextOptions;
};
type TextOptions = {
/** Font configuration */
font: {
/** URL to a font file in TrueType (.ttf), OpenType (.otf), WOFF (.woff), or WOFF2 (.woff2) format */
url: string;
};
/** Font size in points (pt) */
size?: number;
/** Text color in CSS format: hex (#RRGGBB or #RRGGBBAA), rgb(r,g,b), rgba(r,g,b,a), or named colors */
color?: string;
};
type ImageSource = ReadableStream<Uint8Array> | TextRasterize;
type ImageTransform = { type ImageTransform = {
width?: number; width?: number;
height?: number; height?: number;
@@ -12517,6 +13069,9 @@ interface ImageUploadOptions {
requireSignedURLs?: boolean; requireSignedURLs?: boolean;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
creator?: string; creator?: string;
/**
* If 'base64', the input data will be decoded from base64 before processing
*/
encoding?: 'base64'; encoding?: 'base64';
} }
interface ImageUpdateOptions { interface ImageUpdateOptions {
@@ -12524,11 +13079,40 @@ interface ImageUpdateOptions {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
creator?: string; creator?: string;
} }
type ImageMetadataFilterOperators = {
eq?: string | number | boolean;
in?: string[] | number[];
gt?: number;
gte?: number;
lt?: number;
lte?: number;
};
type ImageMetadataFilterValue = string | number | boolean | ImageMetadataFilterOperators;
interface ImageListFilter {
metadata?: Record<string, ImageMetadataFilterValue>;
}
interface ImageListOptions { interface ImageListOptions {
limit?: number; limit?: number;
cursor?: string; cursor?: string;
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
creator?: string; creator?: string;
filter?: ImageListFilter;
}
interface ImageSignedUrlOptions {
variant: string;
expiresIn?: number;
keyName?: string;
}
interface ImageDirectUploadOptions {
id?: string;
requireSignedURLs?: boolean;
metadata?: Record<string, unknown>;
creator?: string;
expiresIn?: number;
}
interface ImageDirectUploadResult {
id: string;
uploadURL: string;
} }
interface ImageList { interface ImageList {
images: ImageMetadata[]; images: ImageMetadata[];
@@ -12546,6 +13130,13 @@ interface ImageHandle {
* @returns ReadableStream of image bytes, or null if not found * @returns ReadableStream of image bytes, or null if not found
*/ */
bytes(): Promise<ReadableStream<Uint8Array> | null>; bytes(): Promise<ReadableStream<Uint8Array> | null>;
/**
* Generate a signed delivery URL for this hosted image.
* @param options Signing configuration
* @returns A signed image delivery URL
* @throws {@link ImagesError} if signing fails
*/
signedUrl(options: ImageSignedUrlOptions): Promise<string>;
/** /**
* Update hosted image metadata * Update hosted image metadata
* @param options Properties to update * @param options Properties to update
@@ -12581,6 +13172,14 @@ interface HostedImagesBinding {
* @throws {@link ImagesError} if list fails * @throws {@link ImagesError} if list fails
*/ */
list(options?: ImageListOptions): Promise<ImageList>; list(options?: ImageListOptions): Promise<ImageList>;
/**
* Create a Direct Creator Upload link, letting an end user upload an
* image straight to Cloudflare without exposing an API token
* @param options Upload link configuration
* @returns The new image ID and the upload URL to hand to the end user
* @throws {@link ImagesError} if creation fails
*/
createDirectUpload(options?: ImageDirectUploadOptions): Promise<ImageDirectUploadResult>;
} }
interface ImagesBinding { interface ImagesBinding {
/** /**
@@ -12595,6 +13194,13 @@ interface ImagesBinding {
* @returns A transform handle * @returns A transform handle
*/ */
input(stream: ReadableStream<Uint8Array>, options?: ImageInputOptions): ImageTransformer; input(stream: ReadableStream<Uint8Array>, options?: ImageInputOptions): ImageTransformer;
/**
* Begin applying a series of transformations to text
* @param content string to be rendered
* @param options font, optional color and size to use in rendering text
* @returns A transform handle
*/
text(content: string, options: TextOptions): ImageTransformer;
/** /**
* Access hosted images CRUD operations * Access hosted images CRUD operations
*/ */
@@ -12624,11 +13230,15 @@ interface ImageTransformer {
type ImageTransformationOutputOptions = { type ImageTransformationOutputOptions = {
encoding?: 'base64'; encoding?: 'base64';
}; };
type ImageTransformationResponseOptions = {
headers?: HeadersInit;
};
interface ImageTransformationResult { interface ImageTransformationResult {
/** /**
* The image as a response, ready to store in cache or return to users * The image as a response, ready to store in cache or return to users
* @param options Options that apply to the returned response, e.g. additional headers
*/ */
response(): Response; response(options?: ImageTransformationResponseOptions): Response;
/** /**
* The content type of the returned image * The content type of the returned image
*/ */
@@ -13064,6 +13674,11 @@ declare namespace CloudflareWorkersModule {
export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number;
export type WorkflowDelayDuration = WorkflowSleepDuration; export type WorkflowDelayDuration = WorkflowSleepDuration;
export type WorkflowDynamicDelayContext = {
ctx: WorkflowStepContext<WorkflowDelayFunction>;
error: Error;
};
export type WorkflowDelayFunction = (input: WorkflowDynamicDelayContext) => WorkflowDelayDuration | Promise<WorkflowDelayDuration>;
export type WorkflowTimeoutDuration = WorkflowSleepDuration; export type WorkflowTimeoutDuration = WorkflowSleepDuration;
export type WorkflowRetentionDuration = WorkflowSleepDuration; export type WorkflowRetentionDuration = WorkflowSleepDuration;
export type WorkflowBackoff = 'constant' | 'linear' | 'exponential'; export type WorkflowBackoff = 'constant' | 'linear' | 'exponential';
@@ -13071,12 +13686,32 @@ declare namespace CloudflareWorkersModule {
export type WorkflowStepConfig = { export type WorkflowStepConfig = {
retries?: { retries?: {
limit: number; limit: number;
delay: WorkflowDelayDuration | number; delay: WorkflowDelayDuration | number | WorkflowDelayFunction;
backoff?: WorkflowBackoff; backoff?: WorkflowBackoff;
}; };
timeout?: WorkflowTimeoutDuration | number; timeout?: WorkflowTimeoutDuration | number;
sensitive?: WorkflowStepSensitivity; sensitive?: WorkflowStepSensitivity;
}; };
// Internal discriminators used only for `WorkflowStep.do` overload
// resolution. They mirror `WorkflowStepConfig` but pin `retries.delay` to a
// single kind so the callback context can be narrowed based on the shape of
// the config argument (rather than on an inferred type parameter, which is
// lost when the caller supplies an explicit return-type argument). Not
// exported: they must not widen the public type surface.
type WorkflowStepConfigWithStaticDelay = Omit<WorkflowStepConfig, 'retries'> & {
retries?: {
limit: number;
delay: WorkflowDelayDuration | number;
backoff?: WorkflowBackoff;
};
};
type WorkflowStepConfigWithDelayFunction = Omit<WorkflowStepConfig, 'retries'> & {
retries: {
limit: number;
delay: WorkflowDelayFunction;
backoff?: WorkflowBackoff;
};
};
export type WorkflowStepRollbackConfig = Pick<WorkflowStepConfig, 'retries' | 'timeout'>; export type WorkflowStepRollbackConfig = Pick<WorkflowStepConfig, 'retries' | 'timeout'>;
export type WorkflowCronSchedule = { export type WorkflowCronSchedule = {
/** Cron expression that triggered this event. */ /** Cron expression that triggered this event. */
@@ -13097,28 +13732,51 @@ declare namespace CloudflareWorkersModule {
type: string; type: string;
sensitive?: WorkflowStepSensitivity; sensitive?: WorkflowStepSensitivity;
}; };
export type WorkflowStepContext = { export type WorkflowStepContext<Delay = WorkflowDelayDuration | number> = {
step: { step: {
name: string; name: string;
count: number; count: number;
}; };
attempt: number; attempt: number;
config: WorkflowStepConfig; config: {
retries?: {
limit: number;
backoff?: WorkflowBackoff;
} & (Delay extends WorkflowDelayFunction ? {} : {
delay: WorkflowDelayDuration | number;
});
timeout?: WorkflowTimeoutDuration | number;
sensitive?: WorkflowStepSensitivity;
};
}; };
export type WorkflowRollbackContext<T = unknown> = { // The rollback handler receives the step context, so it mirrors the same
ctx: WorkflowStepContext; // delay discriminant as the step callback: when the step was configured with
// a dynamic delay function the resolved `config.retries.delay` is omitted,
// otherwise it is present. `Delay` is threaded from the `WorkflowStep.do`
// overload that matched the step config.
export type WorkflowRollbackContext<T = unknown, Delay = WorkflowDelayDuration | number> = {
ctx: WorkflowStepContext<Delay>;
error: Error; error: Error;
output: T | undefined; output: T | undefined;
/** @deprecated Use `ctx.step.name` and `ctx.step.count` instead. */ /** @deprecated Use `ctx.step.name` and `ctx.step.count` instead. */
stepName: string; stepName: string;
}; };
export type WorkflowRollbackHandler<T = unknown> = (ctx: WorkflowRollbackContext<T>) => Promise<void>; export type WorkflowRollbackHandler<T = unknown, Delay = WorkflowDelayDuration | number> = (ctx: WorkflowRollbackContext<T, Delay>) => Promise<void>;
export type WorkflowStepRollbackOptions<T = unknown> = { export type WorkflowStepRollbackOptions<T = unknown, Delay = WorkflowDelayDuration | number> = {
rollback: WorkflowRollbackHandler<T>; rollback: WorkflowRollbackHandler<T, Delay>;
rollbackConfig?: WorkflowStepRollbackConfig; rollbackConfig?: WorkflowStepRollbackConfig;
}; };
export abstract class WorkflowStep { export abstract class WorkflowStep {
do<T extends Rpc.Serializable<T>>(name: string, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>; do<T extends Rpc.Serializable<T>>(name: string, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>;
// The config overloads discriminate on the shape of `config.retries.delay`
// so the callback context reflects whether the resolved delay is present
// (static delay) or omitted (dynamic delay function). Each has a single
// type parameter, so an explicit return-type argument (`do<T>(...)`) still
// resolves here. ORDERING IS LOAD-BEARING: the broad `WorkflowStepConfig`
// fallback MUST remain last, otherwise it shadows the discriminating
// overloads and narrowing is silently lost.
do<T extends Rpc.Serializable<T>>(name: string, config: WorkflowStepConfigWithDelayFunction, callback: (ctx: WorkflowStepContext<WorkflowDelayFunction>) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T, WorkflowDelayFunction>): Promise<T>;
do<T extends Rpc.Serializable<T>>(name: string, config: WorkflowStepConfigWithStaticDelay, callback: (ctx: WorkflowStepContext<WorkflowDelayDuration | number>) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T, WorkflowDelayDuration | number>): Promise<T>;
do<T extends Rpc.Serializable<T>>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>; do<T extends Rpc.Serializable<T>>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise<T>, rollbackOptions?: WorkflowStepRollbackOptions<T>): Promise<T>;
sleep: (name: string, duration: WorkflowSleepDuration) => Promise<void>; sleep: (name: string, duration: WorkflowSleepDuration) => Promise<void>;
sleepUntil: (name: string, timestamp: Date | number) => Promise<void>; sleepUntil: (name: string, timestamp: Date | number) => Promise<void>;
@@ -13894,11 +14552,12 @@ type MarkdownDocument = {
name: string; name: string;
blob: Blob; blob: Blob;
}; };
type OutputFormat = 'markdown' | 'text';
type ConversionResponse = { type ConversionResponse = {
id: string; id: string;
name: string; name: string;
mimeType: string; mimeType: string;
format: 'markdown'; format: OutputFormat;
tokens: number; tokens: number;
data: string; data: string;
} | { } | {
@@ -13915,7 +14574,11 @@ type EmbeddedImageConversionOptions = ImageConversionOptions & {
convert?: boolean; convert?: boolean;
maxConvertedImages?: number; maxConvertedImages?: number;
}; };
type ConversionOutputOptions = {
format?: OutputFormat;
};
type ConversionOptions = { type ConversionOptions = {
output?: ConversionOutputOptions;
html?: { html?: {
images?: EmbeddedImageConversionOptions & { images?: EmbeddedImageConversionOptions & {
convertOGImage?: boolean; convertOGImage?: boolean;
@@ -14010,7 +14673,7 @@ declare namespace TailStream {
interface ConnectEventInfo { interface ConnectEventInfo {
readonly type: "connect"; readonly type: "connect";
} }
type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime"; type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime" | "aborted";
interface ScriptVersion { interface ScriptVersion {
readonly id: string; readonly id: string;
readonly tag?: string; readonly tag?: string;
@@ -14029,6 +14692,7 @@ declare namespace TailStream {
readonly dispatchNamespace?: string; readonly dispatchNamespace?: string;
readonly entrypoint?: string; readonly entrypoint?: string;
readonly executionModel: string; readonly executionModel: string;
readonly durableObjectId?: string;
readonly scriptName?: string; readonly scriptName?: string;
readonly scriptTags?: string[]; readonly scriptTags?: string[];
readonly scriptVersion?: ScriptVersion; readonly scriptVersion?: ScriptVersion;
@@ -14063,11 +14727,22 @@ declare namespace TailStream {
readonly message: string; readonly message: string;
readonly stack?: string; readonly stack?: string;
} }
interface Log { interface TailStreamErrorInfo {
readonly name: string;
readonly message: string;
readonly stack?: string;
}
type Log = {
readonly type: "log"; readonly type: "log";
readonly level: "debug" | "error" | "info" | "log" | "warn"; readonly level: "debug" | "error" | "info" | "log" | "warn";
readonly errorInfo?: readonly (TailStreamErrorInfo | null)[];
} & ({
readonly message: object; readonly message: object;
} readonly truncated?: false;
} | {
readonly message: string;
readonly truncated: true;
});
interface DroppedEventsDiagnostic { interface DroppedEventsDiagnostic {
readonly diagnosticsType: "droppedEvents"; readonly diagnosticsType: "droppedEvents";
readonly count: number; readonly count: number;
@@ -14545,10 +15220,31 @@ declare abstract class Workflow<PARAMS = unknown> {
* @returns A promise that resolves with a list of handles for the created instances. * @returns A promise that resolves with a list of handles for the created instances.
*/ */
public createBatch(batch: WorkflowInstanceCreateOptions<PARAMS>[]): Promise<WorkflowInstance[]>; public createBatch(batch: WorkflowInstanceCreateOptions<PARAMS>[]): Promise<WorkflowInstance[]>;
/**
* Delete a batch of Workflow instances and their stored state.
* `deleteBatch` is limited to 100 instances at a time. Duplicate IDs are deleted once.
* The result contains one entry for each input position; IDs that do not exist are returned as per-instance errors.
* @param instanceIds IDs of the Workflow instances to delete
* @returns A promise that resolves with the successfully deleted instances and any per-instance errors.
*/
public deleteBatch(instanceIds: string[]): Promise<WorkflowBatchDeleteResult>;
} }
type WorkflowBatchDeleteResult = {
deleted: {
id: string;
}[];
errors: {
id: string;
code: number;
message: string;
}[];
};
type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number;
type WorkflowRetentionDuration = WorkflowSleepDuration; type WorkflowRetentionDuration = WorkflowSleepDuration;
/** Geographic regions supported when creating a Workflow instance.
* Location hints are best-effort placement preferences. */
type WorkflowInstanceLocationHint = 'wnam' | 'enam' | 'sam' | 'weur' | 'eeur' | 'apac' | 'apac-ne' | 'apac-se' | 'oc' | 'afr' | 'me';
interface WorkflowInstanceCreateOptions<PARAMS = unknown> { interface WorkflowInstanceCreateOptions<PARAMS = unknown> {
/** /**
* An id for your Workflow instance. Must be unique within the Workflow. * An id for your Workflow instance. Must be unique within the Workflow.
@@ -14566,6 +15262,9 @@ interface WorkflowInstanceCreateOptions<PARAMS = unknown> {
successRetention?: WorkflowRetentionDuration; successRetention?: WorkflowRetentionDuration;
errorRetention?: WorkflowRetentionDuration; errorRetention?: WorkflowRetentionDuration;
}; };
/** A best-effort geographic placement preference for the Workflow instance.
* See `WorkflowInstanceLocationHint` for supported regions. */
locationHint?: WorkflowInstanceLocationHint;
} }
type InstanceStatus = { type InstanceStatus = {
status: 'queued' // means that instance is waiting to be started (see concurrency limits) status: 'queued' // means that instance is waiting to be started (see concurrency limits)
@@ -14583,6 +15282,13 @@ interface WorkflowError {
code?: number; code?: number;
message: string; message: string;
} }
interface WorkflowInstanceTerminateOptions {
/**
* If true, run registered rollback handlers before terminating the instance.
* Only steps that registered rollback handlers are rolled back.
*/
rollback?: boolean;
}
interface WorkflowInstanceRestartOptions { interface WorkflowInstanceRestartOptions {
/** /**
* Restart from a specific step. If omitted, the instance restarts from the beginning. * Restart from a specific step. If omitted, the instance restarts from the beginning.
@@ -14616,14 +15322,19 @@ declare abstract class WorkflowInstance {
public resume(): Promise<void>; public resume(): Promise<void>;
/** /**
* Terminate the instance. If it is errored, terminated or complete, an error will be thrown. * Terminate the instance. If it is errored, terminated or complete, an error will be thrown.
* @param options Options for termination, including whether registered rollback handlers should run.
*/ */
public terminate(): Promise<void>; public terminate(options?: WorkflowInstanceTerminateOptions): Promise<void>;
/** /**
* Restart the instance. Optionally restart from a specific step, preserving * Restart the instance. Optionally restart from a specific step, preserving
* cached results for all steps before it. * cached results for all steps before it.
* @param options Options for the restart, including an optional step to restart from. * @param options Options for the restart, including an optional step to restart from.
*/ */
public restart(options?: WorkflowInstanceRestartOptions): Promise<void>; public restart(options?: WorkflowInstanceRestartOptions): Promise<void>;
/**
* Delete the instance and its stored state.
*/
public delete(): Promise<void>;
/** /**
* Returns the current status of the instance. * Returns the current status of the instance.
*/ */
+9
View File
@@ -46,6 +46,15 @@
"secret_name": "META_APP_SECRET" "secret_name": "META_APP_SECRET"
} }
], ],
// Cloudflare Email Sending, for the password-recovery code (POST /account/recoverpassword).
// Nothing to splice in: the binding has no id, but the `from` domain (EMAIL_FROM, default
// no-reply@<DOMAIN>) has to be enabled for Email Sending once per account — see DEPLOYING.md.
// Locally (and under vitest) the binding logs the message rather than sending it.
"send_email": [
{
"name": "EMAIL"
}
],
"upload_source_maps": true, "upload_source_maps": true,
"observability": { "observability": {
"enabled": false "enabled": false
+20 -6
View File
@@ -362,6 +362,13 @@ const app = new Hono<App>()
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`) (c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
) )
// SDK source downloads use an opaque upload capability (no bearer on this
// request). Only creator-authenticated save history exposes these filenames.
.get('/avatarunityasset/:filename{.+}', (c) => {
const name = c.req.param('filename')
if (!/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}$/.test(name)) return c.notFound()
return serveAsset(c, `avatarunityasset/${name}`)
})
// Editable Unity source manifests. Baked bundles are served separately. // Editable Unity source manifests. Baked bundles are served separately.
.get( .get(
'/roomunityasset/:filename{.+}', '/roomunityasset/:filename{.+}',
@@ -430,15 +437,22 @@ const app = new Hono<App>()
* a build is stored under it. Null when no build is, or the table has not been migrated * a build is stored under it. Null when no build is, or the table has not been migrated
* yet (the rooms worker owns it). * yet (the rooms worker owns it).
*/ */
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> { async function unityAssetR2Key(env: StudioAssetEnv, filename: string): Promise<string | null> {
try { try {
// Inlined rather than imported so this worker does not take a dependency on the // Inlined rather than imported so this worker does not take a dependency on the
// domain package. // domain package.
const row = await db const row = await env.DB.prepare(
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`) `SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`
)
.bind(filename) .bind(filename)
.first<{ stored: number }>() .first<{ stored: number }>()
return row ? filename : null if (!row) return null
if (/^(quest\/)?studio-avatar\//.test(filename)) return `avatar/${filename}`
// Room builds and consumable exports use the game's /room namespace.
// Retain older installations that stored the same filename at the bucket root.
if (filename.startsWith('studio/') && (await env.CDN_ASSETS.head(`room/${filename}`)))
return `room/${filename}`
return filename
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
if (message.toLowerCase().includes('no such table')) return null if (message.toLowerCase().includes('no such table')) return null
@@ -450,14 +464,14 @@ async function unityAssetR2Key(db: D1Database, filename: string): Promise<string
async function serveUnityAsset(c: Context<App>) { async function serveUnityAsset(c: Context<App>) {
const name = c.req.param('filename') ?? '' const name = c.req.param('filename') ?? ''
if (name.includes('..')) return c.body(null, 400) if (name.includes('..')) return c.body(null, 400)
const key = await unityAssetR2Key(c.env.DB, name) const key = await unityAssetR2Key(c.env, name)
return serveAsset(c, key ?? `unityasset/${name}`) return serveAsset(c, key ?? `unityasset/${name}`)
} }
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */ /** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> { async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
if (name.includes('..')) return new Response(null, { status: 400 }) if (name.includes('..')) return new Response(null, { status: 400 })
const key = await unityAssetR2Key(env.DB, name) const key = await unityAssetR2Key(env, name)
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`) const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
if (!head) return new Response(null, { status: 404 }) if (!head) return new Response(null, { status: 404 })
+38
View File
@@ -12,6 +12,32 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com' const ORIGIN = 'https://example.com'
test('serves opaque avatar source manifests and SDK avatar bundle GET/HEAD/ranges', async () => {
const source = '2026-10-18/11111111-1111-1111-1111-111111111111'
await env.CDN_ASSETS.put('avatarunityasset/' + source, '{"Version":3}')
expect(await (await exports.default.fetch(ORIGIN + '/avatarunityasset/' + source)).text()).toBe(
'{"Version":3}'
)
await env.DB.prepare(
'CREATE TABLE IF NOT EXISTS unity_asset(unity_asset_id TEXT NOT NULL,target INTEGER NOT NULL,version INTEGER NOT NULL,kind TEXT NOT NULL,filename TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(unity_asset_id,target,version,kind))'
).run()
const name = 'quest/studio-avatar/test/item/lease.material.assetbundle'
await env.DB.prepare(
'INSERT INTO unity_asset(unity_asset_id,target,version,kind,filename,hash) VALUES (?,2,6,?,?,?)'
)
.bind('avatar-fixture', 'material', name, 'hash')
.run()
await env.CDN_ASSETS.put('avatar/' + name, 'UnityFS\0avatar')
expect(
(await exports.default.fetch(ORIGIN + '/unityasset/' + name, { method: 'HEAD' })).status
).toBe(200)
const response = await exports.default.fetch(ORIGIN + '/unityasset/' + name, {
headers: { Range: 'bytes=0-7' },
})
expect(response.status).toBe(206)
expect(await response.text()).toBe('UnityFS\0')
})
describe('cdn endpoints', () => { describe('cdn endpoints', () => {
test('GET / reports service status', async () => { test('GET / reports service status', async () => {
const res = await exports.default.fetch(`${ORIGIN}/`) const res = await exports.default.fetch(`${ORIGIN}/`)
@@ -357,6 +383,18 @@ describe('cdn endpoints', () => {
expect(head.status).toBe(200) expect(head.status).toBe(200)
expect(head.headers.get('content-length')).toBe('4') expect(head.headers.get('content-length')).toBe('4')
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0)) expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
// Current room/consumable builds live in the game's room namespace.
await env.CDN_ASSETS.put('room/' + filename, new Uint8Array([5, 6, 7, 8, 9]))
const current = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
headers: { Range: 'bytes=1-2' },
})
expect(current.status).toBe(206)
expect(new Uint8Array(await current.arrayBuffer())).toEqual(new Uint8Array([6, 7]))
const currentHead = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
method: 'HEAD',
})
expect(currentHead.status).toBe(200)
expect(currentHead.headers.get('content-length')).toBe('5')
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404) expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
expect( expect(
+3 -1
View File
@@ -17,11 +17,13 @@ export const SCHEMA_DDL: string[] = [
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL, account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL, username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL, has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL,
is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL,
email_lower TEXT GENERATED ALWAYS AS (NULLIF(lower(json_extract(data, '$.email')), '')) VIRTUAL
)`, )`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`,
`CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`, `CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`,
`CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1`, `CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_account_email_lower ON account (email_lower) WHERE email_lower IS NOT NULL`,
] ]
/** The stored avatar payload — opaque JSON the client sets and reads back. */ /** The stored avatar payload — opaque JSON the client sets and reads back. */
+62
View File
@@ -106,6 +106,8 @@ import {
ConsumeConsumableRequest, ConsumeConsumableRequest,
ConsumeEnvelope, ConsumeEnvelope,
ConsumeGiftRequest, ConsumeGiftRequest,
ConsumeRoomConsumableRequest,
ConsumeRoomConsumableResponse,
CreatePurchaseOfferRequest, CreatePurchaseOfferRequest,
CreateRoomCurrencyRequest, CreateRoomCurrencyRequest,
CreateRoomKeyRequest, CreateRoomKeyRequest,
@@ -177,6 +179,7 @@ import {
import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db' import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
import { import {
awardRoomConsumable, awardRoomConsumable,
consumeRoomConsumable,
getRoomConsumable, getRoomConsumable,
getRoomConsumables, getRoomConsumables,
getRoomInventory, getRoomInventory,
@@ -4220,6 +4223,65 @@ const app = new Hono<App>({ strict: false })
return c.json(await getRoomInventory(c.env.DB, roomId, id)) return c.json(await getRoomInventory(c.env.DB, roomId, id))
} }
) )
// Use up one of a room consumable the CALLER holds — drinking the Health Potion. Auth-gated
// (401); a refusal is a 200 in the same shape, as the purchase's is.
.put(
'/api/roomconsumables/v1/roomConsumable/:id/consume',
describeRoute({
tags: ['Econ'],
summary: 'Consume a room consumable',
description: [
'Takes ONE of the consumable named in the path from what the caller holds. The body',
'is only `{ CurrentConcurrencyCode, NewConcurrencyCode }`, at the top level; there is',
'no quantity. `NewConcurrencyCode` is stored and served back as the item’s',
'`ConcurrencyCode`; `CurrentConcurrencyCode` is accepted and NOT checked.',
'',
'Answers `{ Status, InventoryItem }` — the purchase’s `OperationResult` — with the',
'`RoomConsumableInventoryItem` the caller now holds: `Count` the RESULTING total, 0',
'when they used their last (after which `…/room/{roomId}/me` omits it).',
'',
'A consumable that does not exist, or one the caller holds none of, is `Status` 1',
'with a null `InventoryItem`, and nothing is written. The refusal form is an',
'ASSUMPTION — only success has been observed.',
].join('\n'),
security: AUTHED,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'The consumable to use — a `room_consumable` id',
schema: { type: 'string' },
},
],
requestBody: jsonBody(ConsumeRoomConsumableRequest, 'The concurrency pair'),
responses: {
200: json(ConsumeRoomConsumableResponse, 'What the caller now holds, or a refusal'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.json<{ NewConcurrencyCode?: unknown }>().catch(() => null)
const newCode =
body !== null && typeof body === 'object' && typeof body.NewConcurrencyCode === 'string'
? body.NewConcurrencyCode
: null
// Every key is always sent: an omitted `Status` reads as its enum's 0, success.
const refused = () => c.json({ Status: ROOM_KEY_STATUS_FAILED, InventoryItem: null })
const consumable = await getRoomConsumable(c.env.DB, c.req.param('id'))
if (!consumable) return refused()
const held = await consumeRoomConsumable(c.env.DB, id, consumable.RoomConsumableId, newCode)
if (!held) return refused()
return c.json({
Status: ROOM_KEY_STATUS_OK,
InventoryItem: toInventoryItem(id, held, consumable),
})
}
)
// The custom currencies a room has minted. Public, like the room itself: a room's // The custom currencies a room has minted. Public, like the room itself: a room's
// currencies are shown to every player who walks into it, so the list is not a secret // currencies are shown to every player who walks into it, so the list is not a secret
// the way its ban list is. A room with none — or an unknown room — is an empty list. // the way its ban list is. A room with none — or an unknown room — is an empty list.
+21
View File
@@ -1159,6 +1159,27 @@ export const PurchaseRoomConsumableResponse = z.object({
}), }),
}) })
/**
* `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` — just the concurrency pair, at the
* TOP level (not nested under `ConcurrencyCodes` as the purchase sends it). Uses up one.
*/
export const ConsumeRoomConsumableRequest = z.object({
CurrentConcurrencyCode: z.string().nullable().optional().describe('Accepted and NOT checked'),
NewConcurrencyCode: z
.string()
.nullable()
.optional()
.describe('Stored and echoed back as the item’s `ConcurrencyCode`'),
})
/** The consume's answer: the purchase's `OperationResult`, alone. */
export const ConsumeRoomConsumableResponse = z.object({
Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'),
InventoryItem: RoomConsumableInventoryItemDto.nullable().describe(
'What the caller holds afterwards (`Count` the resulting total, 0 included); null on a refusal'
),
})
/** /**
* The envelope the consumable write answers in — the same * The envelope the consumable write answers in — the same
* `{ Value, Success, Error, error_id }` the room-currency writes use. * `{ Value, Success, Error, error_id }` the room-currency writes use.
+37
View File
@@ -13,6 +13,7 @@
* - `POST /api/roomconsumables/v1/roomConsumable/awardBulk` gives one to a player * - `POST /api/roomconsumables/v1/roomConsumable/awardBulk` gives one to a player
* - `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens` and `…/purchase/currency` * - `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens` and `…/purchase/currency`
* sell one, in tokens or the listing's room currency * sell one, in tokens or the listing's room currency
* - `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` uses up one the caller holds
* *
* `PurchaseCurrencyId` is a `room_currency`, so a room prices its goods in its own money. It * `PurchaseCurrencyId` is a `room_currency`, so a room prices its goods in its own money. It
* is nullable — `Guid?` in the client's model — for a listing that names no currency, and * is nullable — `Guid?` in the client's model — for a listing that names no currency, and
@@ -344,6 +345,42 @@ export async function awardRoomConsumable(
} }
} }
/**
* Use up ONE of a consumable a player holds, returning what they hold afterwards — or null
* when they hold none, and nothing was written. `concurrencyCode` is the request's
* `NewConcurrencyCode`, stored to be served back; null leaves the stored one.
*
* A single conditional UPDATE: the `quantity >= 1` guard is what refuses, so two consumes
* racing over the last one cannot both succeed. Here an empty `RETURNING` is exactly the
* answer wanted — "you had none" — unlike the award, where it would be ambiguous.
*/
export async function consumeRoomConsumable(
db: D1Database,
playerId: number,
consumableId: string,
concurrencyCode: string | null = null
): Promise<RoomInventoryHolding | null> {
const row = await db
.prepare(
`UPDATE room_inventory SET
quantity = quantity - 1,
concurrency_code = COALESCE(?3, concurrency_code),
modified_at = ?4
WHERE player_id = ?1 AND consumable_id = ?2 AND quantity >= 1
RETURNING quantity, concurrency_code, modified_at`
)
.bind(playerId, consumableId, concurrencyCode, new Date().toISOString())
.first<{ quantity: number; concurrency_code: string | null; modified_at: string | null }>()
return row
? {
quantity: row.quantity,
concurrencyCode: row.concurrency_code,
modifiedAt: row.modified_at,
}
: null
}
/** /**
* Everything a player owns of one room's consumables, in the shop's order. A listing they own * Everything a player owns of one room's consumables, in the shop's order. A listing they own
* none of is absent — including one they held and spent down to zero. * none of is absent — including one they held and spent down to zero.
@@ -1773,6 +1773,77 @@ describe('econ endpoints', () => {
await env.DB.prepare('DELETE FROM room_balance WHERE currency_id = ?1').bind(coin).run() await env.DB.prepare('DELETE FROM room_balance WHERE currency_id = ?1').bind(coin).run()
}) })
test('PUT roomConsumable/:id/consume uses up one the caller holds', async () => {
const potion = (
await envOf(await save({ ...body, Name: 'Drinkable potion' }, await bearer('1')))
).Value!
await env.DB.prepare(
'INSERT INTO room_inventory (player_id, consumable_id, quantity) VALUES (7801, ?1, 2)'
)
.bind(potion.RoomConsumableId)
.run()
const consume = async (sub: string | null, consumableId: string, code: string) =>
exports.default.fetch(
`${ORIGIN}/api/roomconsumables/v1/roomConsumable/${consumableId}/consume`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(sub === null ? {} : await bearer(sub)),
},
body: JSON.stringify({
CurrentConcurrencyCode: '872adb91-b163-4f2c-aa8f-4f5d828078cb',
NewConcurrencyCode: code,
}),
}
)
const held = async () =>
(
await env.DB.prepare(
'SELECT quantity FROM room_inventory WHERE player_id = 7801 AND consumable_id = ?1'
)
.bind(potion.RoomConsumableId)
.first<{ quantity: number }>()
)?.quantity
expect((await consume(null, potion.RoomConsumableId, 'a')).status).toBe(401)
const res = await consume('7801', potion.RoomConsumableId, 'code-1')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Status: 0,
InventoryItem: {
RoomConsumableId: potion.RoomConsumableId,
AccountId: 7801,
Count: 1,
ConcurrencyCode: 'code-1',
ModifiedAt: expect.any(String),
Consumable: { ...potion, ImageName: '', IsGiftable: false, Visibility: 0 },
},
})
// The last one answers Count 0; after that there is nothing to use.
const last = (await (await consume('7801', potion.RoomConsumableId, 'code-2')).json()) as {
Status: number
InventoryItem: { Count: number; ConcurrencyCode: string } | null
}
expect(last).toMatchObject({
Status: 0,
InventoryItem: { Count: 0, ConcurrencyCode: 'code-2' },
})
const refusal = { Status: 1, InventoryItem: null }
expect(await (await consume('7801', potion.RoomConsumableId, 'code-3')).json()).toEqual(
refusal
)
expect(await held()).toBe(0)
// Someone who never had one, and an unknown consumable, are refused too.
expect(await (await consume('7802', potion.RoomConsumableId, 'x')).json()).toEqual(refusal)
expect(await (await consume('7801', 'nope', 'x')).json()).toEqual(refusal)
await env.DB.prepare('DELETE FROM room_inventory').run()
})
test('GET roomConsumable/room/:id is public and empty for an unknown room', async () => { test('GET roomConsumable/room/:id is public and empty for an unknown room', async () => {
expect(await shopOf(99999)).toEqual([]) expect(await shopOf(99999)).toEqual([])
const res = await exports.default.fetch( const res = await exports.default.fetch(
@@ -8047,6 +8118,7 @@ describe('econ endpoints', () => {
'POST /api/ugcPurchasables/v1/items/bulk', 'POST /api/ugcPurchasables/v1/items/bulk',
'PUT /api/equipment/v1/update', 'PUT /api/equipment/v1/update',
'PUT /api/roomconsumables/v1/roomConsumable', 'PUT /api/roomconsumables/v1/roomConsumable',
'PUT /api/roomconsumables/v1/roomConsumable/{id}/consume',
'PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{via}', 'PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{via}',
'PUT /api/roomkeys/v1/updateAll', 'PUT /api/roomkeys/v1/updateAll',
]) ])
@@ -26,6 +26,7 @@ import {
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
} from '@repo/domain' } from '@repo/domain'
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db' import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
import { import {
banFromReport, banFromReport,
@@ -289,6 +290,9 @@ beforeAll(async () => {
// Platform identity links (owned by the auth worker) — a ban also reaches the // Platform identity links (owned by the auth worker) — a ban also reaches the
// accounts sharing a proven identity with the banned one. // accounts sharing a proven identity with the banned one.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Custom avatar items (owned by the api worker) — every room read joins the published
// room badges from this table.
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
}) })
/** /**
+7
View File
@@ -22,6 +22,13 @@ Player-settings worker served on the `playersettings` subdomain.
> Emptying the map with DELETE puts the player back to a first read: the next > Emptying the map with DELETE puts the player back to a first read: the next
> `GET` re-seeds the defaults. > `GET` re-seeds the defaults.
> Keys under `Growth.YIR.` (the client's Year-In-Review telemetry — distance
> drawn, time with friends, …) and `MakerPenSessionCount` are **dropped** on
> `PUT`, not stored. The client posts them with a new value every time, so they
> were over half of all writes and nothing reads them. Real maker pen preferences
> (`MakerPen_SnappingMode`, …) are kept. See `IGNORED_SETTING_KEYS` /
> `IGNORED_SETTING_PREFIXES` in `default-settings.ts`.
## KV namespace ## KV namespace
```sh ```sh
@@ -8,3 +8,21 @@ export const DEFAULT_SETTINGS: Array<{ Key: string; Value: string }> = [
{ Key: 'TUTORIAL_COMPLETE_MASK', Value: '11' }, { Key: 'TUTORIAL_COMPLETE_MASK', Value: '11' },
{ Key: 'FIRST_TIME_IN_FLAGS', Value: '0' }, { Key: 'FIRST_TIME_IN_FLAGS', Value: '0' },
] ]
/**
* Settings the server DROPS on write, by exact key and by prefix. The client posts its
* Year-In-Review telemetry (`Growth.YIR.UGC.TubeDistance`, `Growth.YIR.DistanceTraveled`, …)
* and the `MakerPenSessionCount` counter through this endpoint as the player plays, and the
* values change every time, so they can never be deduplicated — measured at ~500 CCU they
* were over half of all settings writes. Nothing server-side reads them and the client works
* without them (a new player has none), so a PUT naming one is answered 200 and nothing is
* stored. Real maker pen preferences (`MakerPen_SnappingMode` and the other `MakerPen_*`
* keys) are kept: the client reads those back.
*/
export const IGNORED_SETTING_KEYS: ReadonlySet<string> = new Set(['MakerPenSessionCount'])
export const IGNORED_SETTING_PREFIXES: readonly string[] = ['Growth.YIR.']
/** Whether a posted setting is one the server drops rather than stores. */
export function isIgnoredSetting(key: string): boolean {
return IGNORED_SETTING_KEYS.has(key) || IGNORED_SETTING_PREFIXES.some((p) => key.startsWith(p))
}
@@ -6,7 +6,7 @@ import { mergePlayerSettings } from '@repo/domain'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
import { DEFAULT_SETTINGS } from './default-settings' import { DEFAULT_SETTINGS, isIgnoredSetting } from './default-settings'
import { import {
AUTHED, AUTHED,
formOrJson, formOrJson,
@@ -196,7 +196,9 @@ const app = new Hono<App>()
'player’s other settings alone. A JSON body is also accepted, as one object or an', 'player’s other settings alone. A JSON body is also accepted, as one object or an',
'array, in either `key`/`value` or `Key`/`Value` casing; entries with an empty key are', 'array, in either `key`/`value` or `Key`/`Value` casing; entries with an empty key are',
'dropped. An unparseable or empty body is a no-op 200, not a 400, and so is a PUT whose', 'dropped. An unparseable or empty body is a no-op 200, not a 400, and so is a PUT whose',
'values are already stored — nothing is written to KV. Empty body on success.', 'values are already stored — nothing is written to KV. Keys under `Growth.YIR.` (the',
'client’s Year-In-Review telemetry) and `MakerPenSessionCount`, which change on every',
'post and nothing reads, are dropped rather than stored. Empty body on success.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'), requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
@@ -209,7 +211,7 @@ const app = new Hono<App>()
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const incoming = await parseSettings(c) const incoming = (await parseSettings(c)).filter((s) => !isIgnoredSetting(s.key))
if (incoming.length === 0) return c.body(null, 200) if (incoming.length === 0) return c.body(null, 200)
const patch: Record<string, string> = {} const patch: Record<string, string> = {}
@@ -164,6 +164,42 @@ describe('playersettings endpoints', () => {
) )
}) })
it('PUT /playersettings drops Growth.YIR and MakerPenSessionCount without writing', async () => {
const stored = '{ "PlayerSessionCount": "5" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player:11', stored)
const form = await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'Growth.YIR.UGC.TubeDistance', value: '1234.5' }, await bearer('11'))
)
expect(form.status).toBe(200)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'text')).toBe(stored)
// A JSON batch keeps the real settings (a maker pen PREFERENCE included) and drops
// only the telemetry and the session counter.
const json = await SELF.fetch(`${ORIGIN}/playersettings`, {
method: 'PUT',
headers: { 'content-type': 'application/json', ...(await bearer('11')) },
body: JSON.stringify([
{ key: 'Growth.YIR.DistanceTraveled', value: '99' },
{ key: 'MakerPenSessionCount', value: '3' },
{ key: 'MakerPen_SnappingMode', value: '2' },
]),
})
expect(json.status).toBe(200)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'json')).toEqual({
PlayerSessionCount: '5',
MakerPen_SnappingMode: '2',
})
// A player with nothing stored stays unseeded: no key is created for telemetry alone.
await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'Growth.YIR.TimeSpentWithFriends', value: '7' }, await bearer('12'))
)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:12', 'text')).toBeNull()
})
it('PUT /playersettings 200s with no parseable settings', async () => { it('PUT /playersettings 200s with no parseable settings', async () => {
const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9'))) const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9')))
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -0,0 +1,11 @@
-- The searchable rooms by lowercased name, for the website's header search
-- (`GET /rooms/search/name`, searchRoomsByName).
--
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
--
-- PARTIAL over exactly SEARCHABLE_WHERE, so a prefix seek visits only rooms it can serve
-- and the LIMIT stops it: D1 bills the rows returned, not the room count. The existing
-- `idx_rooms_name_lower` holds every dorm and private room, and each one the seek passed
-- would be read to test the filters. Built from the VIRTUAL columns, so no backfill.
CREATE INDEX IF NOT EXISTS idx_room_search_name ON room (name_lower)
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1;
+127 -41
View File
@@ -70,6 +70,7 @@ import {
roomRoles, roomRoles,
saveSubRoomData, saveSubRoomData,
searchRooms, searchRooms,
searchRoomsByName,
setPlayerData, setPlayerData,
setRoomDescription, setRoomDescription,
setRoomImage, setRoomImage,
@@ -83,6 +84,7 @@ import {
transferRoomOwnership, transferRoomOwnership,
unbanPlayerFromRoom, unbanPlayerFromRoom,
updateRoomFields, updateRoomFields,
withConfirmedRoles,
writeAuditLog, writeAuditLog,
} from '@repo/domain' } from '@repo/domain'
import { import {
@@ -665,16 +667,14 @@ function parseMaxPlayerCalculationMode(value: unknown): number | undefined {
return Object.values(MAX_PLAYER_CALCULATION_MODES).includes(ordinal) ? ordinal : undefined return Object.values(MAX_PLAYER_CALCULATION_MODES).includes(ordinal) ? ordinal : undefined
} }
async function pushRoomUpdate( async function pushRoomUpdate(c: Context<App>, playerId: number, room: Room): Promise<void> {
c: Context<App>,
playerId: number,
room: Record<string, unknown>
): Promise<void> {
try { try {
// The pushed room is the client's room, like any served one: pending invites are not
// part of its `Roles` (see `serveRoom`).
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId, playerId,
NotificationType.SubscriptionUpdateRoom, NotificationType.SubscriptionUpdateRoom,
room withConfirmedRoles(room)
) )
} catch (err) { } catch (err) {
logger.error('failed to push RoomUpdate notification', { logger.error('failed to push RoomUpdate notification', {
@@ -702,7 +702,7 @@ async function pushRoomUpdate(
async function pushRoomUpdateToRoom( async function pushRoomUpdateToRoom(
c: Context<App>, c: Context<App>,
roomId: number, roomId: number,
room: Record<string, unknown>, room: Room,
alsoNotify: number[] = [] alsoNotify: number[] = []
): Promise<void> { ): Promise<void> {
const playerIds = new Set(await getPlayerIdsInRoom(c.env.DB, roomId)) const playerIds = new Set(await getPlayerIdsInRoom(c.env.DB, roomId))
@@ -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
@@ -1108,16 +1114,44 @@ function toSaveDetail(save: Record<string, unknown>, target: number | null) {
} }
} }
/** Client envelope for room mutations: `{ success, error, value }` (lowercase). */ /**
function roomEnvelope(c: Context<App>, value: unknown, error = '') { * A room as the client is shown it. The stored room is served whole EXCEPT for its pending
return c.json({ success: error === '', error, value }) * role invites: a `Roles` entry with `Role` 0 and an `InvitedRole` is an offer the player
* has not answered, and served as-is it put the invited player on the room's member list
* (and, on the 2025 client, in its co-owner list) before they had accepted anything. The
* offer reaches them as a `RoomCoOwnerInvited` message; the entry stays in the STORED room
* for the accept to promote, which is why this is applied where the room leaves the worker
* and never to a room about to be written back.
*
* Every room this worker answers — alone, in a list, in the mutation envelope or pushed as
* a `RoomUpdate` — goes through this (or {@link serveRooms}).
*/
const serveRoom = withConfirmedRoles
/** {@link serveRoom} over a list. */
const serveRooms = (rooms: Room[]): Room[] => rooms.map(serveRoom)
/** {@link serveRoom} over a `{ Results, TotalResults }` page. */
const serveRoomPage = <T extends { Results: Room[] }>(page: T): T => ({
...page,
Results: serveRooms(page.Results),
})
/**
* Client envelope for room mutations: `{ success, error, value }` (lowercase). `value` is
* the updated room (served through {@link serveRoom}) or null on a refusal.
*/
function roomEnvelope(c: Context<App>, value: Room | null, error = '') {
return c.json({ success: error === '', error, value: value && serveRoom(value) })
} }
/** /**
* The same envelope for the ban write, whose `value` is the BAN rather than the room — * The same envelope for the ban write, whose `value` is the BAN rather than the room —
* a ban isn't part of the room the client renders, so there is no updated room to send. * a ban isn't part of the room the client renders, so there is no updated room to send.
*/ */
const banEnvelope = roomEnvelope function banEnvelope(c: Context<App>, value: unknown, error = '') {
return c.json({ success: error === '', error, value })
}
/** /**
* The envelope both leaderboard routes answer: `{ Success, Error, error_id }`, carrying * The envelope both leaderboard routes answer: `{ Success, Error, error_id }`, carrying
@@ -1156,7 +1190,7 @@ const experienceEnvelope = leaderboardEnvelope
async function ownedRooms(c: Context<App>) { async function ownedRooms(c: Context<App>) {
const accountId = await authedAccountId(c) const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c) if (accountId === null) return unauthorized(c)
return c.json(await getRoomsByCreator(c.env.DB, accountId)) return c.json(serveRooms(await getRoomsByCreator(c.env.DB, accountId)))
} }
/** /**
@@ -1169,12 +1203,15 @@ async function ownedRoomsExcludingDorm(c: Context<App>) {
const rooms = (await getContributedRooms(c.env.DB, accountId)).filter((room) => const rooms = (await getContributedRooms(c.env.DB, accountId)).filter((room) =>
canManageRoom(room, accountId) canManageRoom(room, accountId)
) )
return c.json(rooms.filter((r) => r.IsDorm !== true)) return c.json(serveRooms(rooms.filter((r) => r.IsDorm !== true)))
} }
/** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */ /** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */
const DEFAULT_SUGGESTION_COUNT = 10 const DEFAULT_SUGGESTION_COUNT = 10
/** Cap on `/rooms/search/name`'s `take`: a type-ahead list, so the seek stays a few rows. */
const NAME_SEARCH_MAX = 10
const app = new Hono<App>() const app = new Hono<App>()
.use( .use(
'*', '*',
@@ -1325,10 +1362,10 @@ const app = new Hono<App>()
if (idParam) { if (idParam) {
const id = firstId(idParam) const id = firstId(idParam)
const room = id === undefined ? null : await getRoomById(c.env.DB, id) const room = id === undefined ? null : await getRoomById(c.env.DB, id)
return c.json(room ?? (c.req.query('include') === '525581' ? null : {})) return c.json(room ? serveRoom(room) : c.req.query('include') === '525581' ? null : {})
} }
const room = await getRoomByName(c.env.DB, nameParam ?? '') const room = await getRoomByName(c.env.DB, nameParam ?? '')
return c.json(room ?? (c.req.query('include') === '525581' ? null : {})) return c.json(room ? serveRoom(room) : c.req.query('include') === '525581' ? null : {})
} }
) )
@@ -1360,7 +1397,7 @@ const app = new Hono<App>()
return c.json({ error: `You can only have ${maxRooms} rooms.` }, 403) return c.json({ error: `You can only have ${maxRooms} rooms.` }, 403)
const room = await createStudioRoom(c.env.DB, name, accountId, version) const room = await createStudioRoom(c.env.DB, name, accountId, version)
if (!room) return c.json({ error: 'A room with that name already exists.' }, 409) if (!room) return c.json({ error: 'A room with that name already exists.' }, 409)
return c.json(room) return c.json(serveRoom(room))
} }
) )
@@ -1398,7 +1435,39 @@ const app = new Hono<App>()
const query = c.req.query('query') ?? '' const query = c.req.query('query') ?? ''
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30 const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30
return c.json(await searchRooms(c.env.DB, query, skip, take)) return c.json(serveRoomPage(await searchRooms(c.env.DB, query, skip, take)))
}
)
// The website's header search: searchable rooms whose name STARTS with `query`, A–Z,
// as a bare array. Not the game's search (`/rooms/search` is a substring match that
// reads every searchable room); this is an index seek that reads only the rows it
// returns, which is what lets the site ask on every pause in typing. Un-hydrated rooms.
.get(
'/rooms/search/name',
describeRoute({
tags: ['Discovery'],
summary: 'Prefix-search rooms by name',
description: [
'Searchable rooms (public, non-dorm, not `ExcludeFromSearch`) whose name begins',
'with `query`, case-insensitive, alphabetically — a bare array. An index seek, so',
'it reads only the rooms it returns. Rooms are served without subrooms, tags or',
'badges. The game’s substring search is `/rooms/search`.',
].join(' '),
parameters: [
stringQuery('query', 'Name prefix'),
intQuery('take', `How many rooms to return (default and max ${NAME_SEARCH_MAX})`),
],
responses: { 200: json(RoomDto.array(), 'The matching rooms, A–Z') },
}),
async (c) => {
const take = Number.parseInt(c.req.query('take') ?? '', 10)
const rooms = await searchRoomsByName(
c.env.DB,
c.req.query('query') ?? '',
Number.isNaN(take) ? NAME_SEARCH_MAX : Math.min(take, NAME_SEARCH_MAX)
)
return c.json(serveRooms(rooms))
} }
) )
@@ -1483,7 +1552,7 @@ const app = new Hono<App>()
const tag = c.req.query('tag') ?? '' const tag = c.req.query('tag') ?? ''
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getHotRooms(c.env.DB, tag, skip, take)) return c.json(serveRoomPage(await getHotRooms(c.env.DB, tag, skip, take)))
} }
) )
@@ -1535,7 +1604,7 @@ const app = new Hono<App>()
async (c) => { async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getTrendingRooms(c.env.DB, skip, take)) return c.json(serveRoomPage(await getTrendingRooms(c.env.DB, skip, take)))
} }
) )
@@ -1557,7 +1626,7 @@ const app = new Hono<App>()
async (c) => { async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getBaseRooms(c.env.DB, skip, take)) return c.json(serveRooms(await getBaseRooms(c.env.DB, skip, take)))
} }
) )
@@ -1585,7 +1654,7 @@ const app = new Hono<App>()
async (c) => { async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getRecommendedRooms(c.env.DB, skip, take)) return c.json(serveRooms(await getRecommendedRooms(c.env.DB, skip, take)))
} }
) )
@@ -1680,13 +1749,15 @@ const app = new Hono<App>()
if (ids.length > MAX_BULK_ROOM_IDS) return tooManyIds(c) if (ids.length > MAX_BULK_ROOM_IDS) return tooManyIds(c)
const rooms = await getRoomsByIds(c.env.DB, ids) const rooms = await getRoomsByIds(c.env.DB, ids)
return c.json( return c.json(
excludePrivateRooms(c.req.query('excludePrivateRooms')) serveRooms(
? rooms.filter((r) => r.Accessibility === 1) excludePrivateRooms(c.req.query('excludePrivateRooms'))
: rooms ? rooms.filter((r) => r.Accessibility === 1)
: rooms
)
) )
} }
const room = await getRoomByName(c.env.DB, nameParam ?? '') const room = await getRoomByName(c.env.DB, nameParam ?? '')
return c.json(room ? [room] : []) return c.json(room ? [serveRoom(room)] : [])
} }
) )
@@ -1738,7 +1809,7 @@ const app = new Hono<App>()
const rooms = await getRoomsByIds(c.env.DB, ids) const rooms = await getRoomsByIds(c.env.DB, ids)
const excludePrivate = excludePrivateRooms(field('excludePrivateRooms')[0]) const excludePrivate = excludePrivateRooms(field('excludePrivateRooms')[0])
return c.json(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms) return c.json(serveRooms(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms))
} }
) )
@@ -1829,7 +1900,7 @@ const app = new Hono<App>()
async (c) => { async (c) => {
const accountId = await authedAccountId(c) const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c) if (accountId === null) return unauthorized(c)
return c.json(await getContributedRooms(c.env.DB, accountId)) return c.json(serveRooms(await getContributedRooms(c.env.DB, accountId)))
} }
) )
@@ -1886,7 +1957,11 @@ const app = new Hono<App>()
responses: { 200: json(RoomDto.array(), 'That account’s public rooms') }, responses: { 200: json(RoomDto.array(), 'That account’s public rooms') },
}), }),
async (c) => async (c) =>
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10))) c.json(
serveRooms(
await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10))
)
)
) )
// A player's showcased rooms — the hand-picked rail the client draws on a profile, // A player's showcased rooms — the hand-picked rail the client draws on a profile,
@@ -1934,7 +2009,7 @@ const app = new Hono<App>()
if (accountId === null) return unauthorized(c) if (accountId === null) return unauthorized(c)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getFavoritedRooms(c.env.DB, accountId, skip, take)) return c.json(serveRooms(await getFavoritedRooms(c.env.DB, accountId, skip, take)))
} }
) )
@@ -1958,7 +2033,7 @@ const app = new Hono<App>()
if (accountId === null) return unauthorized(c) if (accountId === null) return unauthorized(c)
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getVisitedRooms(c.env.DB, accountId, skip, take)) return c.json(serveRooms(await getVisitedRooms(c.env.DB, accountId, skip, take)))
} }
) )
@@ -2004,9 +2079,11 @@ const app = new Hono<App>()
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json( return c.json(
own serveRooms(
? await getVisitedRooms(c.env.DB, playerId, skip, take) own
: await getPublicVisitedRooms(c.env.DB, playerId, skip, take) ? await getVisitedRooms(c.env.DB, playerId, skip, take)
: await getPublicVisitedRooms(c.env.DB, playerId, skip, take)
)
) )
} }
) )
@@ -2617,9 +2694,14 @@ const app = new Hono<App>()
summary: 'A room’s roles', summary: 'A room’s roles',
description: [ description: [
'Everyone with a role in the room — its creator plus whoever has been granted Host,', 'Everyone with a role in the room — its creator plus whoever has been granted Host,',
'Moderator or CoOwner — as whole `RoomRole` records. This is the same array the', 'Moderator or CoOwner — as whole `RoomRole` records, PLUS every pending invite. It is',
'room DTO serves under `Roles`, on its own endpoint, so it is public exactly like the', 'public exactly like the room is: a role list says who runs a room, which the room',
'room is: a role list says who runs a room, which the room page already shows.', 'page already shows.',
'',
'Unlike the room DTO’s `Roles`, this list INCLUDES unanswered co-owner invites (`Role`',
'0, the offer on `InvitedRole`): the client reads it to show the players with an',
'invite pending. The room DTO leaves those entries out, because there an invited',
'player read as a member before they had accepted anything.',
'', '',
'A bare array, NOT the `{ success, error, value }` envelope the role WRITE answers. An', 'A bare array, NOT the `{ success, error, value }` envelope the role WRITE answers. An',
'unknown room is an empty list rather than an error — it reads the same as a room', 'unknown room is an empty list rather than an error — it reads the same as a room',
@@ -4305,7 +4387,7 @@ const app = new Hono<App>()
return c.json({ return c.json({
success: true, success: true,
error: null, error: null,
value: { room: result.room, subRoomDataSave: toSaveResponse(result.save) }, value: { room: serveRoom(result.room), subRoomDataSave: toSaveResponse(result.save) },
}) })
} }
) )
@@ -4856,7 +4938,9 @@ const app = new Hono<App>()
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0 const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100 const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json( return c.json(
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take) serveRoomPage(
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
)
) )
} }
) )
@@ -5124,7 +5208,9 @@ const app = new Hono<App>()
tags: ['Rooms'], tags: ['Rooms'],
summary: 'A room by id', summary: 'A room by id',
description: [ description: [
'The room as stored, with its `SubRooms` re-attached. Unlike `GET /rooms?id=`, an', 'The room as stored, with its `SubRooms` re-attached and pending role invites left',
'out of `Roles` (an unanswered invite is `Role` 0 with the offer on `InvitedRole`; the',
'invited player is not a member until they accept). Unlike `GET /rooms?id=`, an',
'unknown room here is a 404, not `{}`. A save that has a Rec Room Studio build', 'unknown room here is a 404, not `{}`. A save that has a Rec Room Studio build',
'lists its main bundles on `CurrentSave.UnitySubAssets` (`Target` 0 Windows, 2', 'lists its main bundles on `CurrentSave.UnitySubAssets` (`Target` 0 Windows, 2',
'Android/Quest, `Filename` downloaded from the CDN at `/unityasset/{filename}`).', 'Android/Quest, `Filename` downloaded from the CDN at `/unityasset/{filename}`).',
@@ -5147,7 +5233,7 @@ const app = new Hono<App>()
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)) const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
if (!room) return c.notFound() if (!room) return c.notFound()
filterRoomUnityAssets(room, unityAssetTargetQuery(c)) filterRoomUnityAssets(room, unityAssetTargetQuery(c))
return c.json(room) return c.json(serveRoom(room))
} }
) )
+301 -6
View File
@@ -9,6 +9,7 @@ import {
import { beforeAll, describe, expect, it } from 'vitest' import { beforeAll, describe, expect, it } from 'vitest'
import { import {
answerRoomRoleInvite,
AUDIT_LOG_SCHEMA_DDL, AUDIT_LOG_SCHEMA_DDL,
countRoomsUsingInvention, countRoomsUsingInvention,
createRoomInstance, createRoomInstance,
@@ -34,6 +35,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 +136,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()
@@ -538,6 +541,20 @@ describe('rooms endpoints', () => {
Roles: [{ AccountId: 822, Role: 30 }], Roles: [{ AccountId: 822, Role: 30 }],
}) })
await seed({ RoomId: 30405, Name: 'ContribNoRoles', CreatorAccountId: 821, SubRooms: [] }) await seed({ RoomId: 30405, Name: 'ContribNoRoles', CreatorAccountId: 821, SubRooms: [] })
// ...and one where 820 has only been INVITED to co-own — `Role` 0 with the offer on
// `InvitedRole`. They have no job in the room until they accept, so it stays out of
// both of their lists, and the pending entry is not served as part of the room.
await seed({
RoomId: 30408,
Name: 'ContribInvited',
CreatorAccountId: 821,
Accessibility: 1,
SubRooms: [],
Roles: [
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 820, Role: 0, LastChangedByAccountId: 821, InvitedRole: 30 },
],
})
const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, { const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
headers: await bearer('820'), headers: await bearer('820'),
@@ -552,6 +569,46 @@ describe('rooms endpoints', () => {
expect(rooms.some((r) => r.RoomId === 30407)).toBe(false) expect(rooms.some((r) => r.RoomId === 30407)).toBe(false)
expect(rooms[0]).toMatchObject({ Name: expect.any(String), Accessibility: expect.any(Number) }) expect(rooms[0]).toMatchObject({ Name: expect.any(String), Accessibility: expect.any(Number) })
// The unanswered invite lists the room for NOBODY: not under "contributed" (above),
// not under "owned".
const owned = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('820') })
const ownedIds = ((await owned.json()) as Array<{ RoomId: number }>).map((r) => r.RoomId)
expect(ownedIds).toContain(30401)
expect(ownedIds).not.toContain(30408)
// Nor is the invited player part of the room as served: the room DTO carries only HELD
// roles. `GET /rooms/:id/roles` DOES list the invite — the client shows pending invites
// from it — and the stored room keeps the entry for the accept.
type Served = { Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }> }
const servedRoles = async () =>
((await (await SELF.fetch(`${ORIGIN}/rooms/30408`)).json()) as Served).Roles
expect(await servedRoles()).toEqual([
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
])
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30408/roles`)).json()).toEqual([
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 820, Role: 0, LastChangedByAccountId: 821, InvitedRole: 30 },
])
expect(
await env.DB.prepare(
'SELECT role, invited_role FROM room_role WHERE room_id = 30408 AND account_id = 820'
).first()
).toEqual({ role: 0, invited_role: 30 })
// Accepting is what puts them in: the room appears in both lists and the entry is served.
const invitedRoom = (await getRoomById(env.DB, 30408))!
expect(await answerRoomRoleInvite(env.DB, 30408, 820, 30, invitedRoom)).not.toBeNull()
expect(await contributed('820')).toContain(30408)
const ownedAfter = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
headers: await bearer('820'),
})
expect(((await ownedAfter.json()) as Array<{ RoomId: number }>).map((r) => r.RoomId)).toContain(
30408
)
expect(await servedRoles()).toContainEqual(
expect.objectContaining({ AccountId: 820, Role: 30, InvitedRole: 0 })
)
// A player who contributes to nothing gets an empty array, not a 404. // A player who contributes to nothing gets an empty array, not a 404.
const none = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, { const none = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
headers: await bearer('829'), headers: await bearer('829'),
@@ -577,8 +634,8 @@ describe('rooms endpoints', () => {
// The DB is shared across this file, and these are the only player-made public rooms // The DB is shared across this file, and these are the only player-made public rooms
// in it — leaving them behind changes what the `new`/`community` room feeds serve. // in it — leaving them behind changes what the `new`/`community` room feeds serve.
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run() await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30408').run()
await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30407').run() await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30408').run()
}) })
it('GET /rooms/:roomId/experience serves the room’s stored XP settings, no auth', async () => { it('GET /rooms/:roomId/experience serves the room’s stored XP settings, no auth', async () => {
@@ -624,6 +681,46 @@ describe('rooms endpoints', () => {
expect(body.Results.some((r) => r.Name === 'RecCenter')).toBe(true) expect(body.Results.some((r) => r.Name === 'RecCenter')).toBe(true)
}) })
it('GET /rooms/search/name prefix-matches searchable rooms A–Z through the partial index', async () => {
const search = async (query: string, extra = '') =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/search/name?query=${encodeURIComponent(query)}${extra}`)
).json()) as Array<{ Name: string; IsDorm?: boolean; Stats: { VisitCount: number } }>
// A bare array of whole rooms, case-insensitive, every name starting with the query, A–Z.
const rec = await search('REC', '&take=10')
expect(rec.some((r) => r.Name === 'RecCenter')).toBe(true)
for (const r of rec) expect(r.Name.toLowerCase().startsWith('rec')).toBe(true)
const names = rec.map((r) => r.Name.toLowerCase())
expect(names).toEqual([...names].sort())
expect(typeof rec[0]?.Stats.VisitCount).toBe('number')
// A prefix, not a substring: "center" is inside RecCenter but doesn't start it.
expect((await search('center')).some((r) => r.Name === 'RecCenter')).toBe(false)
// `take` caps it, and is itself capped at 10; empty and wildcard queries find nothing.
expect((await search('r', '&take=2')).length).toBeLessThanOrEqual(2)
expect(await search('', '&take=500')).toEqual([])
expect(await search('%')).toEqual([])
expect((await search('r', '&take=500')).length).toBeLessThanOrEqual(10)
// Dorms (named `@user's Dorm`) are never served.
expect((await search('@')).every((r) => r.IsDorm !== true)).toBe(true)
// The point of the endpoint: a SEEK on the partial index, so D1 reads only the rows it
// returns. Fall back to a scan (or the unfiltered name index) and every call is billed
// for every room again.
const plan = await env.DB.prepare(
`EXPLAIN QUERY PLAN SELECT data FROM room
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1
AND name_lower >= ?1 AND name_lower < ?2 AND name_lower LIKE ?3 ESCAPE '\\'
ORDER BY name_lower LIMIT 5`
)
.bind('r', 'r\u{10FFFF}', 'r%')
.all<{ detail: string }>()
const detail = plan.results.map((r) => r.detail).join('\n')
expect(detail).toContain('SEARCH room USING INDEX idx_room_search_name')
expect(detail).not.toContain('TEMP B-TREE')
})
it('GET /rooms/autocomplete_search suggests names and tags as plain strings', async () => { it('GET /rooms/autocomplete_search suggests names and tags as plain strings', async () => {
const suggest = async (query: string, extra = '') => const suggest = async (query: string, extra = '') =>
(await ( (await (
@@ -1673,6 +1770,18 @@ describe('rooms endpoints', () => {
type RoomEnv = { success: boolean; error: string; value: Record<string, unknown> | null } type RoomEnv = { success: boolean; error: string; value: Record<string, unknown> | null }
const envOf = async (res: Response) => (await res.json()) as RoomEnv const envOf = async (res: Response) => (await res.json()) as RoomEnv
// A role entry as STORED, read off the `room_role` index — pending invites included.
// The served room carries only held roles; `GET /rooms/:id/roles` lists invites too.
type StoredRole = { Role: number; InvitedRole: number; LastChangedByAccountId: number | null }
const storedRoleOf = async (roomId: number, accountId: number) =>
(await env.DB.prepare(
`SELECT role AS Role, invited_role AS InvitedRole,
last_changed_by_account_id AS LastChangedByAccountId
FROM room_role WHERE room_id = ?1 AND account_id = ?2`
)
.bind(roomId, accountId)
.first<StoredRole>()) ?? undefined
// A subroom as the client sees it. There is no GET for a single subroom — the client // A subroom as the client sees it. There is no GET for a single subroom — the client
// reads them off the room — so tests do the same. // reads them off the room — so tests do the same.
const subRoomOf = async ( const subRoomOf = async (
@@ -2089,7 +2198,9 @@ describe('rooms endpoints', () => {
success: false, success: false,
error: 'You must provide a valid role!', error: 'You must provide a valid role!',
}) })
expect(await roleOf(885)).toMatchObject({ InvitedRole: 30 }) expect(await storedRoleOf(2, 885)).toMatchObject({ Role: 0, InvitedRole: 30 })
// The role list still shows the invite as pending.
expect(await roleOf(885)).toMatchObject({ Role: 0, InvitedRole: 30 })
}) })
it('a revoke pushes a RoomUpdate to the room and to the player who lost the role', async () => { it('a revoke pushes a RoomUpdate to the room and to the player who lost the role', async () => {
@@ -2148,9 +2259,13 @@ describe('rooms endpoints', () => {
) )
// RecCenter (room 2) is owned by account 1. The owner invites 850 to co-ownership, // RecCenter (room 2) is owned by account 1. The owner invites 850 to co-ownership,
// leaving a pending offer and no role yet. // leaving a pending offer and no role yet. The role list shows the pending invite, but
// the room DTO does not list them: they are not a member until they accept.
expect((await putForm('/rooms/2/roles/850/invite', { role: '30' }, '1')).status).toBe(200) expect((await putForm('/rooms/2/roles/850/invite', { role: '30' }, '1')).status).toBe(200)
expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 }) expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Roles: Role[] }
expect(room2.Roles.some((r) => r.AccountId === 850)).toBe(false)
// No token → 401 (auth gate). // No token → 401 (auth gate).
expect((await putForm('/rooms/2/roles/850', { role: '30' })).status).toBe(401) expect((await putForm('/rooms/2/roles/850', { role: '30' })).status).toBe(401)
@@ -2163,7 +2278,7 @@ describe('rooms endpoints', () => {
await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, caller)) await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, caller))
).toMatchObject({ success: false, error: 'Co-ownership must be invited, not granted!' }) ).toMatchObject({ success: false, error: 'Co-ownership must be invited, not granted!' })
} }
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 }) expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// Unknown room → failure envelope. // Unknown room → failure envelope.
expect( expect(
await envOf(await putForm('/rooms/99999/roles/850', { role: '30' }, '850')) await envOf(await putForm('/rooms/99999/roles/850', { role: '30' }, '850'))
@@ -2180,7 +2295,7 @@ describe('rooms endpoints', () => {
expect(await envOf(await putForm('/rooms/2/roles/850', { role: '255' }, '850'))).toMatchObject({ expect(await envOf(await putForm('/rooms/2/roles/850', { role: '255' }, '850'))).toMatchObject({
success: false, success: false,
}) })
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 }) expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
// A player with no entry in the room at all is the same rejection — probing tells // A player with no entry in the room at all is the same rejection — probing tells
// them nothing. // them nothing.
@@ -2227,11 +2342,13 @@ describe('rooms endpoints', () => {
// Invited, then declined: the whole entry goes, leaving `Roles` as it was before the // Invited, then declined: the whole entry goes, leaving `Roles` as it was before the
// offer rather than an entry with nothing pending on it. // offer rather than an entry with nothing pending on it.
expect((await putForm('/rooms/2/roles/851/invite', { role: '30' }, '1')).status).toBe(200) expect((await putForm('/rooms/2/roles/851/invite', { role: '30' }, '1')).status).toBe(200)
expect(await storedRoleOf(2, 851)).toMatchObject({ Role: 0, InvitedRole: 30 })
expect(await roleOf(851)).toMatchObject({ Role: 0, InvitedRole: 30 }) expect(await roleOf(851)).toMatchObject({ Role: 0, InvitedRole: 30 })
const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851') const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851')
expect(declined.status).toBe(200) expect(declined.status).toBe(200)
expect(await envOf(declined)).toMatchObject({ success: true, error: '' }) expect(await envOf(declined)).toMatchObject({ success: true, error: '' })
expect(await storedRoleOf(2, 851)).toBeUndefined()
expect(await roleOf(851)).toBeUndefined() expect(await roleOf(851)).toBeUndefined()
// And the decline can't be replayed either — there is no entry left to answer. // And the decline can't be replayed either — there is no entry left to answer.
@@ -2246,6 +2363,7 @@ describe('rooms endpoints', () => {
expect(await roleOf(852)).toMatchObject({ Role: 10, InvitedRole: 0 }) expect(await roleOf(852)).toMatchObject({ Role: 10, InvitedRole: 0 })
expect((await putForm('/rooms/2/roles/852/invite', { role: '30' }, '1')).status).toBe(200) expect((await putForm('/rooms/2/roles/852/invite', { role: '30' }, '1')).status).toBe(200)
expect((await putForm('/rooms/2/roles/852', { role: '0' }, '852')).status).toBe(200) expect((await putForm('/rooms/2/roles/852', { role: '0' }, '852')).status).toBe(200)
expect(await storedRoleOf(2, 852)).toBeUndefined()
expect(await roleOf(852)).toBeUndefined() expect(await roleOf(852)).toBeUndefined()
}) })
@@ -2493,12 +2611,23 @@ describe('rooms endpoints', () => {
expect(await res.json()).toEqual({ success: true }) expect(await res.json()).toEqual({ success: true })
// A new entry, holding no role yet — only the pending offer, stamped with the inviter. // A new entry, holding no role yet — only the pending offer, stamped with the inviter.
// The role list shows it as a pending invite (the client lists pending invites from
// it); the room DTO's `Roles` does not, until they accept.
expect(await storedRoleOf(2, 187)).toEqual({
Role: 0,
InvitedRole: 30,
LastChangedByAccountId: 1,
})
expect(await rolesOf(2)).toContainEqual({ expect(await rolesOf(2)).toContainEqual({
AccountId: 187, AccountId: 187,
Role: 0, Role: 0,
InvitedRole: 30, InvitedRole: 30,
LastChangedByAccountId: 1, LastChangedByAccountId: 1,
}) })
const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
Roles: Array<{ AccountId: number }>
}
expect(room2.Roles.some((r) => r.AccountId === 187)).toBe(false)
// One MessageReceived to the INVITED player carrying a type 62 RoomCoOwnerInvited — // One MessageReceived to the INVITED player carrying a type 62 RoomCoOwnerInvited —
// the entry alone is silent, this is what raises the invite on their client. // the entry alone is silent, this is what raises the invite on their client.
@@ -6162,6 +6291,7 @@ describe('rooms endpoints', () => {
'GET /rooms/ownedby/{accountId}', 'GET /rooms/ownedby/{accountId}',
'GET /rooms/recommendations', 'GET /rooms/recommendations',
'GET /rooms/search', 'GET /rooms/search',
'GET /rooms/search/name',
'GET /rooms/visitedby/me', 'GET /rooms/visitedby/me',
'GET /rooms/visitedby/{playerId}', 'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}', 'GET /rooms/{roomId}',
@@ -6329,6 +6459,111 @@ describe('studio room bundles', () => {
) )
} }
it('serves partitioned prefab and shared-material exports as save dependencies', async () => {
await reset()
await storeFiles()
const prefab = '00111111-2222-4333-8444-555555555555'
const shared = '00222222-2222-4333-8444-555555555555'
await env.DB.prepare(
"UPDATE subroom_save SET data=json_set(data,'$.ServerData',json(?1)) WHERE sub_room_id=99011"
)
.bind(
JSON.stringify({
Exports: [
{ e: 'prefab', u: prefab.toUpperCase() },
{ e: 'shared', u: shared },
{ e: 'duplicate', u: prefab },
],
})
)
.run()
for (const id of [prefab, shared]) {
for (const target of [0, 2]) {
for (const kind of ['main', 'stripped'] as const) {
await putUnityAsset(
env.DB,
{
UnityAssetId: id,
Target: target,
Version: 5,
Filename: publicStudioBundleFilename(
9901,
id,
target === 0 ? 'windows' : 'android',
kind
),
Hash: winHash,
},
kind
)
}
}
}
for (const target of [0, 2]) {
const response = await SELF.fetch(`${ORIGIN}/rooms/9901?unityAssetTarget=${target}`)
const room = (await response.json()) as {
SubRooms: Array<{
CurrentSave: {
SubRoomDataSaveId: number
UnitySubAssets: Array<{ UnityAssetId: string; Target: number }>
}
}>
}
const current = room.SubRooms[0]!.CurrentSave
expect(current.UnitySubAssets.map((asset) => asset.UnityAssetId).sort()).toEqual(
[ASSET, prefab, shared].sort()
)
expect(current.UnitySubAssets.every((asset) => asset.Target === target)).toBe(true)
const detailResponse = await SELF.fetch(
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${current.SubRoomDataSaveId}?unityAssetTarget=${target}`,
{ headers: await bearer('1') }
)
expect(await detailResponse.json()).toMatchObject({
UnityAsset: target === 0 ? winName : androidName,
UnitySubAssets: [prefab, shared].map((id) => ({
UnityAssetId: id,
UnityAsset: publicStudioBundleFilename(
9901,
id,
target === 0 ? 'windows' : 'android',
'main'
),
UnityAssetHash: winHash,
})),
})
}
// In-game saves reuse the compiled Studio asset. They must keep the export
// manifest when staging, saving again, and publishing the new scene data.
for (const autoPublish of [false, false, true]) {
const response = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/data`, {
method: 'POST',
headers: { ...(await bearer('1')), 'Content-Type': 'application/json' },
body: JSON.stringify({
UnityAssetId: ASSET.toUpperCase(),
SubRoomData: { Filename: 'in-game-save' },
AutoPublish: autoPublish,
}),
})
const result = (await response.json()) as {
success: boolean
value: { subRoomDataSave: { subRoomDataSaveId: number; unityAsset: string } }
}
expect(result.success).toBe(true)
expect(result.value.subRoomDataSave.unityAsset).toBe(winName)
const detail = await SELF.fetch(
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${result.value.subRoomDataSave.subRoomDataSaveId}?unityAssetTarget=0`,
{ headers: await bearer('1') }
)
expect(await detail.json()).toMatchObject({
UnityAsset: winName,
UnitySubAssets: [prefab, shared].map((UnityAssetId) => ({ UnityAssetId })),
})
}
await env.DB.prepare('DELETE FROM unity_asset WHERE unity_asset_id IN (?1,?2)')
.bind(prefab, shared)
.run()
})
it('loads baked bundles onto the room and serves the unity asset', async () => { it('loads baked bundles onto the room and serves the unity asset', async () => {
await reset() await reset()
@@ -7185,3 +7420,63 @@ describe('Studio source revision permissions', () => {
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' }) expect(await source.json()).toMatchObject({ filename: 'first-draft.json' })
}) })
}) })
it('omits hydrated Badges when persisting a room settings edit', async () => {
await seedRoomWithSubRooms(env.DB, {
RoomId: 987670,
Name: 'BadgeSerialization',
CreatorAccountId: 987670,
SubRooms: [],
})
await env.DB.prepare(
"UPDATE room SET data=json_set(data,'$.Badges',json(?)) WHERE room_id=987670"
)
.bind(JSON.stringify([{ Name: 'derived badge' }]))
.run()
const response = await SELF.fetch(`${ORIGIN}/rooms/987670/warning`, {
method: 'PUT',
headers: await bearer('987670'),
body: new URLSearchParams({ warningMask: '1' }),
})
expect(response.status).toBe(200)
const stored = await env.DB.prepare('SELECT data FROM room WHERE room_id=987670').first<{
data: string
}>()
expect(JSON.parse(stored!.data)).not.toHaveProperty('Badges')
})
// The badge lookup runs on every room page, served by a partial index over the badge rows
// (api migrations/0035, mirrored in SCHEMA_DDL above). Only a PUBLISHED badge with a built save
// hydrates; a draft stays on its creator's Studio shelf.
it("GET /rooms/:id hydrates the room's published badges", async () => {
await seedRoomWithSubRooms(env.DB, {
RoomId: 987671,
Name: 'BadgeHydration',
CreatorAccountId: 987671,
SubRooms: [],
})
const badge = (id: string, roomId: number, accessibility: number, saves: unknown[]) =>
JSON.stringify({
CustomAvatarItemId: id,
CreatorAccountId: 987671,
Name: id,
OutfitType: 1000,
Accessibility: accessibility,
CurrentSaves: saves,
CustomBadgeMetadata: { RoomId: roomId },
})
const insert = env.DB.prepare('INSERT INTO custom_avatar_item (data) VALUES (?)')
await env.DB.batch([
insert.bind(badge('badge-published', 987671, 1, [{ BodyType: 0 }])),
insert.bind(badge('badge-draft', 987671, 0, [{ BodyType: 0 }])),
insert.bind(badge('badge-unbuilt', 987671, 1, [])),
insert.bind(badge('badge-other-room', 987670, 1, [{ BodyType: 0 }])),
])
// Twice: the second read takes the path that remembers the table exists.
for (let i = 0; i < 2; i++) {
const res = await SELF.fetch(`${ORIGIN}/rooms/987671`)
expect(res.status).toBe(200)
const room = (await res.json()) as { Badges: Array<{ CustomAvatarItemId: string }> }
expect(room.Badges.map((b) => b.CustomAvatarItemId)).toEqual(['badge-published'])
}
})
+3
View File
@@ -56,6 +56,8 @@ const UPLOAD_SUBFOLDER: Record<number, string> = {
8: 'room', 8: 'room',
9: 'roomunityasset', 9: 'roomunityasset',
10: 'unityasset', 10: 'unityasset',
13: 'avatar',
14: 'avatarunityasset',
} }
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */ /** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
@@ -380,6 +382,7 @@ const app = new Hono<App>()
contentType = image.contentType contentType = image.contentType
} }
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, { await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
customMetadata: { ownerId: String(id) },
httpMetadata: { contentType }, httpMetadata: { contentType },
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the // Record the SHA-256 on the object. R2 stores an md5 on its own, but the
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and // hashes the client is served (an invention's `BlobHash`) are SHA-256, and
@@ -21,6 +21,27 @@ beforeAll(async () => {
// storage worker's validation accepts it. // storage worker's validation accepts it.
const TEST_SECRET = 'test-signing-key' const TEST_SECRET = 'test-signing-key'
it('stores avatar source and baked uploads with immutable account ownership metadata', async () => {
for (const [type, prefix] of [
[13, 'avatar'],
[14, 'avatarunityasset'],
] as const) {
const form = new FormData()
form.set('fileType', String(type))
form.set('file', new File(['avatar source'], 'source.bin'))
const r = await SELF.fetch(ORIGIN + '/upload', {
method: 'POST',
headers: { ...(await bearer()), 'User-Agent': 'RRStudio' },
body: form,
})
expect(r.status).toBe(200)
const body = await r.json<{ Filename: string; Hash: string }>()
expect(body.Hash).toBeTruthy()
const object = await env.CDN_ASSETS.head(prefix + '/' + body.Filename)
expect(object?.customMetadata?.ownerId).toBe('42')
}
})
function b64url(input: ArrayBuffer | string): string { function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = '' let binary = ''
+27 -8
View File
@@ -83,12 +83,16 @@ build ID with a null completion time. Repeated requests for an in-progress save
reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status; reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status;
`PUT /cloud-builds/cancel` cancels a queued or running job. `PUT /cloud-builds/cancel` cancels a queued or running job.
Apply migration `0003_studio_cloud_build_job.sql`. Configure a private Apply migration `0003_studio_cloud_build_job.sql`. Create the private
`CLOUD_BUILD_KEY` and run the outbound worker from `recflare/cloud-building` with `CLOUD_BUILD_KEY` in the shared Secrets Store (the one `JWT_SECRET` lives in — see
the matching credential. Without a key, creation returns an explicit unavailable `.env.example`) and run the outbound worker from `recflare/cloud-building` with the
response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and Android), and same value as its `STUDIO_WORKER_KEY`. The binding must exist for the worker to
`CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169. Match installed editor modules to deploy; a value of `off` disables cloud building, and creation then returns an
the enabled targets. explicit unavailable response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and
Android), and `CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169 — the cloud-building
worker claims only jobs for its own configured template version (171 by default),
so set this to match or jobs queue and are never claimed. Match installed editor
modules to the enabled targets.
The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish` The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish`
routes lease jobs to that worker. Only validated UnityFS artifacts for the routes lease jobs to that worker. Only validated UnityFS artifacts for the
@@ -96,5 +100,20 @@ allocated asset IDs and requested platforms are accepted. SDK bundle version 5
is retained. Completion links the submitted save; auto-publish does not replace is retained. Completion links the submitted save; auto-publish does not replace
a newer draft. Failed, cancelled, or expired jobs never become successful builds. a newer draft. Failed, cancelled, or expired jobs never become successful builds.
Claims include a signed, short-lived SDK validation token with no account subject Claims include a signed, short-lived SDK validation token with no account subject
or roles. Deploy the paired API support: only bulk component-text validation or roles. Bulk component-text validation and job-scoped grant reads accept this
accepts this credential. It cannot authenticate as an account or publish a room. credential. It cannot authenticate as an account or publish a room.
## Avatar Studio and consumable validation
Avatar authoring uses per-account Studio grants, source revisions, and the managed
cloud queue. Apply `migrations/0004_avatar_studio.sql` to the shared database before
enabling these routes. The API owns creator saves and explicit publication; the
Studio service queues prefab builds and returns per-platform main/stripped bundles
at version 5 and material bundles at version 6. Image badges publish directly.
Changed body/configuration inputs receive distinct output IDs, and automatic
publication cannot replace a newer revision. Deploy the paired cloud-building
worker for prefab support; restricted validation credentials grant no account role.
The SDK's access override reads the target account's entitlement. Build-validation
credentials can inspect only the current owners of their active job's room; peers
can inspect grants only while they currently manage the same room.
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id);
CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL);
+350 -62
View File
@@ -1,11 +1,18 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { import {
AVATAR_STUDIO_SLOTS,
avatarItemAccessBit,
avatarStudioFlags,
avatarStudioItem,
avatarStudioSave,
canManageRoom,
canManageRoomById, canManageRoomById,
getRoomById, getRoomById,
getSubRoom, getSubRoom,
getSubRoomSaveById, getSubRoomSaveById,
publicStudioBundleFilename, publicStudioBundleFilename,
publishAvatarSave,
studioBundleR2Key, studioBundleR2Key,
} from '@repo/domain' } from '@repo/domain'
import { import {
@@ -16,13 +23,28 @@ import {
import { parseMultipart } from './multipart' import { parseMultipart } from './multipart'
import type { App } from './context' import type { Context } from 'hono'
import type { App, Env } from './context'
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job ( export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued', cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
)` )`
interface Job { interface Job {
kind?: 'avatar'
avatarItemId?: string
avatar?: {
itemId: string
outfitType: number
bodyType: number
configuration: string
name: string
createdAt: string
price: number
creatorAccountId: number
canCreateRoomieEquipment: boolean
permittedItemTypes: number[]
}
buildId: string buildId: string
roomId: number roomId: number
subRoomId: number subRoomId: number
@@ -34,7 +56,8 @@ interface Job {
targets: number[] targets: number[]
manifest: { manifest: {
Version: number Version: number
MainSceneProjectRelativePath: string MainSceneProjectRelativePath?: string
MainPrefabProjectRelativePath?: string
ExportPartitionVersion: number ExportPartitionVersion: number
Files: Array<{ n: string; p: string; g?: string }> Files: Array<{ n: string; p: string; g?: string }>
Exports?: Array<{ e: string; d?: string[] }> Exports?: Array<{ e: string; d?: string[] }>
@@ -51,6 +74,7 @@ const ok = (value: unknown) => ({ success: true, error: null, value })
const fail = (error: string) => ({ success: false, error, value: null }) const fail = (error: string) => ({ success: false, error, value: null })
const ID = /^[1-9]\d*$/ const ID = /^[1-9]\d*$/
const guid = () => crypto.randomUUID() const guid = () => crypto.randomUUID()
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
interface Artifact { interface Artifact {
field: string field: string
unityAssetId: string unityAssetId: string
@@ -84,7 +108,6 @@ interface CloudBuildRecord {
unity_asset_id: string unity_asset_id: string
created_by_account_id: number created_by_account_id: number
} }
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
function wire(row: CloudBuildRecord) { function wire(row: CloudBuildRecord) {
return { return {
cloudBuildId: row.cloud_build_id, cloudBuildId: row.cloud_build_id,
@@ -99,7 +122,21 @@ function wire(row: CloudBuildRecord) {
: null, : null,
} }
} }
async function privateAuth(request: Request, key: string | undefined) { /**
* The pull worker's credential, resolved per request from the Secrets Store. Returns ''
* when cloud building is off: the secret holds the literal `off` (the binding has to
* resolve for the worker to deploy, so that is how an operator opts out) or can't be read.
* Every gate on "is cloud building configured" goes through here.
*/
export async function cloudBuildKey(env: Env): Promise<string> {
try {
const key = (await env.CLOUD_BUILD_KEY.get()) ?? ''
return key === 'off' ? '' : key
} catch {
return ''
}
}
async function privateAuth(request: Request, key: string) {
if (!key) return false if (!key) return false
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`)) const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
const got = await crypto.subtle.digest( const got = await crypto.subtle.digest(
@@ -116,7 +153,48 @@ async function pendingBuild(db: D1Database, roomId: number, subRoomId: number, s
.bind(roomId, subRoomId, saveId) .bind(roomId, subRoomId, saveId)
.first<CloudBuildRecord>() .first<CloudBuildRecord>()
} }
async function pendingAvatarBuild(db: D1Database, itemId: string, saveId: number) {
return db
.prepare(
"SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) JOIN studio_cloud_build_job j USING(cloud_build_id) WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing') LIMIT 1"
)
.bind(itemId, saveId)
.first<CloudBuildRecord>()
}
const app = new Hono<App>() const app = new Hono<App>()
.get('/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}', async (c) => {
const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (id === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, c.req.param('itemId'))
if (!item || item.CreatorAccountId !== id) return c.body(null, 403)
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
const { results } = await c.env.DB.prepare(
'SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) WHERE a.item_id=? ORDER BY b.started_at DESC'
)
.bind(item.CustomAvatarItemId)
.all<CloudBuildRecord>()
return c.json({
results: results.slice(skip, skip + take).map(wire),
totalResults: results.length,
})
})
.post(
'/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}/save/:saveId{[1-9][0-9]*}',
(c) => enqueueAvatar(c, c.req.param('itemId'), Number(c.req.param('saveId')))
)
.post('/cloud-builds/custom-avatar-item/create-for-new-build-version', async (c) => {
const form = await c.req.parseBody()
const itemId = String(form.customAvatarItemId ?? ''),
assetId = String(form.unityAssetId ?? '')
const row = await c.env.DB.prepare(
'SELECT id FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
)
.bind(itemId, assetId)
.first<{ id: number }>()
if (!row) return c.notFound()
return enqueueAvatar(c, itemId, row.id, form)
})
.post('/cloud-builds/create', async (c) => { .post('/cloud-builds/create', async (c) => {
const secret = await c.env.JWT_SECRET.get() const secret = await c.env.JWT_SECRET.get()
const accountId = await validateAndGetAccountId(c.req.raw, secret) const accountId = await validateAndGetAccountId(c.req.raw, secret)
@@ -124,7 +202,7 @@ const app = new Hono<App>()
if (accountId === null) return c.body(null, 401) if (accountId === null) return c.body(null, 401)
if (!roles?.includes('betastudio')) if (!roles?.includes('betastudio'))
return c.json(fail('Studio upload authorization is required.'), 403) return c.json(fail('Studio upload authorization is required.'), 403)
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503) if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
const form = await c.req.parseBody() const form = await c.req.parseBody()
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key])) const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id)))) if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id))))
@@ -272,11 +350,16 @@ const app = new Hono<App>()
const form = await c.req.parseBody() const form = await c.req.parseBody()
const id = textField(form.cloudBuildId) const id = textField(form.cloudBuildId)
const row = await c.env.DB.prepare( const row = await c.env.DB.prepare(
'SELECT room_id FROM studio_cloud_build WHERE cloud_build_id = ?' 'SELECT room_id,created_by_account_id FROM studio_cloud_build WHERE cloud_build_id = ?'
) )
.bind(id) .bind(id)
.first<{ room_id: number }>() .first<{ room_id: number; created_by_account_id: number }>()
if (!row || !(await canManageRoomById(c.env.DB, row.room_id, accountId))) if (
!row ||
!(row.room_id === 0
? row.created_by_account_id === accountId
: await canManageRoomById(c.env.DB, row.room_id, accountId))
)
return c.json(fail('No accessible build.'), 404) return c.json(fail('No accessible build.'), 404)
await c.env.DB.batch([ await c.env.DB.batch([
c.env.DB.prepare( c.env.DB.prepare(
@@ -289,7 +372,7 @@ const app = new Hono<App>()
return c.json(ok(null)) return c.json(ok(null))
}) })
.use('/internal/cloud-builds/*', async (c, next) => { .use('/internal/cloud-builds/*', async (c, next) => {
if (!(await privateAuth(c.req.raw, c.env.CLOUD_BUILD_KEY))) return c.body(null, 401) if (!(await privateAuth(c.req.raw, await cloudBuildKey(c.env)))) return c.body(null, 401)
return next() return next()
}) })
.post('/internal/cloud-builds/claim', async (c) => { .post('/internal/cloud-builds/claim', async (c) => {
@@ -325,17 +408,13 @@ const app = new Hono<App>()
}) })
.post('/internal/cloud-builds/:id/heartbeat', async (c) => { .post('/internal/cloud-builds/:id/heartbeat', async (c) => {
const { leaseToken } = await c.req.json<{ leaseToken: string }>() const { leaseToken } = await c.req.json<{ leaseToken: string }>()
const leaseUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString()
const result = await c.env.DB.prepare( const result = await c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?" "UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
) )
.bind( .bind(leaseUntil, c.req.param('id'), leaseToken, new Date().toISOString())
new Date(Date.now() + 5 * 60 * 1000).toISOString(),
c.req.param('id'),
leaseToken,
new Date().toISOString()
)
.run() .run()
return c.json({ active: result.meta.changes > 0 }) return c.json(result.meta.changes > 0 ? { active: true, leaseUntil } : { active: false })
}) })
.post('/internal/cloud-builds/:id/finish', async (c) => { .post('/internal/cloud-builds/:id/finish', async (c) => {
const form = await parseMultipart(c.req.raw) const form = await parseMultipart(c.req.raw)
@@ -359,6 +438,26 @@ const app = new Hono<App>()
]) ])
return c.json(ok(null)) return c.json(ok(null))
} }
if (job.kind === 'avatar') {
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
if (
!item ||
!save ||
item.CreatorAccountId !== job.accountId ||
!((await avatarStudioFlags(c.env.DB, job.accountId)) & avatarItemAccessBit(item.OutfitType))
) {
await c.env.DB.batch([
c.env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='building'"
).bind(id, lease.lease_token),
c.env.DB.prepare(
"UPDATE studio_cloud_build SET completed_at=?,error='Avatar source or authorization was removed.' WHERE cloud_build_id=? AND completed_at=''"
).bind(new Date().toISOString(), id),
])
return c.json(fail('Avatar source or authorization was removed.'), 409)
}
}
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)]) const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
let entries: unknown let entries: unknown
try { try {
@@ -368,7 +467,10 @@ const app = new Hono<App>()
} }
if (!Array.isArray(entries) || !entries.every(isArtifact)) if (!Array.isArray(entries) || !entries.every(isArtifact))
return c.json(fail('Invalid build artifact metadata.'), 400) return c.json(fail('Invalid build artifact metadata.'), 400)
if (!entries.length || entries.length > assets.size * job.targets.length * 2) if (
!entries.length ||
entries.length > assets.size * job.targets.length * (job.kind === 'avatar' ? 3 : 2)
)
return c.json(fail('Missing build artifacts.'), 400) return c.json(fail('Missing build artifacts.'), 400)
const seen = new Set<string>() const seen = new Set<string>()
for (const entry of entries) { for (const entry of entries) {
@@ -377,8 +479,10 @@ const app = new Hono<App>()
if ( if (
!assets.has(entry.unityAssetId) || !assets.has(entry.unityAssetId) ||
!job.targets.includes(entry.target) || !job.targets.includes(entry.target) ||
!['main', 'stripped'].includes(entry.kind) || !(job.kind === 'avatar' ? ['main', 'stripped', 'material'] : ['main', 'stripped']).includes(
entry.version !== 5 || entry.kind
) ||
entry.version !== (job.kind === 'avatar' && entry.kind === 'material' ? 6 : 5) ||
!(file instanceof File) || !(file instanceof File) ||
file.size === 0 || file.size === 0 ||
seen.has(key) seen.has(key)
@@ -390,6 +494,11 @@ const app = new Hono<App>()
for (const target of job.targets) for (const target of job.targets)
if (!seen.has(`${asset}:${target}:main`)) if (!seen.has(`${asset}:${target}:main`))
return c.json(fail('A required asset bundle is missing.'), 400) return c.json(fail('A required asset bundle is missing.'), 400)
if (
job.kind === 'avatar' &&
job.targets.some((target) => !seen.has(`${job.unityAssetId}:${target}:material`))
)
return c.json(fail('An avatar material bundle is missing.'), 400)
const writes: D1PreparedStatement[] = [] const writes: D1PreparedStatement[] = []
for (const entry of entries) { for (const entry of entries) {
const file = form[entry.field] as File const file = form[entry.field] as File
@@ -416,8 +525,11 @@ const app = new Hono<App>()
entry.target === 2 ? 'android' : 'windows', entry.target === 2 ? 'android' : 'windows',
entry.kind entry.kind
) )
const filename = baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`) const filename =
const key = studioBundleR2Key(filename) job.kind === 'avatar'
? `${entry.target === 2 ? 'quest/' : ''}studio-avatar/${job.avatarItemId}/${entry.unityAssetId}/${lease.lease_token}.${entry.kind}.assetbundle`
: baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
const key = job.kind === 'avatar' ? `avatar/${filename}` : studioBundleR2Key(filename)
await c.env.CDN_ASSETS.put(key, bytes) await c.env.CDN_ASSETS.put(key, bytes)
keys.push(key) keys.push(key)
writes.push( writes.push(
@@ -439,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,
})
)
)
}
+13 -2
View File
@@ -17,12 +17,23 @@ export type Env = SharedHonoEnv & {
// Shared `recflare-cdn` bucket. Local Studio builds land under // Shared `recflare-cdn` bucket. Local Studio builds land under
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind. // `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
CDN_ASSETS: R2Bucket CDN_ASSETS: R2Bucket
/** Private pull-worker credential; leave unset to disable cloud build dispatch. */ /**
CLOUD_BUILD_KEY?: string * Private pull-worker credential, out of the shared Secrets Store (same store as
* JWT_SECRET). Read it through `cloudBuildKey()` in cloud-builds.ts, never `.get()`
* directly: the value `off` (or an unreadable secret) means cloud building is disabled.
*/
CLOUD_BUILD_KEY: SecretsStoreSecret
CLOUD_BUILD_TARGETS?: string CLOUD_BUILD_TARGETS?: string
CLOUD_BUILD_TEMPLATE_VERSION?: string CLOUD_BUILD_TEMPLATE_VERSION?: string
/** R2 key of an operator-published template catalog; unset disables downloads. */ /** R2 key of an operator-published template catalog; unset disables downloads. */
STUDIO_DISTRIBUTION_MANIFEST?: string STUDIO_DISTRIBUTION_MANIFEST?: string
/**
* Discord role ids whose holders have Studio upload access without the account's
* `hasStudio` flag (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`; the same var `auth`
* stamps `betastudio` from). The access lookups here rebuild the role from the account
* rather than a token, so they read it through the same `hasStudioAccess`.
*/
STUDIO_ROLLOUT?: string
} }
/** Variables can be extended */ /** Variables can be extended */
+12 -5
View File
@@ -109,22 +109,29 @@ export async function getUserBuildCount(
const end = new Date(start.getTime() + 60 * 60 * 1000) const end = new Date(start.getTime() + 60 * 60 * 1000)
const row = await db const row = await db
.prepare( .prepare(
`SELECT COUNT(*) AS total, `SELECT SUM(CASE WHEN room_id!=0 THEN 1 ELSE 0 END) AS total,
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS failed SUM(CASE WHEN room_id!=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN room_id=0 THEN 1 ELSE 0 END) AS avatar_total,
SUM(CASE WHEN room_id=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS avatar_failed
FROM studio_cloud_build FROM studio_cloud_build
WHERE created_by_account_id = ?1 WHERE created_by_account_id = ?1
AND started_at >= ?2 AND started_at < ?3` AND started_at >= ?2 AND started_at < ?3`
) )
.bind(accountId, start.toISOString(), end.toISOString()) .bind(accountId, start.toISOString(), end.toISOString())
.first<{ total: number | string; failed: number | string | null }>() .first<{
total: number | string
failed: number | string | null
avatar_total: number | null
avatar_failed: number | null
}>()
return { return {
accountId, accountId,
startTime: start.toISOString(), startTime: start.toISOString(),
endTime: end.toISOString(), endTime: end.toISOString(),
subRoomTotalBuildCount: Number(row?.total ?? 0), subRoomTotalBuildCount: Number(row?.total ?? 0),
subRoomFailedBuildCount: Number(row?.failed ?? 0), subRoomFailedBuildCount: Number(row?.failed ?? 0),
avatarItemTotalBuildCount: 0, avatarItemTotalBuildCount: Number(row?.avatar_total ?? 0),
avatarItemFailedBuildCount: 0, avatarItemFailedBuildCount: Number(row?.avatar_failed ?? 0),
} }
} }
+79 -1
View File
@@ -3,13 +3,15 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { import {
canManageRoomById, canManageRoomById,
getAccount,
getPlayerIdsInRoomSubRoom, getPlayerIdsInRoomSubRoom,
getRoomCoOwnerIds, getRoomCoOwnerIds,
getSubRoom, getSubRoom,
} from '@repo/domain' } from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers' import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' import { studioBuildValidationId, validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { hasStudioAccess } from '../../auth/src/platform-db'
import cloudBuilds from './cloud-builds' import cloudBuilds from './cloud-builds'
import distribution from './distribution' import distribution from './distribution'
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds' import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
@@ -30,6 +32,13 @@ function positiveId(raw: string | undefined): number | null {
return Number.isSafeInteger(id) ? id : null return Number.isSafeInteger(id) ? id : null
} }
/** SQLite datetime() is UTC, but its space-separated output is not a .NET JSON DateTime. */
function accessTimestamp(value: string | undefined, fallback: string): string {
const normalized = value?.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/, '$1T$2Z')
const time = normalized ? Date.parse(normalized) : NaN
return Number.isFinite(time) ? new Date(time).toISOString() : fallback
}
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.' const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
const UPLOAD_FIRST = const UPLOAD_FIRST =
'Upload the room before building locally. The latest save is where this build is recorded.' 'Upload the room before building locally. The latest save is where this build is recorded.'
@@ -168,6 +177,75 @@ const app = new Hono<App>()
.get('/', async (c) => { .get('/', async (c) => {
return c.text('hello, world!') return c.text('hello, world!')
}) })
.get('/access/override/:accountId{[1-9][0-9]*}', async (c) => {
const secret = await c.env.JWT_SECRET.get()
const caller = await validateAndGetAccountId(c.req.raw, secret)
const id = Number(c.req.param('accountId'))
if (!Number.isSafeInteger(id)) return c.body(null, 400)
let roles = await validateAndGetRoles(c.req.raw, secret)
if (caller === null) {
const buildId = await studioBuildValidationId(c.req.raw, secret)
if (!buildId) return c.body(null, 401)
const job = await c.env.DB.prepare(
"SELECT payload FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='building' AND lease_until>?"
)
.bind(buildId, new Date().toISOString())
.first<{ payload: string }>()
if (!job) return c.body(null, 403)
const payload = JSON.parse(job.payload) as {
roomId: number
room?: { Roles?: Array<{ AccountId: number; Role: number }> }
}
if (
!payload.roomId ||
!payload.room?.Roles?.some((r) => r.AccountId === id && [30, 255].includes(r.Role))
)
return c.body(null, 403)
if (!(await getRoomCoOwnerIds(c.env.DB, payload.roomId))?.includes(id))
return c.body(null, 403)
const account = await getAccount(c.env.DB, id)
roles = (await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT)) ? ['betastudio'] : []
}
if (
caller !== null &&
caller !== id &&
!roles?.some((r) => r === 'developer' || r === 'moderator')
) {
// The SDK validates a registry against every owner of its room. Allow
// peers to inspect grants only when both currently manage the same room.
const shared = await c.env.DB.prepare(
`SELECT 1 FROM room r WHERE
(r.creator_account_id=?1 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?1 AND json_extract(role.value,'$.Role') IN (30,255))) AND
(r.creator_account_id=?2 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?2 AND json_extract(role.value,'$.Role') IN (30,255))) LIMIT 1`
)
.bind(caller, id)
.first()
if (!shared) return c.body(null, 403)
}
// Inspection authorization above belongs to the caller; this grant belongs to the target.
if (caller !== null && caller !== id)
roles = (await hasStudioAccess(
c.env.DB,
await getAccount(c.env.DB, id),
c.env.STUDIO_ROLLOUT
))
? ['betastudio']
: []
const grant = await c.env.DB.prepare(
'SELECT flags,created_at,modified_at FROM avatar_studio_access WHERE account_id=?'
)
.bind(id)
.first<{ flags: number; created_at: string; modified_at: string }>()
const flags = roles?.includes('betastudio') ? (grant?.flags ?? 0) : 0
const now = new Date().toISOString()
return c.json({
AccountId: id,
StudioAccess: roles?.includes('betastudio') ? 1 : 2,
AvatarStudioAccess: flags,
CreatedAt: accessTimestamp(grant?.created_at, now),
ModifiedAt: accessTimestamp(grant?.modified_at, now),
})
})
// There is no circuit recommendation corpus on a fresh deployment. An empty // There is no circuit recommendation corpus on a fresh deployment. An empty
// list is the SDK's supported no-recommendations state, not a failed lookup. // list is the SDK's supported no-recommendations state, not a failed lookup.
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([])) .get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
@@ -0,0 +1,348 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import {
AVATAR_STUDIO_SCHEMA,
avatarSaveDto,
avatarStudioSave,
publishAvatarSave,
ROOM_SCHEMA_DDL,
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { generateToken } from '@repo/jwt'
import { SCHEMA_DDL, toQuestCustomAvatarItem } from '../../../../api/src/custom-avatar-items-db'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const origin = 'https://example.com',
itemId = '11111111-1111-1111-1111-111111111111',
assetId = '22222222-2222-2222-2222-222222222222'
const worker = { Authorization: 'Bearer private-avatar-worker-key' }
async function auth(id = 42, roles = ['betastudio']) {
return {
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
}
}
const manifest = {
Version: 3,
MainPrefabProjectRelativePath: 'Assets/Hat.prefab',
Files: [{ n: 'a'.repeat(40), p: 'Assets/Hat.prefab' }],
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
await adminSecretsStore(env.CLOUD_BUILD_KEY).create('private-avatar-worker-key')
env.CLOUD_BUILD_TARGETS = '0,2'
for (const sql of [
...SCHEMA_DDL,
...ROOM_SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
...STUDIO_CLOUD_BUILD_SCHEMA_DDL,
...UNITY_ASSET_SCHEMA_DDL,
CLOUD_JOB_SCHEMA,
])
await env.DB.prepare(sql).run()
})
beforeEach(async () => {
for (const table of [
'studio_cloud_build_job',
'studio_cloud_build',
'studio_avatar_build',
'unity_asset',
'avatar_studio_save',
'avatar_studio_access',
'custom_avatar_item',
])
await env.DB.prepare('DELETE FROM ' + table).run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
await env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
.bind(
JSON.stringify({
CustomAvatarItemId: itemId,
CreatorAccountId: 42,
OutfitType: 0,
Accessibility: 0,
Name: 'Hat',
CurrentSaves: [],
})
)
.run()
await env.DB.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,update_current,created_at) VALUES (1,?,1,?,'source','hash','thumb','{}',1,'2026-10-18')"
)
.bind(itemId, assetId)
.run()
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
})
async function create(id = 42) {
return SELF.fetch(origin + '/cloud-builds/custom-avatar-item/' + itemId + '/save/1', {
method: 'POST',
headers: await auth(id),
body: new URLSearchParams({ pipelineVersion: '2', autoPublish: 'true' }),
})
}
async function claim() {
const r = await SELF.fetch(origin + '/internal/cloud-builds/claim', {
method: 'POST',
headers: worker,
})
return r.json<{ job: { buildId: string; kind: string; manifest: unknown }; leaseToken: string }>()
}
function artifacts(lease: { leaseToken: string }, materialVersion = 6) {
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
const entries = []
for (const target of [0, 2])
for (const kind of ['main', 'material', 'stripped']) {
const field = `bundle${target}${kind}`
form.set(
field,
new File(['UnityFS\0avatar fixture ' + target + kind], field + '.assetbundle')
)
entries.push({
field,
unityAssetId: assetId,
target,
kind,
version: kind === 'material' ? materialVersion : 5,
})
}
form.set('artifacts', JSON.stringify(entries))
return form
}
it('authorizes per-account access and rejects builds from other owners', async () => {
expect((await SELF.fetch(origin + '/access/override/42')).status).toBe(401)
expect(
(await SELF.fetch(origin + '/access/override/42', { headers: await auth(43) })).status
).toBe(403)
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 1 })
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth(42, []) })).json()
).toMatchObject({ AvatarStudioAccess: 0 })
expect((await create(43)).status).toBe(403)
})
it('serializes SQLite and ISO access timestamps as SDK-compatible UTC DateTimes', async () => {
await env.DB.prepare(
'UPDATE avatar_studio_access SET flags=15,created_at=?,modified_at=? WHERE account_id=42'
)
.bind('2026-10-19T00:45:00.372Z', '2026-10-19 16:44:56')
.run()
expect(
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
).toMatchObject({
AvatarStudioAccess: 15,
CreatedAt: '2026-10-19T00:45:00.372Z',
ModifiedAt: '2026-10-19T16:44:56.000Z',
})
})
it('builds and publishes prefab revisions with real SDK main/material version distinctions', async () => {
expect((await create()).status).toBe(200)
const lease = await claim()
expect(lease.job.kind).toBe('avatar')
expect(lease.job.manifest).toMatchObject(manifest)
const finish = () => origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish'
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease, 5) }))
.status
).toBe(400)
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
).toBe(200)
const row = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
const item = JSON.parse(row!.data)
expect(item.CurrentSaves).toHaveLength(1)
expect(item.CurrentSaves[0]).toMatchObject({
CustomAvatarItemSaveId: 1,
BodyType: 1,
OutfitType: 0,
})
expect(item.CurrentSaves[0].UnityAsset).toMatch(/^studio-avatar\//)
expect(item.CurrentSaves[0].UnityAsset2).toContain('.material.assetbundle')
expect(item.CurrentSaves[0]).not.toHaveProperty('SourceUnityAssetFilename')
const saved = await avatarStudioSave(env.DB, itemId, 1),
dto = await avatarSaveDto(env.DB, saved!)
expect(dto.BakedAssets).toHaveLength(4)
expect(dto.BakedAssets.map((a) => a.Version).sort()).toEqual([5, 5, 6, 6])
const quest = toQuestCustomAvatarItem(item).CurrentSaves[0]
expect(quest.UnityAssetHash).toBe(
dto.BakedAssets.find((a) => a.Target === 2 && a.Version === 5)?.Hash
)
expect(quest.UnityAssetHash).not.toBe(item.CurrentSaves[0].UnityAssetHash)
expect((await env.CDN_ASSETS.get('avatar/' + dto.UnityAsset))?.size).toBeGreaterThan(8)
expect(
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
).toBe(409)
const count = await SELF.fetch(origin + '/cloud-builds/get-build-count-for-user', {
headers: await auth(),
})
expect(await count.json()).toMatchObject({
value: { subRoomTotalBuildCount: 0, avatarItemTotalBuildCount: 1 },
})
})
it('rejects source code paths and cannot cancel another account’s build', async () => {
await env.CDN_ASSETS.put(
'avatarunityasset/source',
JSON.stringify({ ...manifest, Files: [null] })
)
expect((await create()).status).toBe(400)
await env.CDN_ASSETS.put(
'avatarunityasset/source',
JSON.stringify({
...manifest,
Files: [...manifest.Files, { n: 'a'.repeat(40), p: 'Assets/Code.cs' }],
})
)
expect((await create()).status).toBe(400)
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
await create()
const lease = await claim()
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(401)
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
headers: await auth(43),
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(404)
expect(
(
await SELF.fetch(origin + '/cloud-builds/cancel', {
method: 'PUT',
headers: await auth(),
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
})
).status
).toBe(200)
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(lease),
})
).status
).toBe(409)
})
it('does not publish a deleted item or an older revision after a newer save', async () => {
await create()
const lease = await claim()
await env.DB.prepare(
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1'
).run()
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(lease),
})
).status
).toBe(200)
const item = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
expect(JSON.parse(item!.data).CurrentSaves).toEqual([])
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
await env.DB.prepare('DELETE FROM studio_cloud_build').run()
await env.DB.prepare('DELETE FROM studio_avatar_build').run()
await create()
const deleted = await claim()
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
expect(
(
await SELF.fetch(origin + '/internal/cloud-builds/' + deleted.job.buildId + '/finish', {
method: 'POST',
headers: worker,
body: artifacts(deleted),
})
).status
).toBe(409)
})
it('deduplicates concurrent avatar requests and allows a new build after failure', async () => {
const responses = await Promise.all([create(), create(), create()])
const ids = await Promise.all(
responses.map(async (response) => {
expect(response.status).toBe(200)
return (await response.json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
})
)
expect(new Set(ids).size).toBe(1)
for (const table of ['studio_cloud_build', 'studio_avatar_build', 'studio_cloud_build_job'])
expect(await env.DB.prepare('SELECT COUNT(*) AS n FROM ' + table).first()).toEqual({ n: 1 })
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='failed'").run()
expect(
(await (await create()).json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
).not.toBe(ids[0])
})
it.each([false, true])(
'checks automatic avatar revision ordering in the write, including CAS retry=%s, preserving explicit restore',
async (retry) => {
await env.DB.prepare("INSERT INTO unity_asset VALUES (?,0,5,'main','old.bundle','hash')")
.bind(assetId)
.run()
let intercepted = false
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'prepare')
return (sql: string) => {
const wrap = (statement: D1PreparedStatement): D1PreparedStatement =>
new Proxy(statement, {
get(stmt, method) {
if (method === 'bind') return (...values: unknown[]) => wrap(stmt.bind(...values))
if (method === 'run' && sql.startsWith('UPDATE custom_avatar_item'))
return async () => {
if (!intercepted) {
intercepted = true
await target
.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT 2,item_id,body_type,'new-identity',source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1"
)
.run()
if (retry)
await target
.prepare(
"UPDATE custom_avatar_item SET data=json_set(data,'$.Name','Concurrent metadata')"
)
.run()
}
return stmt.run()
}
const value = Reflect.get(stmt, method)
return typeof value === 'function' ? value.bind(stmt) : value
},
})
return wrap(target.prepare(sql))
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
expect(await publishAvatarSave(DB, itemId, 1, true)).toBe(false)
expect(intercepted).toBe(true)
let item = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
)
expect(item.CurrentSaves).toEqual([])
expect(await publishAvatarSave(env.DB, itemId, 1)).toBe(true)
item = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
)
expect(item.CurrentSaves[0].CustomAvatarItemSaveId).toBe(1)
if (retry) expect(item.Name).toBe('Concurrent metadata')
}
)
@@ -8,7 +8,7 @@ import {
UNITY_ASSET_SCHEMA_DDL, UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain' } from '@repo/domain'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds' import cloudBuilds, { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context' import type { Env } from '../../context'
@@ -49,7 +49,7 @@ async function auth(id = 42, studio = true) {
} }
beforeAll(async () => { beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key') await adminSecretsStore(env.JWT_SECRET).create('test-key')
env.CLOUD_BUILD_KEY = key await adminSecretsStore(env.CLOUD_BUILD_KEY).create(key)
env.CLOUD_BUILD_TARGETS = '0' env.CLOUD_BUILD_TARGETS = '0'
for (const sql of [ for (const sql of [
...ROOM_SCHEMA_DDL, ...ROOM_SCHEMA_DDL,
@@ -468,3 +468,138 @@ it('does not complete a build whose submitted save was deleted during compilatio
.run() .run()
} }
}) })
function builtArtifacts(lease: Awaited<ReturnType<typeof claim>>) {
const form = new FormData()
form.set('leaseToken', lease.leaseToken)
form.set('bundle', new File(['UnityFS\0regression'], 'bundle.assetbundle'))
form.set(
'artifacts',
JSON.stringify([
{
field: 'bundle',
unityAssetId: lease.job.unityAssetId,
target: 0,
kind: 'main',
version: 5,
},
])
)
return form
}
function finishBuild(
lease: Awaited<ReturnType<typeof claim>>,
form = builtArtifacts(lease),
bindings = env
) {
return cloudBuilds.request(
origin + `/internal/cloud-builds/${lease.job.buildId}/finish`,
{ method: 'POST', headers: worker, body: form },
bindings
)
}
it.each(['before finish', 'before transaction'])(
'rejects removed room co-owner authority %s without linking or publishing',
async (timing) => {
const prior = await env.DB.prepare('SELECT data FROM room WHERE room_id=3000').first<{
data: string
}>()
const originalSave = await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first<{ data: string }>()
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
.bind(JSON.stringify([{ AccountId: 43, Role: 30 }]))
.run()
await create(43, true, { autoPublish: 'true' })
const lease = await claim()
const revoke = () =>
env.DB.prepare(
"UPDATE room SET data=json_set(data,'$.Roles',json('[]')) WHERE room_id=3000"
).run()
if (timing === 'before finish') await revoke()
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'batch')
return async (statements: D1PreparedStatement[]) => {
await revoke()
return target.batch(statements)
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
try {
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
expect(
await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first()
).toEqual(originalSave)
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
expect(
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
.bind(lease.job.buildId)
.first()
).toEqual({ status: 'failed' })
expect(
await env.CDN_ASSETS.get(
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
)
).toBeNull()
expect(
(await env.CDN_ASSETS.list({ prefix: `room/studio/3000/${lease.job.unityAssetId}` }))
.objects
).toEqual([])
} finally {
await env.DB.prepare('UPDATE room SET data=? WHERE room_id=3000').bind(prior!.data).run()
}
}
)
it.each(['expired lease', 'deleted save'])(
'cleans uploaded artifacts when final publication finds %s',
async (condition) => {
await create()
const lease = await claim()
const save = await env.DB.prepare(
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
).first<{ data: string }>()
const DB = new Proxy(env.DB, {
get(target, key) {
if (key === 'batch')
return async (statements: D1PreparedStatement[]) => {
if (condition === 'expired lease')
await target
.prepare("UPDATE studio_cloud_build_job SET lease_until='2000-01-01'")
.run()
else
await target
.prepare('DELETE FROM subroom_save WHERE sub_room_data_save_id=3002')
.run()
return target.batch(statements)
}
const value = Reflect.get(target, key)
return typeof value === 'function' ? value.bind(target) : value
},
})
try {
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
expect(
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
.bind(lease.job.unityAssetId)
.first()
).toBeNull()
expect(
await env.CDN_ASSETS.get(
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
)
).toBeNull()
} finally {
if (condition === 'deleted save')
await env.DB.prepare('INSERT INTO subroom_save VALUES (3002,3001,?)').bind(save!.data).run()
}
}
)
@@ -0,0 +1,108 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import { SCHEMA_DDL as ACCOUNT_SCHEMA, AVATAR_STUDIO_SCHEMA, ROOM_SCHEMA_DDL } from '@repo/domain'
import { generateStudioBuildValidationToken, generateToken } from '@repo/jwt'
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const base = 'https://example.com/access/override/'
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
for (const sql of [
...ACCOUNT_SCHEMA,
...ROOM_SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
CLOUD_JOB_SCHEMA,
])
await env.DB.prepare(sql).run()
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
.bind(JSON.stringify({ accountId: 42, username: 'Fixture', hasStudio: true }))
.run()
await env.DB.prepare('INSERT INTO room(data) VALUES (?)')
.bind(
JSON.stringify({ RoomId: 3000, CreatorAccountId: 42, Roles: [{ AccountId: 42, Role: 255 }] })
)
.run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,8,'now','now')").run()
})
beforeEach(async () => {
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
await env.DB.prepare(
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,status,lease_until,created_at) VALUES (?,?,?,?,?)'
)
.bind(
'123',
JSON.stringify({ roomId: 3000, room: { Roles: [{ AccountId: 42, Role: 255 }] } }),
'building',
new Date(Date.now() + 60000).toISOString(),
'now'
)
.run()
})
async function request(id: number, build = '123') {
return SELF.fetch(base + id, {
headers: {
Authorization: 'Bearer ' + (await generateStudioBuildValidationToken(build, 'test-key')),
},
})
}
it('allows read-only consumable validation only for an active job’s room owners', async () => {
expect(await (await request(42)).json()).toMatchObject({
AccountId: 42,
StudioAccess: 1,
AvatarStudioAccess: 8,
})
expect((await request(43)).status).toBe(403)
expect((await request(42, '124')).status).toBe(403)
expect((await SELF.fetch(base + '42')).status).toBe(401)
})
it('rejects cancelled and expired jobs', async () => {
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='cancelled'").run()
expect((await request(42)).status).toBe(403)
await env.DB.prepare(
"UPDATE studio_cloud_build_job SET status='building',lease_until='2000-01-01'"
).run()
expect((await request(42)).status).toBe(403)
})
it('lets co-owners validate each other’s grants without exposing unrelated accounts', async () => {
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
.bind(
JSON.stringify([
{ AccountId: 42, Role: 255 },
{ AccountId: 43, Role: 30 },
])
)
.run()
const headers = {
Authorization:
'Bearer ' + (await generateToken('43', 'fixture', 0, 'test-key', ['betastudio'])),
}
expect(await (await SELF.fetch(base + '42', { headers })).json()).toMatchObject({
AvatarStudioAccess: 8,
})
expect((await SELF.fetch(base + '44', { headers })).status).toBe(403)
})
it('uses target entitlement for staff inspections in both directions', async () => {
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
.bind(JSON.stringify({ accountId: 44, username: 'Disabled', hasStudio: false }))
.run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (44,15,'now','now')").run()
const staff = async (roles: string[]) => ({
Authorization: 'Bearer ' + (await generateToken('99', 'staff', 0, 'test-key', roles)),
})
expect(
await (
await SELF.fetch(base + '44', { headers: await staff(['developer', 'betastudio']) })
).json()
).toMatchObject({ AccountId: 44, StudioAccess: 2, AvatarStudioAccess: 0 })
expect(
await (await SELF.fetch(base + '42', { headers: await staff(['moderator']) })).json()
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 8 })
})
+14
View File
@@ -37,6 +37,20 @@
"binding": "JWT_SECRET", "binding": "JWT_SECRET",
"store_id": "local", "store_id": "local",
"secret_name": "JWT_SECRET" "secret_name": "JWT_SECRET"
},
// The credential the cloud-building pull worker (recflare/cloud-building, its
// STUDIO_WORKER_KEY) presents as a bearer token on /internal/cloud-builds/*. A real
// secret, so it lives in the same store rather than riding along as a `--var` from
// .env, which would put it in plaintext on every worker. The binding must resolve
// for this worker to deploy: create it with the value `off` to run without cloud
// builds (creation then answers 503 and the private routes refuse everyone).
//
// wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
// --scopes workers --remote
{
"binding": "CLOUD_BUILD_KEY",
"store_id": "local",
"secret_name": "CLOUD_BUILD_KEY"
} }
], ],
"upload_source_maps": true, "upload_source_maps": true,
+51 -5
View File
@@ -154,6 +154,23 @@ box. The grant runs after Plus is granted and the link written, and a failure in
logged rather than failing the claim. Unset, the var awards nothing and the claim is as logged rather than failing the claim. Unset, the var awards nothing and the claim is as
before. before.
**Unlinking.** The same panel offers **Unlink Discord** whenever any Discord is on the
account — a claim's link or the bare one "Sign in with Discord" leaves — and
`POST /api/benefits/unlink` removes every `PlatformType.Discord` link the account holds.
It is refused (403) until the account has an **email address**: an account made from
Discord has none until the player sets one, the link may be its only way in, and an address
is what the account can be recovered through (a password is not required — a passwordless
account sets one in game). The row decides, not the page; `/api/benefits/status` serves the
same flag (`hasEmail`, beside `discordLinked`) so the panel can say so first. Afterwards that
Discord opens no account — signing in with it makes a new one — and the once-only guard lets
it claim elsewhere. **The grants go with the link**: `hasPlus` and `hasStudio` are both
cleared, whoever set them (an operator's grant too — nothing on the row says which wrote
it), and the panel warns before the confirm; both reach the game on the next token. What the
account does keep is that it claimed: removing a link with roles recorded stamps
`hasClaimedDiscord` on the account, and the claim's "first link" check consults it, or
link → unlink → link would pay the one-time supporter gift on every cycle. The route is not gated on the Discord
configuration — the link is in the table whether or not the claim is still open.
The link also records the member's **roles** (`platform_account.role`, a JSON array of The link also records the member's **roles** (`platform_account.role`, a JSON array of
role ids). The claim writes them from the member record it read; on their own they'd be role ids). The claim writes them from the member record it read; on their own they'd be
a snapshot from claim time, because the claim revokes the player's token the moment it's a snapshot from claim time, because the claim revokes the player's token the moment it's
@@ -220,11 +237,40 @@ won't pick up newly created secrets until it restarts.
which one is missing. `wrangler tail www` shows it. 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)
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RecFlare — play like it&apos;s 2025</title> <title>RecFlare</title>
<meta <meta
name="description" name="description"
content="RecFlare is a free, open source fan rebuild of the RecNet servers. Download for PC, join the Discord, and play." content="RecFlare is a free, open source fan rebuild of the RecNet servers. Download for PC, join the Discord, and play."
+8
View File
@@ -40,6 +40,14 @@ const AUTH_MESSAGES: Record<string, string> = {
'That platform cannot be verified — only Steam and Meta are supported.', '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 —
+1104 -118
View File
@@ -16,6 +16,7 @@ import { downgradeRoom, needsDowngrade } from '../room-converter'
// The session token, the worker hostnames and the `call` every request goes through — // The session token, the worker hostnames and the `call` every request goes through —
// see api.ts for why they're a module of their own rather than defined here. // see api.ts for why they're a module of their own rather than defined here.
import { import {
accountByUsername,
accountIdForUsername, accountIdForUsername,
call, call,
hasToken, hasToken,
@@ -30,11 +31,12 @@ import {
import { DeviceLinkPage } from './DeviceLink' import { DeviceLinkPage } from './DeviceLink'
import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item' import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item'
import { ModerationPage, PlayerBanStatus } from './Moderation' import { ModerationPage, PlayerBanStatus } from './Moderation'
import { PumpleBossFight } from './PumpleBossFight'
import { StatsPage } from './Stats' import { StatsPage } from './Stats'
import { StudioAccessPage } from './StudioAccess' import { StudioAccessPage } from './StudioAccess'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { Hosts } from './api' import type { Hosts, PublicAccount } from './api'
/** /**
* Site config from `www`. `signupEnabled` is false when the operator has no Turnstile * Site config from `www`. `signupEnabled` is false when the operator has no Turnstile
@@ -56,6 +58,12 @@ interface SiteConfig {
* benefits are off. The `state` nonce is appended here — see `startDiscordAuth`. * benefits are off. The `state` nonce is appended here — see `startDiscordAuth`.
*/ */
discordAuthorizeUrl: string | null discordAuthorizeUrl: string | null
/**
* The consent URL for "Sign in with Discord" — same app, `identify` scope only, and its own
* return leg (`/login/discord`). On the same switch as the claim, so null whenever
* `benefitsEnabled` is false. See `DiscordLoginPage`.
*/
discordLoginUrl: string | null
} }
/** The private self DTO from `accounts` (`GET /account/me`). */ /** The private self DTO from `accounts` (`GET /account/me`). */
@@ -66,6 +74,8 @@ interface SelfAccount {
email: string | null email: string | null
/** A key on the `img` worker — `DefaultProfileImage.jpg` until the player sets one. */ /** A key on the `img` worker — `DefaultProfileImage.jpg` until the player sets one. */
profileImage: string profileImage: string
/** The profile banner's key on `img` — `""` until the player sets one. */
bannerImage: string
/** /**
* Username changes left on the account — each change spends one, and an account * Username changes left on the account — each change spends one, and an account
* starts with one. Absent on an older self DTO, which reads as "unknown": the form * starts with one. Absent on an older self DTO, which reads as "unknown": the form
@@ -356,6 +366,41 @@ async function setProfileImage(imageName: string): Promise<void> {
if (res.success !== true) throw new Error('The accounts worker refused the image.') if (res.success !== true) throw new Error('The accounts worker refused the image.')
} }
/**
* Upload a banner picture through the same `api` route as the avatar, but typed
* `SavedImageType.None` (0): the game has no banner type (its banner is one of the
* player's camera photos), and anything but ShareCamera stays out of the photo feed and
* the player's photo list, while ProfileThumbnail would also overwrite the avatar. Filed
* under `none/` in the bucket. Returns the key `img` serves it by.
*/
async function uploadBannerImage(file: File): Promise<string> {
const form = new FormData()
form.set('imgMeta', JSON.stringify({ savedImageType: 0 }))
form.set('image', file)
const { ImageName } = await call<{ ImageName?: string }>(
`${where().api}/api/images/v4/uploadsaved`,
{ method: 'POST', multipart: form, authed: true }
)
if (!ImageName) throw new Error('The api worker accepted the picture but returned no name.')
return ImageName
}
/**
* Point the account's banner at an uploaded picture — `PUT /account/me/bannerimage` on
* `accounts`, the call the game makes when a player picks a banner. Same `{ success }`
* envelope as the avatar's PUT, and it pushes the same `AccountUpdate` frame, so a
* running client redraws the banner too.
*/
async function setBannerImage(imageName: string): Promise<void> {
const res = await call<{ success?: boolean }>(`${where().accounts}/account/me/bannerimage`, {
method: 'PUT',
authed: true,
form: { imageName },
refusal: 'The accounts worker refused the banner.',
})
if (res.success !== true) throw new Error('The accounts worker refused the banner.')
}
/** /**
* The blob's SHA-256, base64 — the encoding this API's hash fields use (an invention's * The blob's SHA-256, base64 — the encoding this API's hash fields use (an invention's
* `BlobHash` comes back the same way). `rooms` only echoes it back on the save, but a * `BlobHash` comes back the same way). `rooms` only echoes it back on the save, but a
@@ -539,6 +584,30 @@ const saveEmail = (email: string): Promise<unknown> =>
refusal: 'That email address looks wrong.', refusal: 'That email address looks wrong.',
}) })
/**
* The email change awaiting a code, if any: `saveEmail` STAGES the address and `accounts`
* emails six-digit codes — first to the current address when there is one (so a stolen
* session can't move it), then to the new one (so a typo is never stored). `sentTo` says
* which step this is. Each code is spent with {@link confirmEmail}. This read is what lets
* the form show the code step after a reload, or right after signup.
*/
interface PendingEmail {
pending: string | null
sentTo: string | null
}
const fetchPendingEmail = (): Promise<PendingEmail> =>
call<PendingEmail>(`${where().accounts}/account/me/email/pending`, { authed: true })
/**
* Spend the code from the email. One try per code. `applied` false means the current address
* has approved and a second code is now out to the new one, which `pending`/`sentTo` name.
*/
const confirmEmail = (code: string): Promise<PendingEmail & { applied: boolean }> =>
call<PendingEmail & { applied: boolean }>(`${where().accounts}/account/me/email/confirm`, {
form: { code },
authed: true,
})
/** Change the account's password. Lives on `auth`, not `accounts`. */ /** Change the account's password. Lives on `auth`, not `accounts`. */
const changePassword = (oldPassword: string, newPassword: string): Promise<unknown> => const changePassword = (oldPassword: string, newPassword: string): Promise<unknown> =>
call(`${where().auth}/account/me/changepassword`, { call(`${where().auth}/account/me/changepassword`, {
@@ -546,12 +615,40 @@ const changePassword = (oldPassword: string, newPassword: string): Promise<unkno
authed: true, authed: true,
}) })
/**
* "Forgot password": ask `auth` to email a recovery code to the address. The same route the
* game posts, so it answers in the client's PascalCase envelope — and a refusal is a 200
* with `Success: false`, which `call` doesn't throw on, hence the check here. `Success: true`
* says nothing about whether the address is on an account; the page's copy allows for that.
*/
const recoverPassword = async (email: string): Promise<void> => {
const r = await call<{ Success: boolean; Error: string | null }>(
`${where().auth}/account/recoverpassword`,
{ form: { email } }
)
if (!r.Success) throw new Error(r.Error || 'The recovery email could not be sent.')
}
/** Finish the recovery: the address, the code from the email, and the password to set. */
const resetPassword = (email: string, code: string, newPassword: string): Promise<unknown> =>
call(`${where().auth}/account/resetpassword`, { form: { email, code, newPassword } })
/** Where this account's benefits stand: `www` reads them off the account row. */ /** Where this account's benefits stand: `www` reads them off the account row. */
interface BenefitsStatus { interface BenefitsStatus {
/** Whether the account already has Rec Room Plus. */ /** Whether the account already has Rec Room Plus. */
hasPlus: boolean hasPlus: boolean
/** Whether a Discord identity is already tied to it. Which one is deliberately not served. */ /** Whether a Discord identity is already tied to it. Which one is deliberately not served. */
linked: boolean linked: boolean
/**
* Whether ANY Discord is on the account — a claim's link or the bare one "Sign in with
* Discord" leaves. What the unlink button keys on; `linked` is only the claimed case.
*/
discordLinked: boolean
/**
* The unlink's one precondition: the account must have an email address, or taking the
* Discord off could leave nothing to recover it by. Served so the panel can say so first.
*/
hasEmail: boolean
} }
/** /**
@@ -582,6 +679,14 @@ interface ClaimResult {
const claimBenefits = (code: string): Promise<ClaimResult> => const claimBenefits = (code: string): Promise<ClaimResult> =>
call<ClaimResult>('/api/benefits/claim', { json: { code }, authed: true }) call<ClaimResult>('/api/benefits/claim', { json: { code }, authed: true })
/**
* Take the Discord off the account. `www` refuses (403, with a sentence) unless the account
* has a password and an email — the panel checks the same two flags first so the button
* only shows when it can work. Answers the status as it now stands.
*/
const unlinkDiscord = (): Promise<BenefitsStatus> =>
call<BenefitsStatus>('/api/benefits/unlink', { json: {}, authed: true })
/** /**
* The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage. * The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage.
* *
@@ -592,21 +697,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
@@ -739,6 +875,7 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
// and tokens last a day and are never refreshed. Without this line the claim looks like // and tokens last a day and are never refreshed. Without this line the claim looks like
// it silently did nothing, which is the single most likely support question here. // it silently did nothing, which is the single most likely support question here.
const [relogin, setRelogin] = useState(false) const [relogin, setRelogin] = useState(false)
const [unlinking, setUnlinking] = useState(false)
// StrictMode runs effects twice in dev, and a Discord code is single-use: the second // StrictMode runs effects twice in dev, and a Discord code is single-use: the second
// run would redeem a spent code and report a failure over a claim that just worked. // run would redeem a spent code and report a failure over a claim that just worked.
const redeemed = useRef(false) const redeemed = useRef(false)
@@ -779,7 +916,14 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
setPending(true) setPending(true)
claimBenefits(code) claimBenefits(code)
.then((result) => { .then((result) => {
setStatus({ hasPlus: result.hasPlus, linked: true }) setStatus((prev) => ({
hasPlus: result.hasPlus,
linked: true,
discordLinked: true,
// The claim says nothing about this; keep what the status read said, and assume
// none until it answers.
hasEmail: prev?.hasEmail ?? false,
}))
const who = result.discordUsername ? ` as ${result.discordUsername}` : '' const who = result.discordUsername ? ` as ${result.discordUsername}` : ''
// Linked either way; whether Plus came with it depends on the roles. Without a // Linked either way; whether Plus came with it depends on the roles. Without a
// qualifying role the roles are still saved for the game, and that is said // qualifying role the roles are still saved for the game, and that is said
@@ -881,6 +1025,60 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
Verify with Discord Verify with Discord
</button> </button>
)} )}
{status?.discordLinked && !pending && (
// Taking the Discord off again. Offered for ANY link — a claim's, or the bare one a
// Discord sign-in made — but only once the account can be recovered without it: an
// account made from Discord has no email until the player sets one, and `www`
// refuses the unlink until there is one. The panel says so rather than showing a
// button that would fail.
<div className="unlink">
<h3>Unlink Discord</h3>
{status.hasEmail ? (
<>
<p className="muted">
Removes your Discord from this account. Rec Room Plus and Rec Room Studio access
granted to this account are removed with it, your roles stop being read, and signing
in with that Discord afterwards will make a new account rather than open this one.
You can link again at any time.
</p>
<button
type="button"
className="linkish danger"
disabled={unlinking}
onClick={() => {
if (
!confirm(
'Unlink your Discord from this account? Rec Room Plus and Studio access on this account will be removed.'
)
)
return
setUnlinking(true)
setError('')
setDone('')
unlinkDiscord()
.then((next) => {
setStatus(next)
setRelogin(false)
setDone('Your Discord has been unlinked from this account.')
})
.catch((err: unknown) =>
setError(err instanceof Error ? err.message : String(err))
)
.finally(() => setUnlinking(false))
}}
>
{unlinking ? 'Unlinking…' : 'Unlink Discord'}
</button>
</>
) : (
<p className="hint">
Before you can unlink, add an email address to this account (the{' '}
<strong>Email</strong> tab), so the account can be recovered without Discord.
</p>
)}
</div>
)}
</section> </section>
) )
} }
@@ -952,16 +1150,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 —
@@ -983,24 +1171,23 @@ interface PublicRoom {
Stats: { VisitCount: number; CheerCount: number; FavoriteCount: number } Stats: { VisitCount: number; CheerCount: number; FavoriteCount: number }
} }
/** Prefix-search accounts by username — backs the header search bar. */ /** Prefix-search accounts by username — backs the header search. */
async function searchPlayers(query: string): Promise<PublicAccount[]> { async function searchPlayers(query: string, take: number): Promise<PublicAccount[]> {
if (query.trim() === '') return [] if (query.trim() === '') return []
return call<PublicAccount[]>( return call<PublicAccount[]>(
`${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}` `${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}&take=${take}`
) )
} }
/** The profile page's account: an exact lookup, not the search bar's prefix query. */
const fetchPublicAccount = (username: string): Promise<PublicAccount | null> => 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`
* reads it and, only when the list is the caller's own, includes the photos they keep * reads it and, only when the list is the caller's own, includes the photos they keep
* private — so a signed-in player sees all of theirs, on `/account` and on their own * private — so a signed-in player sees all of theirs, on `/account` and on their own
* `/u/<name>`, while everyone else's profile is the public list either way. Nothing * `/user/<name>`, while everyone else's profile is the public list either way. Nothing
* here decides who the viewer is; the worker does, from the token. * here decides who the viewer is; the worker does, from the token.
*/ */
const fetchPlayerPhotos = (accountId: number): Promise<PublicPhoto[]> => const fetchPlayerPhotos = (accountId: number): Promise<PublicPhoto[]> =>
@@ -1073,91 +1260,199 @@ async function setRoomExcluded(roomId: number, excluded: boolean): Promise<Owned
return result.value return result.value
} }
/** The `/u/<username>` path, or null for any other path. */ /** The `/user/<username>` path (or the old `/u/<username>`), or null for any other path. */
function usernameFromPath(path: string): string | null { function usernameFromPath(path: string): string | null {
const match = /^\/u\/([^/]+)$/.exec(path) const match = /^\/(?:user|u)\/([^/]+)$/.exec(path)
return match ? decodeURIComponent(match[1]) : null return match ? decodeURIComponent(match[1]) : null
} }
// The nav's player search. `/u/:name` profiles stay reachable by URL either way; this only // The nav's search. `/user/:name` and `/rooms/:id` stay reachable by URL either way; this
// decides whether the search bar that leads to them is shown. // only decides whether the search button that leads to them is shown.
const SHOW_PLAYER_SEARCH: boolean = true const SHOW_SEARCH: boolean = true
/** How many of each kind the search panel lists. */
const SEARCH_TAKE = 5
/** /**
* The header search bar — a rec.net-style bubble dropdown of matching players as you * Rooms whose name starts with `query`. The prefix endpoint, not the game's `/rooms/search`:
* type. Debounced so it doesn't fire a search per keystroke; closes on selecting a * that one's substring match reads every public room per call, which a search fired on
* result, on Escape, or on clicking outside it. * every pause in typing can't afford; this one is an index seek billed per row returned.
*/ */
function PlayerSearch({ navigate }: { navigate: Navigate }) { const searchRooms = (query: string, take: number): Promise<PublicRoom[]> =>
const [query, setQuery] = useState('') query.trim() === ''
const [results, setResults] = useState<PublicAccount[]>([]) ? Promise.resolve([])
: call<PublicRoom[]>(
`${where().rooms}/rooms/search/name?query=${encodeURIComponent(query.trim())}&take=${take}`
)
function SearchIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" strokeWidth="2.2" />
<path
d="M16 16l4.5 4.5"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
/>
</svg>
)
}
/**
* The header search: a magnifier button that opens a panel searching players and rooms
* together as you type. A button rather than an always-on bar so it fits a phone's nav.
* Debounced so it doesn't fire per keystroke; closes on picking a result, on Escape, or
* on a tap outside it. Enter takes the first player, else the first room.
*/
function SiteSearch({ navigate }: { navigate: Navigate }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [players, setPlayers] = useState<PublicAccount[]>([])
const [rooms, setRooms] = useState<PublicRoom[]>([])
// The query the results on screen answer, so "nothing found" waits for the answer.
const [answered, setAnswered] = useState('')
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Answers by normalized query, for the panel's lifetime on the page: backspacing to a
// query already asked redraws it without another request (and another D1 read).
const cache = useRef(new Map<string, [PublicAccount[], PublicRoom[]]>())
useEffect(() => { useEffect(() => {
if (query.trim() === '') { const key = query.trim().toLowerCase()
setResults([]) const show = ([p, r]: [PublicAccount[], PublicRoom[]]) => {
setPlayers(p.slice(0, SEARCH_TAKE))
setRooms(r.slice(0, SEARCH_TAKE))
setAnswered(query)
}
if (key === '') {
setPlayers([])
setRooms([])
setAnswered('')
return return
} }
const known = cache.current.get(key)
if (known) {
show(known)
return
}
let stale = false
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
void searchPlayers(query) void Promise.all([
.then((r) => setResults(r.slice(0, 6))) searchPlayers(key, SEARCH_TAKE).catch(() => null),
.catch(() => setResults([])) searchRooms(key, SEARCH_TAKE).catch(() => null),
]).then(([p, r]) => {
const found: [PublicAccount[], PublicRoom[]] = [p ?? [], r ?? []]
// A failed half isn't cached, so the next time this is typed it asks again.
if (p !== null && r !== null) cache.current.set(key, found)
if (!stale) show(found)
})
}, 250) }, 250)
return () => clearTimeout(timeout) return () => {
stale = true
clearTimeout(timeout)
}
}, [query]) }, [query])
useEffect(() => { useEffect(() => {
const onClickOutside = (e: MouseEvent) => { if (!open) return
inputRef.current?.focus()
const onPointerDown = (e: PointerEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false) setOpen(false)
} }
} }
document.addEventListener('mousedown', onClickOutside) document.addEventListener('pointerdown', onPointerDown)
return () => document.removeEventListener('mousedown', onClickOutside) return () => document.removeEventListener('pointerdown', onPointerDown)
}, []) }, [open])
const go = (username: string) => { const go = (to: string) => {
setOpen(false) setOpen(false)
setQuery('') setQuery('')
navigate(`/u/${encodeURIComponent(username)}`) navigate(to)
} }
const playerPath = (r: PublicAccount) => `/user/${encodeURIComponent(r.username)}`
const roomPath = (r: PublicRoom) => `/rooms/${r.RoomId}`
const nothing = query.trim() !== '' && answered === query && players.length + rooms.length === 0
return ( return (
<div className="player-search" ref={containerRef}> <div className="site-search" ref={containerRef}>
<input <button
type="text" type="button"
placeholder="Search players…" className={`site-search-toggle${open ? ' active' : ''}`}
value={query} aria-label="Search players and rooms"
onChange={(e) => { aria-expanded={open}
setQuery(e.target.value) onClick={() => setOpen((o) => !o)}
setOpen(true) >
}} <SearchIcon />
onFocus={() => setOpen(true)} </button>
onKeyDown={(e) => { {open && (
if (e.key === 'Escape') setOpen(false) <div className="site-search-panel">
if (e.key === 'Enter' && results[0]) go(results[0].username) <input
}} ref={inputRef}
/> type="search"
{open && results.length > 0 && ( placeholder="Search players and rooms…"
<div className="player-search-bubble"> aria-label="Search players and rooms"
{results.map((r) => ( value={query}
<button onChange={(e) => setQuery(e.target.value)}
key={r.accountId} onKeyDown={(e) => {
className="player-search-result" if (e.key === 'Escape') setOpen(false)
onClick={() => go(r.username)} if (e.key === 'Enter') {
> if (players[0]) go(playerPath(players[0]))
<img else if (rooms[0]) go(roomPath(rooms[0]))
className="player-search-avatar" }
src={`${where().img}/${r.profileImage}?width=64`} }}
alt="" />
/> {players.length > 0 && (
<span> <div className="site-search-group">
<span className="player-search-name">{r.displayName || r.username}</span> <p className="site-search-heading">Players</p>
<span className="player-search-handle">@{r.username}</span> {players.map((r) => (
</span> <button
</button> key={r.accountId}
))} type="button"
className="site-search-result"
onClick={() => go(playerPath(r))}
>
<img
className="site-search-avatar"
src={`${where().img}/${r.profileImage}?width=64`}
alt=""
/>
<span>
<span className="site-search-name">{r.displayName || r.username}</span>
<span className="site-search-sub">@{r.username}</span>
</span>
</button>
))}
</div>
)}
{rooms.length > 0 && (
<div className="site-search-group">
<p className="site-search-heading">Rooms</p>
{rooms.map((r) => (
<button
key={r.RoomId}
type="button"
className="site-search-result"
onClick={() => go(roomPath(r))}
>
<img
className="site-search-thumb"
src={`${where().img}/${r.ImageName}?width=128`}
alt=""
/>
<span>
<span className="site-search-name">^{r.Name}</span>
<span className="site-search-sub">
{r.Stats.VisitCount.toLocaleString()} visits
</span>
</span>
</button>
))}
</div>
)}
{nothing && <p className="site-search-empty">No players or rooms match.</p>}
</div> </div>
)} )}
</div> </div>
@@ -1284,7 +1579,7 @@ function ImagePage({ imageId, config }: { imageId: number; config: SiteConfig |
} }
/** /**
* A player's public profile page (`/u/<username>`) — banner, avatar, bio-adjacent * A player's public profile page (`/user/<username>`) — banner, avatar, bio-adjacent
* info, their public rooms, and their public photos. Read-only: this is the * info, their public rooms, and their public photos. Read-only: this is the
* "rec.net-style profile" other players browse to, not the owner's own dashboard * "rec.net-style profile" other players browse to, not the owner's own dashboard
* (that stays on `/account`). * (that stays on `/account`).
@@ -1306,7 +1601,7 @@ function PlayerPage({
useEffect(() => { useEffect(() => {
// Waits for the config, not the session: this page is public, but every fetch on it // Waits for the config, not the session: this page is public, but every fetch on it
// goes through `where()`, which has no hostnames until `/api/config` lands. On a cold // goes through `where()`, which has no hostnames until `/api/config` lands. On a cold
// load of `/u/<name>` the effect would otherwise fire first and the page would be // load of `/user/<name>` the effect would otherwise fire first and the page would be
// stuck on "Still starting up". // stuck on "Still starting up".
if (config === undefined) return if (config === undefined) return
setAccount(undefined) setAccount(undefined)
@@ -1777,11 +2072,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 +2095,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 +2109,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 +2136,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 +2226,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 +2275,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 +2292,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 +2304,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 +2361,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 +2466,7 @@ export function App() {
turnstileSiteKey: null, turnstileSiteKey: null,
benefitsEnabled: false, benefitsEnabled: false,
discordAuthorizeUrl: null, discordAuthorizeUrl: null,
discordLoginUrl: null,
}) })
setAccount(null) setAccount(null)
}) })
@@ -2102,7 +2484,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
@@ -2115,6 +2502,21 @@ export function App() {
navigate={navigate} navigate={navigate}
onAuthed={setAccount} onAuthed={setAccount}
/> />
) : path === '/confirm-email' ? (
// Where the email-change confirmation's link lands (`?code=…`). Wants the
// account's own session — the code approves the change, the session says whose —
// so a signed-out visitor is sent through sign-in and back.
<ConfirmEmailPage
account={account}
search={search}
navigate={navigate}
onChange={setAccount}
/>
) : path === '/reset-password' ? (
// Where the recovery email's link lands (`?email=…&code=…`), and where "Forgot
// your password?" on the sign-in page goes. Needs no session — the emailed code
// is the credential — and a client-side route like the rest.
<ResetPasswordPage search={search} navigate={navigate} />
) : path === '/device' ? ( ) : path === '/device' ? (
// Studio opens this from verification_uri_complete. The code was minted by // Studio opens this from verification_uri_complete. The code was minted by
// auth; this page only approves it with the website session. // auth; this page only approves it with the website session.
@@ -2175,6 +2577,7 @@ export function App() {
)} )}
<SiteFooter /> <SiteFooter />
<HalloweenOverlay /> <HalloweenOverlay />
<PumpleBossFight />
</> </>
) )
} }
@@ -2222,7 +2625,6 @@ function NavBar({
RecFlare RecFlare
</Link> </Link>
<nav className="nav-links"> <nav className="nav-links">
{SHOW_PLAYER_SEARCH && <PlayerSearch navigate={navigate} />}
<a href={DISCORD_INVITE} target="_blank" rel="noreferrer"> <a href={DISCORD_INVITE} target="_blank" rel="noreferrer">
Discord Discord
</a> </a>
@@ -2267,6 +2669,7 @@ function NavBar({
Sign in Sign in
</Link> </Link>
)} )}
{SHOW_SEARCH && <SiteSearch navigate={navigate} />}
</nav> </nav>
</header> </header>
) )
@@ -2482,7 +2885,7 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
return ( return (
<section className="about"> <section className="about">
<div> <div>
<h2 className="about-title">A cloud architected server for the 2023/2025 game clients</h2> <h2 className="about-title">A cloud architected server for the 2023-2026 game clients</h2>
<p className="about-lede"> <p className="about-lede">
A free fan project, made by players who missed it. Aiming to be{' '} A free fan project, made by players who missed it. Aiming to be{' '}
<strong>feature-complete</strong> and infinitely scalable —{' '} <strong>feature-complete</strong> and infinitely scalable —{' '}
@@ -2563,6 +2966,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 +3013,30 @@ 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} />
<p className="muted swap">
<Link to="/reset-password" navigate={navigate}>
Forgot your password?
</Link>
</p>
{/* 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 +3055,380 @@ function LoginPage({
) )
} }
/**
* `/confirm-email` — where the email-change emails' links land, with the code in the query.
* The code approves a step and the account's own session says whose, so this wants a
* sign-in: a signed-out visitor goes through `/login` and comes back here. The account
* page's email card does the same thing without the link; this page exists so the emails
* have somewhere to point. Both steps' emails point here: after the current address
* approves, the page waits for the new address's code.
*/
function ConfirmEmailPage({
account,
search,
navigate,
onChange,
}: {
account: SelfAccount | null | undefined
search: string
navigate: Navigate
onChange: (a: SelfAccount) => void
}) {
const [code, setCode] = useState(new URLSearchParams(search).get('code') ?? '')
const [pending, setPending] = useState<PendingEmail | null | undefined>(undefined)
const [approved, setApproved] = useState('')
const { pending: busy, error, done, run } = useAction()
useEffect(() => {
if (account === null) navigate(`/login?next=${encodeURIComponent(`/confirm-email${search}`)}`)
}, [account, navigate, search])
useEffect(() => {
if (!account) return
fetchPendingEmail()
.then((p) => setPending(p.pending === null ? null : p))
.catch(() => setPending(null))
}, [account])
if (!account) return null
return (
<main className="shell">
<section className="card">
<h2>Confirm your email change</h2>
{done ? (
<>
<p className="ok">{done}</p>
<p className="muted swap">
<Link to="/account" navigate={navigate}>
Back to your account
</Link>
</p>
</>
) : pending === null ? (
<>
<p className="muted">
No email change is waiting on <strong>@{account.username}</strong>. It may already be
confirmed, or the code may have lapsed or been used — save the address again from your
account page for a new one.
</p>
<p className="muted swap">
<Link to="/account" navigate={navigate}>
Go to your account
</Link>
</p>
</>
) : (
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const next = await confirmEmail(code.trim())
if (!next.applied) {
// Step 1 done; the second code's email links back here too. Not
// `done`: the page stays on the form for that code.
setPending(next)
setCode('')
setApproved(
`Approved. A second code was emailed to ${next.sentTo} — enter it here, or follow that email's link.`
)
return ''
}
const applied = pending?.pending ?? ''
onChange({ ...account, email: applied })
return `Your account's email is now ${applied}.`
})
}}
>
{pending && (
<p className="muted">
Changing <strong>@{account.username}</strong>&apos;s email to{' '}
<strong>{pending.pending}</strong>. Enter the code sent to{' '}
<strong>{pending.sentTo}</strong>. It works once: a wrong code voids the change.
{pending.sentTo !== pending.pending &&
' Once approved, a second code goes to the new address.'}
</p>
)}
{approved && <p className="ok">{approved}</p>}
<label>
Code
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
value={code}
autoComplete="one-time-code"
onChange={(e) => setCode(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={busy || pending === undefined}>
{busy ? 'Confirming…' : 'Confirm'}
</button>
</form>
)}
</section>
</main>
)
}
/**
* `/reset-password` — password recovery, both halves. With no code in the query it asks for
* the email address on the account and has `auth` send the code; with one (the email's link
* carries both the address and the code) it asks for the new password and posts the three
* together. Whoever typed the code by hand, or asked for the email here and then read it,
* switches to the second form with the link under the first.
*
* The code is spent by ONE attempt, right or wrong — `auth` voids the recovery on a wrong
* code so it can't be guessed — so a refusal here points back at requesting a new email
* rather than inviting a retry of the code.
*/
function ResetPasswordPage({ search, navigate }: { search: string; navigate: Navigate }) {
const params = new URLSearchParams(search)
const [email, setEmail] = useState(params.get('email') ?? '')
const [code, setCode] = useState(params.get('code') ?? '')
const [newPassword, setNewPassword] = useState('')
// The link arrives with a code; a visitor from the sign-in page doesn't have one yet.
const [haveCode, setHaveCode] = useState(params.get('code') !== null)
const request = useAction()
const finish = useAction()
return (
<main className="shell">
<section className="card">
{haveCode ? (
<>
<h2>Choose a new password</h2>
<p className="muted">
Enter the six-digit code from the email and the password you want. The code works
once, for 30 minutes.
</p>
{finish.done ? (
<>
<p className="ok">{finish.done}</p>
<p className="muted swap">
<Link to="/login" navigate={navigate}>
Sign in
</Link>
</p>
</>
) : (
<form
onSubmit={(e) => {
e.preventDefault()
void finish.run(async () => {
await resetPassword(email.trim(), code.trim(), newPassword)
return 'Your password has been changed. Sign in with it here or in the game.'
})
}}
>
<label>
Email address
<input
type="email"
value={email}
autoComplete="email"
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Code
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
value={code}
autoComplete="one-time-code"
onChange={(e) => setCode(e.target.value)}
required
/>
</label>
<label>
New password
<input
type="password"
value={newPassword}
autoComplete="new-password"
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</label>
{finish.error && <p className="error">{finish.error}</p>}
<button type="submit" disabled={finish.pending}>
{finish.pending ? 'Saving…' : 'Set password'}
</button>
</form>
)}
{!finish.done && (
<p className="muted swap">
No code, or it stopped working?{' '}
<a
href="/reset-password"
onClick={(e) => {
e.preventDefault()
setHaveCode(false)
}}
>
Request a new email
</a>
</p>
)}
</>
) : (
<>
<h2>Reset your password</h2>
<p className="muted">
Enter the email address on your account. If there is an account under it, we&apos;ll
email you a six-digit code and a link to come back here.
</p>
<form
onSubmit={(e) => {
e.preventDefault()
void request.run(async () => {
await recoverPassword(email.trim())
return "If that address is on an account, an email is on its way. Check your inbox (and spam folder), then enter the code here or follow the email's link."
})
}}
>
<label>
Email address
<input
type="email"
value={email}
autoComplete="email"
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
{request.error && <p className="error">{request.error}</p>}
{request.done && <p className="ok">{request.done}</p>}
<button type="submit" disabled={request.pending}>
{request.pending ? 'Sending…' : 'Email me a code'}
</button>
</form>
<p className="muted swap">
Already have a code?{' '}
<a
href="/reset-password"
onClick={(e) => {
e.preventDefault()
setHaveCode(true)
}}
>
Enter it
</a>
</p>
</>
)}
</section>
</main>
)
}
/**
* `/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,
@@ -2838,7 +3640,7 @@ function PublicRoomView({
{creator && ( {creator && (
<p className="room-hero-by"> <p className="room-hero-by">
by{' '} by{' '}
<Link to={`/u/${encodeURIComponent(creator.username)}`} navigate={navigate}> <Link to={`/user/${encodeURIComponent(creator.username)}`} navigate={navigate}>
{creator.displayName || creator.username} {creator.displayName || creator.username}
</Link> </Link>
</p> </p>
@@ -2889,6 +3691,7 @@ function PublicRoomView({
<> <>
{isDeveloper() && <StaffRoomTokens room={room} />} {isDeveloper() && <StaffRoomTokens room={room} />}
<StaffListing room={room} onRoomChange={onRoomChange} /> <StaffListing room={room} onRoomChange={onRoomChange} />
<StaffClearInstances room={room} />
{isDeveloper() && <StaffTakedown room={room} onTakenDown={onTakenDown} />} {isDeveloper() && <StaffTakedown room={room} onTakenDown={onTakenDown} />}
</> </>
)} )}
@@ -3027,6 +3830,64 @@ function StaffListing({
) )
} }
/**
* Delete every instance of the room, for one whose sessions are stuck. Two steps, like the
* dorm clear: nothing is lost for good, but anyone inside is cut off from their session.
*/
function StaffClearInstances({ room }: { room: OwnedRoom }) {
const [confirming, setConfirming] = useState(false)
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Instances</h2>
<p className="hint">
Deletes every instance of this room, including ones with players in them. The next player to
join gets a fresh instance.
</p>
<div className="mod-filter-actions">
{confirming ? (
<>
<button
type="submit"
disabled={pending}
onClick={() =>
void run(async () => {
const res = await call<{ deleted: number[] }>(
`/api/staff/rooms/${room.RoomId}/clear-instances`,
{ authed: true, method: 'POST' }
)
setConfirming(false)
const n = res.deleted.length
return n === 0
? `^${room.Name} had no instances.`
: `Cleared ${n} instance${n === 1 ? '' : 's'} of ^${room.Name}.`
})
}
>
{pending ? 'Clearing…' : 'Confirm clear instances'}
</button>
<button
type="button"
className="linkish"
disabled={pending}
onClick={() => setConfirming(false)}
>
Cancel
</button>
</>
) : (
<button type="submit" onClick={() => setConfirming(true)}>
Clear instances
</button>
)}
</div>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
</section>
)
}
function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) { function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) {
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const { pending, error, run } = useAction() const { pending, error, run } = useAction()
@@ -3820,7 +4681,8 @@ function SignupForm({
// Saved with the new session's own token: `create_account` takes no email, // Saved with the new session's own token: `create_account` takes no email,
// `accounts` owns the field. Deliberately not fatal — the account exists and // `accounts` owns the field. Deliberately not fatal — the account exists and
// the session is live, and the same field is one call away on the account // the session is live, and the same field is one call away on the account
// page. // page. The address is STAGED, not set: `accounts` emails it a code, and the
// account page (where this lands) picks the pending change up and asks for it.
if (wanted !== '') await saveEmail(wanted).catch(() => {}) if (wanted !== '') await saveEmail(wanted).catch(() => {})
// The session is already stored, so a failure here isn't one they can act on // The session is already stored, so a failure here isn't one they can act on
@@ -3934,6 +4796,11 @@ function Dashboard({
// settings form they opened the page to avoid. // settings form they opened the page to avoid.
{ id: 'rooms', label: 'My rooms', render: () => <MyRooms navigate={navigate} /> }, { id: 'rooms', label: 'My rooms', render: () => <MyRooms navigate={navigate} /> },
{ id: 'photos', label: 'My photos', render: () => <MyPhotos accountId={account.accountId} /> }, { id: 'photos', label: 'My photos', render: () => <MyPhotos accountId={account.accountId} /> },
{
id: 'banner',
label: 'Profile banner',
render: () => <BannerUpload account={account} onChange={onChange} />,
},
{ {
id: 'username', id: 'username',
label: 'Username', label: 'Username',
@@ -4112,6 +4979,55 @@ function MyPhotos({ accountId }: { accountId: number }) {
) )
} }
/**
* The profile banner, as an {@link ImageUpload} in the profile banner's own shape: upload
* the picture (`uploadBannerImage`), then set it on the account (`setBannerImage`). The
* game only lets a player pick one of their camera photos; this takes any JPEG or PNG.
* The held account is patched with the new key, so the preview redraws without a refetch.
*/
function BannerUpload({
account,
onChange,
}: {
account: SelfAccount
onChange: (a: SelfAccount) => void
}) {
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Profile banner</h2>
<p className="muted">
The wide picture across the top of your profile. Click it to upload a JPEG or PNG.
</p>
<ImageUpload
// No banner yet: a transparent pixel, so the frame shows its gradient backdrop
// (the profile's empty-banner look) rather than a stretched default avatar.
src={
account.bannerImage
? `${where().img}/${account.bannerImage}?width=1024`
: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
}
className="banner-upload"
imgClassName="banner-preview"
hint={account.bannerImage ? 'Change banner' : 'Upload a banner'}
pending={pending}
onFile={(file) =>
void run(async () => {
requireImage(file)
const imageName = await uploadBannerImage(file)
await setBannerImage(imageName)
onChange({ ...account, bannerImage: imageName })
return 'Banner updated.'
})
}
/>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
</section>
)
}
/** /**
* One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's * One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's
* doing. The whole row links to the room's own page. * doing. The whole row links to the room's own page.
@@ -4806,18 +5722,43 @@ function EmailForm({
onChange: (a: SelfAccount) => void onChange: (a: SelfAccount) => void
}) { }) {
const [email, setEmail] = useState(account.email ?? '') const [email, setEmail] = useState(account.email ?? '')
const { pending, error, done, run } = useAction() // The change a code is out for, once `accounts` says so — asked on mount, so a reload
// (or arriving here straight from signup, which saved the address) resumes at the code.
const [pending, setPending] = useState<PendingEmail | null>(null)
const [code, setCode] = useState('')
const save = useAction()
const confirm = useAction()
useEffect(() => {
fetchPendingEmail()
.then((p) => setPending(p.pending === null ? null : p))
.catch(() => {})
}, [])
return ( return (
<section className="card"> <section className="card">
<h2>Email</h2> <h2>Email</h2>
<p className="muted">
Used to recover your password, so changing it takes a code at your current address to
approve it, then one at the new address to confirm it. With no address yet, just the second.
</p>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault()
void run(async () => { void save.run(async () => {
await saveEmail(email.trim()) const wanted = email.trim()
onChange({ ...account, email }) await saveEmail(wanted)
return 'Email saved.' // Re-saving the current address applies at once; anything else is staged
// and `accounts` knows where the code went.
const now = await fetchPendingEmail().catch(() => null)
if (now === null || now.pending === null) {
onChange({ ...account, email: wanted })
setPending(null)
return 'Email saved.'
}
setPending(now)
setCode('')
return `A six-digit code was emailed to ${now.sentTo}. Enter it below.`
}) })
}} }}
> >
@@ -4831,12 +5772,57 @@ function EmailForm({
required required
/> />
</label> </label>
{error && <p className="error">{error}</p>} {save.error && <p className="error">{save.error}</p>}
{done && <p className="ok">{done}</p>} {save.done && <p className="ok">{save.done}</p>}
<button type="submit" disabled={pending}> <button type="submit" disabled={save.pending}>
{pending ? 'Saving…' : 'Save email'} {save.pending ? 'Saving…' : 'Save email'}
</button> </button>
</form> </form>
{pending && (
<form
onSubmit={(e) => {
e.preventDefault()
void confirm.run(async () => {
const next = await confirmEmail(code.trim())
setCode('')
if (!next.applied) {
// Step 1 done: the current address approved. Step 2's code is out.
setPending(next)
return `Approved. A second code was emailed to ${next.sentTo} — enter it to finish.`
}
const applied = pending.pending ?? ''
onChange({ ...account, email: applied })
setEmail(applied)
setPending(null)
return 'Email changed.'
})
}}
>
<p className="muted">
Changing to <strong>{pending.pending}</strong>. Enter the code sent to{' '}
<strong>{pending.sentTo}</strong> — it works once, for 30 minutes. Wrong code, or
mistyped the address? Save the address again for a new one.
</p>
<label>
Code
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
value={code}
autoComplete="one-time-code"
onChange={(e) => setCode(e.target.value)}
required
/>
</label>
{confirm.error && <p className="error">{confirm.error}</p>}
{confirm.done && <p className="ok">{confirm.done}</p>}
<button type="submit" disabled={confirm.pending}>
{confirm.pending ? 'Confirming…' : 'Confirm'}
</button>
</form>
)}
{!pending && confirm.done && <p className="ok">{confirm.done}</p>}
</section> </section>
) )
} }
+4 -4
View File
@@ -216,10 +216,10 @@ export function ItemPage({
<p className="room-hero-by"> <p className="room-hero-by">
by{' '} by{' '}
<a <a
href={`/u/${encodeURIComponent(creator.username)}`} href={`/user/${encodeURIComponent(creator.username)}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
navigate(`/u/${encodeURIComponent(creator.username)}`) navigate(`/user/${encodeURIComponent(creator.username)}`)
}} }}
> >
{creator.displayName || creator.username} {creator.displayName || creator.username}
@@ -379,10 +379,10 @@ export function InventionPage({
<p className="room-hero-by"> <p className="room-hero-by">
by{' '} by{' '}
<a <a
href={`/u/${encodeURIComponent(creator.username)}`} href={`/user/${encodeURIComponent(creator.username)}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
navigate(`/u/${encodeURIComponent(creator.username)}`) navigate(`/user/${encodeURIComponent(creator.username)}`)
}} }}
> >
{creator.displayName || creator.username} {creator.displayName || creator.username}
+1 -1
View File
@@ -369,7 +369,7 @@ function BanSummary({
} }
/** /**
* One line for a player's profile page (`/u/<username>`): whether a ban blocks them right * One line for a player's profile page (`/user/<username>`): whether a ban blocks them right
* now — their own or a linked account's — and a link to their full history here. * now — their own or a linked account's — and a link to their full history here.
*/ */
export function PlayerBanStatus({ export function PlayerBanStatus({
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react'
type Mob = { id: number; x: number; y: number; hp: number }
type Shot = { id: number; x: number; y: number; vx: number; vy: number; bomb?: boolean }
type Game = {
player: number
hp: number
bossHp: number
mobs: Mob[]
enemyShots: Shot[]
arrows: Shot[]
bombs: number
kills: number
waves: number
nextId: number
lastSpawn: number
lastAttack: number
phase: 'fight' | 'victory' | 'defeat'
}
const freshGame = (): Game => ({
player: (typeof window !== 'undefined' ? window.innerWidth : 1024) / 2, hp: 100, bossHp: 100,
mobs: [], enemyShots: [], arrows: [], bombs: 0, kills: 0,
waves: 1, nextId: 1, lastSpawn: 0, lastAttack: 0, phase: 'fight',
})
const css = `
.rf-boss{position:fixed;inset:0;z-index:100010;overflow:hidden;background:radial-gradient(ellipse at 50% 34%,#542619 0%,#26121a 43%,#090810 100%);color:#fff0d5;font-family:system-ui,sans-serif;cursor:crosshair;user-select:none}
.rf-boss:before{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,#08060a66,transparent 35%,#05050988);z-index:0}
.rf-boss:after{content:"";position:absolute;inset:0;pointer-events:none;opacity:.22;background-image:radial-gradient(#ffb35b 1px,transparent 1px);background-size:37px 37px;mask-image:linear-gradient(transparent,#000 30%,#000)}
.rf-boss *{box-sizing:border-box}
.rf-boss-hud{position:absolute;z-index:8;top:18px;left:50%;transform:translateX(-50%);width:min(900px,82%);display:grid;grid-template-columns:1.2fr 1fr;gap:22px;padding:16px 20px;border:1px solid #e9843c88;border-radius:15px;background:linear-gradient(135deg,#160d12eF,#2a1117eF);box-shadow:0 10px 45px #0009,inset 0 1px #ffd2a322;backdrop-filter:blur(8px)}
.rf-boss-label{font-weight:950;letter-spacing:2px;font-size:11px;text-transform:uppercase;margin-bottom:8px;color:#ffddb2}
.rf-boss-track{height:17px;border:1px solid #ffd9aa88;background:#100a10;border-radius:5px;overflow:hidden;box-shadow:inset 0 2px 5px #000a}
.rf-boss-fill{height:100%;background:linear-gradient(90deg,#a82420,#ff6036,#ffb05b);box-shadow:0 0 15px #ff552a99;transition:width .12s}
.rf-boss-player .rf-boss-fill{background:linear-gradient(90deg,#138a65,#49e7a0);box-shadow:0 0 14px #3df0a088}
.rf-boss-monster{position:absolute;z-index:4;top:16%;left:50%;width:min(43vw,390px);height:min(43vw,390px);min-width:230px;min-height:230px;transform:translateX(-50%);display:grid;place-items:center;filter:drop-shadow(0 0 18px #ff5b24aa);animation:rf-boss-hover 3s ease-in-out infinite}
.rf-boss-monster:before{content:"";position:absolute;width:90%;height:35%;bottom:0;border-radius:50%;background:#ff5a1726;filter:blur(25px)}
.rf-boss-monster img{position:relative;width:100%;height:100%;object-fit:contain;image-rendering:pixelated;filter:drop-shadow(0 12px 0 #150a10) drop-shadow(0 0 16px #ff6b35aa)}
.rf-boss-minion{position:absolute;z-index:4;transform:translate(-50%,-50%);width:clamp(44px,7vw,78px);height:clamp(44px,7vw,78px);filter:drop-shadow(0 0 10px #ff4b2588);pointer-events:none}
.rf-boss-minion img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated}
.rf-boss-shot{position:absolute;z-index:5;transform:translate(-50%,-50%);font-size:25px;pointer-events:none;filter:drop-shadow(0 0 9px #ff712f);animation:rf-shot-glow .5s ease-in-out infinite alternate}
.rf-boss-player-char{position:absolute;z-index:5;bottom:7%;font-size:42px;transform:translateX(-50%);width:64px;height:64px;display:grid;place-items:center;border-radius:50%;background:radial-gradient(circle,#77eaff35,transparent 70%);filter:drop-shadow(0 0 10px #7ad9ff);pointer-events:none}
.rf-boss-hint{position:absolute;z-index:8;bottom:14px;left:50%;transform:translateX(-50%);width:max-content;max-width:88%;text-align:center;font-size:11px;font-weight:850;letter-spacing:1px;color:#ffe3c1;padding:12px 18px;border:1px solid #b9684777;border-radius:9px;background:#100b13dc;box-shadow:0 4px 25px #0008}
.rf-boss-vines{position:absolute;z-index:6;inset:0;pointer-events:none;overflow:hidden;opacity:1}
.rf-boss-vines svg{position:absolute;inset:-2%;width:104%;height:104%;overflow:visible}
.rf-boss-vines .vine-main{fill:none;stroke:#172f1b;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;filter:drop-shadow(0 0 5px #050807)}
.rf-boss-vines .vine-edge{fill:none;stroke:#426a2d;stroke-width:12;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:25 8;animation:rf-vine-crawl 7s linear infinite}
.rf-boss-vines .vine-glow{fill:none;stroke:#df722a;stroke-width:3;stroke-linecap:round;stroke-dasharray:3 28;animation:rf-vine-crawl 4s linear infinite}
.rf-boss-vines .thorn{fill:#72913b;stroke:#18291a;stroke-width:3}
.rf-boss-vines .root{fill:none;stroke:#253d20;stroke-width:39;stroke-linecap:round;opacity:.92}
.rf-boss-vines-retreat{animation:rf-vine-retreat 2.8s ease-in forwards}
.rf-boss-end{position:absolute;z-index:10;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;background:linear-gradient(180deg,#08090bea,#130d0bea);padding:24px}
.rf-boss-panel{max-width:560px;width:100%;padding:clamp(26px,5vw,44px);border:2px solid #9b542d;border-radius:4px;background:#171310;box-shadow:8px 8px 0 #050505, inset 0 0 0 4px #171310, inset 0 0 0 5px #443023;color:#eee2ce}
.rf-boss-title{font-family:Impact,'Arial Narrow',system-ui,sans-serif;font-size:clamp(34px,6.5vw,60px);font-weight:900;line-height:1.05;letter-spacing:1px;text-transform:uppercase;color:#f0a04d;text-shadow:3px 3px 0 #522719;margin:0 0 20px}
.rf-boss-button{margin:10px 6px 0;border:2px solid #c47b43;border-radius:3px;padding:12px 20px;background:#30251b;color:#ffe5c3;font-family:inherit;font-size:13px;font-weight:800;letter-spacing:1px;text-transform:uppercase;cursor:pointer;box-shadow:0 3px 0 #090807;transition:background .12s,transform .12s}
.rf-boss-button:hover{background:#513522;transform:translateY(-2px)}
@keyframes rf-boss-hover{0%,100%{translate:0 0}50%{translate:0 -8px}}
@keyframes rf-shot-glow{from{scale:.9}to{scale:1.12}}
@keyframes rf-vine-crawl{to{stroke-dashoffset:-132}}
@keyframes rf-vine-retreat{to{transform:scale(1.4);opacity:0}}
@media(max-width:600px){.rf-boss-hud{top:10px;width:86%;padding:12px;gap:12px}.rf-boss-label{font-size:9px;letter-spacing:1px}.rf-boss-track{height:13px}.rf-boss-monster{top:23%;width:65vw;height:65vw;min-width:0;min-height:0}.rf-boss-hint{font-size:9px;letter-spacing:.3px;padding:10px;bottom:8px}}
@media(prefers-reduced-motion:reduce){.rf-boss-monster,.rf-boss-shot,.rf-boss-vines .vine-edge,.rf-boss-vines .vine-glow{animation:none}}
`
export function PumpleBossFight() {
const [active, setActive] = useState(false)
const [view, setView] = useState<Game>(freshGame)
const game = useRef<Game>(freshGame())
const mouse = useRef({ x: typeof window !== 'undefined' ? window.innerWidth / 2 : 512, y: typeof window !== 'undefined' ? window.innerHeight * .78 : 600 })
const aim = useRef({ x: typeof window !== 'undefined' ? window.innerWidth / 2 : 512, y: typeof window !== 'undefined' ? window.innerHeight * .3 : 200 })
const started = useRef(false)
useEffect(() => {
const start = () => {
if (started.current) return
started.current = true
game.current = freshGame()
mouse.current = { x: innerWidth / 2, y: innerHeight * .78 }
setView({ ...game.current })
setActive(true)
}
window.addEventListener('rf-pumple-boss', start)
return () => window.removeEventListener('rf-pumple-boss', start)
}, [])
useEffect(() => {
if (!active) return
const g = game.current
let last = performance.now()
let frame = 0
let endTimer = 0
const tick = () => {
const now = performance.now()
const dt = Math.min(.05, (now - last) / 1000)
last = now
if (g.phase === 'fight') {
g.player = Math.max(28, Math.min(innerWidth - 28, mouse.current.x))
const difficulty = 1 + (g.waves - 1) * .18
if (now - g.lastSpawn > Math.max(900, 3000 / difficulty) && g.mobs.length < 5 + g.waves) {
g.lastSpawn = now
g.mobs.push({ id: g.nextId++, x: Math.random() * (innerWidth - 80) + 40, y: innerHeight * (.35 + Math.random() * .28), hp: 2 })
}
if (now - g.lastAttack > Math.max(450, 1400 / difficulty)) {
g.lastAttack = now
const sx = innerWidth / 2, sy = innerHeight * .29
const dx = g.player - sx, dy = innerHeight * .78 - sy
const d = Math.hypot(dx, dy) || 1
g.enemyShots.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 280 * difficulty, vy: dy / d * 280 * difficulty })
if (g.waves >= 2) {
g.enemyShots.push({ id: g.nextId++, x: sx, y: sy, vx: -dx / d * 240 * difficulty, vy: 270 * difficulty })
}
}
for (const m of g.mobs) {
const dx = g.player - m.x, dy = innerHeight * .78 - m.y, d = Math.hypot(dx, dy) || 1
m.x += dx / d * 75 * difficulty * dt
m.y += dy / d * 75 * difficulty * dt
if (d < 35) { g.hp -= 18 * dt; m.hp = 0 }
}
for (const s of g.enemyShots) { s.x += s.vx * dt; s.y += s.vy * dt }
g.enemyShots = g.enemyShots.filter(s => {
if (Math.hypot(s.x - g.player, s.y - innerHeight * .78) < 32) { g.hp -= 12; return false }
return s.x > -50 && s.x < innerWidth + 50 && s.y < innerHeight + 50
})
for (const s of g.arrows) {
s.x += s.vx * dt; s.y += s.vy * dt
if (s.bomb) {
let detonated = false
for (const m of g.mobs) {
if (m.hp > 0 && Math.hypot(s.x - m.x, s.y - m.y) < 65) {
m.hp = 0
detonated = true
}
}
if (Math.hypot(s.x - innerWidth / 2, s.y - innerHeight * .29) < 115) {
g.bossHp -= 14
detonated = true
}
if (detonated) s.x = -999
} else {
let hit = false
for (const m of g.mobs) {
if (m.hp > 0 && Math.hypot(s.x - m.x, s.y - m.y) < 28) {
m.hp = 0
hit = true
break
}
}
if (!hit && Math.hypot(s.x - innerWidth / 2, s.y - innerHeight * .29) < 90) {
g.bossHp -= 2
hit = true
}
if (hit) s.x = -999
}
}
g.arrows = g.arrows.filter(s => s.x > -100 && s.x < innerWidth + 100 && s.y > -100 && s.y < innerHeight + 100)
// Count defeated minions before removing them; some kills drop bombs.
const dead = g.mobs.filter(m => m.hp <= 0)
if (dead.length) {
g.kills += dead.length
for (let i = 0; i < dead.length; i++) {
if (Math.random() < .38) g.bombs++
}
}
g.mobs = g.mobs.filter(m => m.hp > 0)
if (g.bossHp <= 0) g.phase = 'victory'
else if (g.hp <= 0) g.phase = 'defeat'
if (g.kills >= g.waves * 5) g.waves = Math.min(8, Math.floor(g.kills / 5) + 1)
}
setView({ ...g, mobs: [...g.mobs], enemyShots: [...g.enemyShots], arrows: [...g.arrows] })
if (g.phase !== 'fight') {
window.clearInterval(frame)
endTimer = window.setTimeout(() => {
if (g.phase === 'defeat') window.location.reload()
}, 6500)
}
}
frame = window.setInterval(tick, 50) as unknown as number
return () => { window.clearInterval(frame); window.clearTimeout(endTimer) }
}, [active])
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
mouse.current = { x: e.clientX, y: e.clientY }
aim.current = { x: e.clientX, y: e.clientY }
}
const fire = (e: React.MouseEvent<HTMLDivElement>) => {
if (view.phase !== 'fight') return
const g = game.current
const sx = g.player, sy = innerHeight * .78
const dx = e.clientX - sx, dy = e.clientY - sy, d = Math.hypot(dx, dy) || 1
g.arrows.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 620, vy: dy / d * 620 })
}
const bomb = () => {
const g = game.current
if (!active || g.phase !== 'fight' || g.bombs <= 0) return
g.bombs--
const sx = g.player, sy = innerHeight * .78
const dx = aim.current.x - sx, dy = aim.current.y - sy, d = Math.hypot(dx, dy) || 1
g.arrows.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 350, vy: dy / d * 350, bomb: true })
setView({ ...g, arrows: [...g.arrows] })
}
useEffect(() => {
if (!active) return
const key = (e: KeyboardEvent) => { if (e.code === 'Space') { e.preventDefault(); bomb() } }
window.addEventListener('keydown', key)
return () => window.removeEventListener('keydown', key)
}, [active])
if (!active) return null
const ended = view.phase !== 'fight'
return <div className="rf-boss" onMouseMove={onMove} onClick={fire} onContextMenu={e => e.preventDefault()}>
<style>{css}</style>
<div className="rf-boss-hud">
<div style={{ flex: 1 }}><div className="rf-boss-label">YOUR HEALTH {Math.max(0, Math.ceil(view.hp))}/100</div><div className="rf-boss-track rf-boss-player"><div className="rf-boss-fill" style={{ width: `${Math.max(0, view.hp)}%` }} /></div></div>
<div style={{ flex: 1 }}><div className="rf-boss-label">PUMPLE {Math.max(0, Math.ceil(view.bossHp))}%</div><div className="rf-boss-track"><div className="rf-boss-fill" style={{ width: `${Math.max(0, view.bossHp)}%` }} /></div></div>
</div>
<div className="rf-boss-monster">
<img src={new URL('./pumple-frames/frame-04.png', import.meta.url).href} alt="Pumple, the pumpkin king" draggable={false} />
</div>
{view.mobs.map(m => <div key={m.id} className="rf-boss-minion" style={{ left: m.x, top: m.y }}>
<img src={new URL('./Pumple_monster_walk.gif', import.meta.url).href} alt="" draggable={false} />
</div>)}
{view.enemyShots.map(s => <div key={s.id} className="rf-boss-shot" style={{ left: s.x, top: s.y }}>🔥</div>)}
{view.arrows.map(s => <div key={s.id} className="rf-boss-shot" style={{ left: s.x, top: s.y }}>{s.bomb ? '💣' : '➶'}</div>)}
<div className="rf-boss-player-char" style={{ left: view.player }}>🛡️</div>
<div className="rf-boss-hint">MOVE YOUR MOUSE TO DODGE · CLICK TO SHOOT ARROWS · SPACE: PUMPKIN BOMB ({view.bombs}) · WAVE {view.waves}</div>
<div className={`rf-boss-vines ${view.phase === 'victory' ? 'rf-boss-vines-retreat' : ''}`}>
<svg viewBox="0 0 1200 800" preserveAspectRatio="none" aria-hidden="true">
<path className="root" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760"/>
<path className="vine-main" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760 M-20 300 Q100 250 160 330 T250 390 M1220 450 Q1100 390 1050 460 T960 510"/>
<path className="vine-edge" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760 M-20 300 Q100 250 160 330 T250 390 M1220 450 Q1100 390 1050 460 T960 510"/>
<path className="vine-glow" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760"/>
<g className="thorn">
<path d="M54 180 l-35 -18 27 43z M94 305 l-36 -8 30 31z M81 500 l-33 -18 23 39z M1140 200 l34 -24 -24 48z M1100 360 l37 -12 -29 36z M1134 545 l36 -19 -23 43z M190 760 l-5 -40 27 31z M1010 760 l6 -40 -28 31z M180 345 l-25 -28 7 40z M1035 455 l24 -28 -7 40z"/>
</g>
</svg>
</div>
{ended && <><div className="rf-boss-end"><div className="rf-boss-panel">
<h1 className="rf-boss-title">{view.phase === 'victory' ? 'PUMPLE DEFEATED!' : 'RECFLARE HAS FALLEN'}</h1>
<p>{view.phase === 'victory' ? 'The pumpkin king crumbles into embers. The vines retreat and RecFlare is saved!' : 'The vines have consumed the battlefield. Pumple claims RecFlare...'}</p>
{view.phase === 'victory' && <p>Waves reached: {view.waves} · Minions defeated: {view.kills} · Bombs remaining: {view.bombs}</p>}
{view.phase === 'victory' && <button className="rf-boss-button" onClick={e => { e.stopPropagation(); setActive(false); started.current = false }}>RETURN TO RECFLARE</button>}
{view.phase === 'defeat' && <p>The site will reset after the vines take over...</p>}
</div></div></>}
</div>
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

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

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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