Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad2b84b358 | |||
| 619b4c772c | |||
| f6214fb824 | |||
| 44704fec51 | |||
| fc6cf31591 | |||
| 4709c390fd | |||
| eeb652fef7 | |||
| d819bdbe3d | |||
| 207138ef97 | |||
| f2c16f198a | |||
| d14d96f489 | |||
| 95538e89ae | |||
| 5f34028267 | |||
| b02a646892 | |||
| 8397997234 | |||
| 4e2cdd87eb | |||
| d1e69bfd14 | |||
| 09e40af6b7 | |||
| f9600c1836 | |||
| 3da35a8692 |
@@ -77,6 +77,13 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# Turning it on also needs the Turnstile keypair in the Secrets Store (see www).
|
||||
# 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
|
||||
# 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
|
||||
@@ -227,9 +234,12 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000
|
||||
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
|
||||
#
|
||||
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer
|
||||
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT
|
||||
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
|
||||
# Also add https://<your domain>/claim AND https://<your domain>/login/discord to the app's
|
||||
# Redirects in the Discord developer portal, or the exchange is refused. The second is the
|
||||
# login page's "Sign in with Discord" (same switch): it signs into the account the Discord is
|
||||
# linked to, or CREATES one — the player then sets a password on the website to sign in to
|
||||
# the game, including on a Quest with no Meta account. Granting Plus takes effect on the
|
||||
# player's NEXT sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
|
||||
#
|
||||
# OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a
|
||||
# bot token, so `platform_account.role` tracks the guild instead of the day they claimed.
|
||||
@@ -276,6 +286,18 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# link Discord at /claim, and come back.
|
||||
# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002
|
||||
|
||||
# OPTIONAL: a staged rollout of Rec Room Studio to Discord roles (`auth`, `studio`, `www`).
|
||||
# The Discord ROLE ids whose holders get Studio upload access — `auth` stamps the
|
||||
# `betastudio` role into their token exactly as it does for an account staff flagged
|
||||
# `hasStudio` on the website, and `studio`'s access lookups and the website's Studio
|
||||
# settings page say "granted" for them too. The flag keeps working beside this, so staff
|
||||
# can still add individuals. Same rules as ROLE_IDS above: snowflakes, not names; commas, no
|
||||
# spaces; ANY one qualifies; roles come from the snapshot the benefits claim and the daily
|
||||
# sweep keep, so a newly granted role counts once the sweep runs (or the player re-claims),
|
||||
# and the player's Studio token picks it up on their next sign-in or refresh. UNSET — or
|
||||
# parsing to no roles — grants nobody: the flag alone decides.
|
||||
# RECFLARE_STUDIO_ROLLOUT=1077000000000000001,1077000000000000002
|
||||
|
||||
# OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the
|
||||
# RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
|
||||
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
|
||||
@@ -289,3 +311,24 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
|
||||
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
|
||||
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
|
||||
|
||||
# Rec Room Studio (`studio`). Cloud builds are configured OUTSIDE this file: the credential
|
||||
# the build host (recflare/cloud-building, its STUDIO_WORKER_KEY) presents to claim jobs
|
||||
# lives in the shared Secrets Store, like JWT_SECRET above — not as a var, since every var
|
||||
# here reaches every worker in plaintext. The binding has to resolve for `studio` to
|
||||
# deploy, so create it even if you run no build host: the literal `off` disables cloud
|
||||
# builds (the editor is told "Cloud building is not configured").
|
||||
#
|
||||
# wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
|
||||
# --scopes workers --remote
|
||||
#
|
||||
# The knobs ARE vars. TARGETS is the Unity build targets a job is built for (0 Windows,
|
||||
# 2 Android), commas, no spaces; the build host needs the matching editor modules.
|
||||
# TEMPLATE_VERSION is the Studio template a job is built against, and the build host
|
||||
# claims ONLY jobs for its own configured version (171 by default there, 169 here), so
|
||||
# a mismatch leaves jobs queued forever with no error on either side — set this to the
|
||||
# template the host has installed. DISTRIBUTION_MANIFEST is the R2 key of the template
|
||||
# catalog the editor downloads from; unset disables downloads (see apps/studio/README.md).
|
||||
# RECFLARE_CLOUD_BUILD_TARGETS=0,2
|
||||
# RECFLARE_CLOUD_BUILD_TEMPLATE_VERSION=169
|
||||
# RECFLARE_STUDIO_DISTRIBUTION_MANIFEST=roomunityasset/studio-distribution/catalog-v171.json
|
||||
|
||||
@@ -312,6 +312,18 @@ inconsistency here without checking the client first.
|
||||
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
|
||||
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
|
||||
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
||||
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
||||
|
||||
@@ -56,7 +56,8 @@ bun runx admin grant-moderator --username alice --remote
|
||||
Sets the account's `hasStudio` flag, which `auth` stamps into the token as the
|
||||
`betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
|
||||
normally manage this from the website (`/settings/recroomstudio`); this is the
|
||||
operator's way in. Takes effect on the player's next Studio sign-in or refresh.
|
||||
operator's way in. Takes effect on the player's next Studio sign-in or refresh. To open
|
||||
Studio to a whole Discord role instead, name it in `RECFLARE_STUDIO_ROLLOUT` (`.env`).
|
||||
|
||||
```sh
|
||||
bun runx admin grant-studio --username alice
|
||||
|
||||
@@ -140,6 +140,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
|
||||
> 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!
|
||||
|
||||
```bash
|
||||
|
||||
@@ -20,7 +20,9 @@ worker; this worker binds it read/write.
|
||||
| GET | `/accountprivacysettings/:id` | | An account's privacy settings |
|
||||
| PUT | `/account/me/displayname` | ✓ | Set display name |
|
||||
| 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 |
|
||||
| PUT | `/account/me/identityflags` | ✓ | Set identity flags bitmask |
|
||||
| PUT | `/account/me/personalpronouns` | ✓ | Set personal pronouns (posted as `pronounFlags`) |
|
||||
|
||||
@@ -3,12 +3,20 @@ import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
beginEmailCode,
|
||||
cancelEmailCode,
|
||||
completeEmailCode,
|
||||
createAccount,
|
||||
DEFAULT_USERNAME_CHANGES,
|
||||
defaultAccount,
|
||||
EMAIL_CODE_TTL_SECONDS,
|
||||
emailSender,
|
||||
getAccount,
|
||||
getAccountByEmail,
|
||||
getAccountByUsername,
|
||||
getAccountsByIds,
|
||||
pendingEmailCode,
|
||||
SEARCH_LIMIT,
|
||||
searchAccounts,
|
||||
updateAccount,
|
||||
} 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
|
||||
// value — the enum has no runtime dependencies.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import { buildEndpoints } from '../../ns/src/endpoints'
|
||||
import { emailChangeEmail } from './email-change-email'
|
||||
import {
|
||||
AccountDto,
|
||||
BannerImageRequest,
|
||||
@@ -33,6 +43,9 @@ import {
|
||||
CreateAccountRequest,
|
||||
CreateAccountResult,
|
||||
DisplayNameRequest,
|
||||
EmailConfirmRequest,
|
||||
EmailConfirmResponse,
|
||||
EmailRefusal,
|
||||
EmailRequest,
|
||||
EmojiRequest,
|
||||
form,
|
||||
@@ -40,6 +53,7 @@ import {
|
||||
IdentityFlagsRequest,
|
||||
json,
|
||||
ParentalControl,
|
||||
PendingEmailResponse,
|
||||
PhoneRequest,
|
||||
PrivacySettings,
|
||||
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. */
|
||||
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. */
|
||||
const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
@@ -337,16 +436,61 @@ const app = new Hono<App>()
|
||||
description: 'Username prefix; empty matches nothing meaningful',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'take',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: `How many to return (default and max ${SEARCH_LIMIT}); fewer reads fewer rows`,
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(AccountDto.array(), 'Matching public accounts') },
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.query('name') ?? ''
|
||||
const accounts = await searchAccounts(c.env.DB, name)
|
||||
// The seek stops at the LIMIT, so a caller that draws five (the site's header
|
||||
// search) is billed five rows rather than the default twenty.
|
||||
const take = Number.parseInt(c.req.query('take') ?? '', 10)
|
||||
const limit = Number.isNaN(take) ? SEARCH_LIMIT : Math.max(1, Math.min(take, SEARCH_LIMIT))
|
||||
const accounts = await searchAccounts(c.env.DB, name, limit)
|
||||
return c.json(accounts.map(toAccountDto))
|
||||
}
|
||||
)
|
||||
|
||||
// Exact lookup by username — what the site's `/user/<name>` profile pages and the staff
|
||||
// `@username` fields resolve a player through. An indexed equality on `username_lower`;
|
||||
// before this existed they went through the prefix search above and filtered the
|
||||
// exact hit client-side, which cost a 20-row index walk per profile view. Same path
|
||||
// as the official API's `GET /account/username/{username}`.
|
||||
.get(
|
||||
'/account/username/:name',
|
||||
describeRoute({
|
||||
tags: ['Lookup'],
|
||||
summary: 'A single public account, by exact username',
|
||||
description:
|
||||
'Case-insensitive exact match; a leading `@` is ignored. 404 when no account has the name.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'name',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The username, exactly (case-insensitive)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(AccountDto, 'The public account'),
|
||||
404: { description: 'No account has that username' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param('name').trim().replace(/^@/, '')
|
||||
const account = name === '' ? null : await getAccountByUsername(c.env.DB, name)
|
||||
if (!account) return c.json({ error: `There's no player called @${name}.` }, 404)
|
||||
return c.json(toAccountDto(account))
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Bulk / single lookup ------------------------------------------------
|
||||
// Register the static `bulk` path before the `/account/:id` param route.
|
||||
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
|
||||
@@ -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(
|
||||
'/account/me/email',
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set email',
|
||||
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
|
||||
summary: 'Change email (sends a confirmation code)',
|
||||
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,
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Not a syntactically valid address (empty body)' },
|
||||
200: json(SuccessResponse, 'Code sent (or the same address re-saved)'),
|
||||
400: json(
|
||||
EmailRefusal,
|
||||
'Not a syntactically valid address (empty body), or already on another account / asked again too soon'
|
||||
),
|
||||
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))),
|
||||
@@ -604,11 +789,151 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
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 })
|
||||
}
|
||||
)
|
||||
|
||||
// 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).
|
||||
.post(
|
||||
'/account/me/phone',
|
||||
|
||||
@@ -15,6 +15,24 @@ export type Env = SharedHonoEnv & {
|
||||
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||
// push AccountUpdate/SelfAccountUpdate notifications on profile mutations.
|
||||
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 */
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,38 @@ export const EmailRequest = z.object({
|
||||
.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({
|
||||
// No shape rule on purpose: the client sends E.164 (`+15552223333`), which the name
|
||||
// rule above would reject outright by eating the leading `+`.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
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'
|
||||
|
||||
@@ -22,6 +22,8 @@ beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
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)')
|
||||
await env.DB.batch([
|
||||
insert.bind(JSON.stringify({ accountId: 0, username: 'RecRoom', displayName: 'Rec Room' })),
|
||||
@@ -153,6 +155,24 @@ describe('public endpoints', () => {
|
||||
expect(await empty.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /account/username/:name is an exact, case-insensitive lookup', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/username/coach`)
|
||||
expect(res.status).toBe(200)
|
||||
const account = (await res.json()) as { accountId: number; username: string }
|
||||
expect(account).toMatchObject({ accountId: 1, username: 'Coach' })
|
||||
// A leading `@` is tolerated — staff fields take names in that form.
|
||||
const at = await exports.default.fetch(
|
||||
`${ORIGIN}/account/username/${encodeURIComponent('@Coach')}`
|
||||
)
|
||||
expect(((await at.json()) as { accountId: number }).accountId).toBe(1)
|
||||
// A prefix is NOT a match: that's what `/account/search` is for.
|
||||
const prefix = await exports.default.fetch(`${ORIGIN}/account/username/coa`)
|
||||
expect(prefix.status).toBe(404)
|
||||
const missing = await exports.default.fetch(`${ORIGIN}/account/username/zzzznope`)
|
||||
expect(missing.status).toBe(404)
|
||||
expect(await missing.json()).toEqual({ error: "There's no player called @zzzznope." })
|
||||
})
|
||||
|
||||
test('GET /account/:id/bio returns an empty bio', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
|
||||
expect(await res.json()).toEqual({ accountId: 7, bio: '' })
|
||||
@@ -650,18 +670,249 @@ describe('auth-gated endpoints', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /account/me/email persists the email, surfaced by /account/me', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email: 'ners@recroom.com' }),
|
||||
/** POST an address for an account, as the website and the game do. */
|
||||
const postEmail = async (id: string, email: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email }),
|
||||
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(await res.json()).toEqual({ success: true })
|
||||
|
||||
// The stored email is now returned by the self account (was a null stub).
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('888') })
|
||||
expect(((await me.json()) as { email: string }).email).toBe('ners@recroom.com')
|
||||
// Staged, not applied: the self account still has no email, and the pending read says
|
||||
// where the code went — the NEW address, since there is nobody else to ask.
|
||||
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 () => {
|
||||
@@ -688,7 +939,9 @@ describe('auth-gated endpoints', () => {
|
||||
'GET /',
|
||||
'GET /account/bulk',
|
||||
'GET /account/me',
|
||||
'GET /account/me/email/pending',
|
||||
'GET /account/search',
|
||||
'GET /account/username/{name}',
|
||||
'GET /account/{id}',
|
||||
'GET /account/{id}/bio',
|
||||
'GET /accountprivacysettings/{id}',
|
||||
@@ -697,6 +950,7 @@ describe('auth-gated endpoints', () => {
|
||||
'POST /account/bulk',
|
||||
'POST /account/create',
|
||||
'POST /account/me/email',
|
||||
'POST /account/me/email/confirm',
|
||||
'POST /account/me/phone',
|
||||
'PUT /account/me/bannerimage',
|
||||
'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,
|
||||
// and an undeliverable address costs nothing here.
|
||||
for (const email of [
|
||||
// and an undeliverable address costs nothing here. One account per address: a second
|
||||
// save on the same account within two minutes is refused for the cooldown, not the syntax.
|
||||
for (const [i, email] of [
|
||||
'someone@example.com',
|
||||
'first.last+tag@mail.example.co.uk',
|
||||
'someone@localhost',
|
||||
]) {
|
||||
].entries()) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email }),
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: await authed(`881${i}`),
|
||||
})
|
||||
expect(res.status, email).toBe(200)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* 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
|
||||
/*! *****************************************************************************
|
||||
Copyright (c) Cloudflare. All rights reserved.
|
||||
@@ -19,26 +19,26 @@ and limitations under the License.
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
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)
|
||||
*/
|
||||
declare class DOMException extends Error {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)
|
||||
@@ -96,61 +96,61 @@ interface Console {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -168,23 +168,24 @@ interface Console {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* The **`console.trace()`** static method outputs a stack trace to the console.
|
||||
@@ -193,7 +194,7 @@ interface Console {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -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 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)
|
||||
*/
|
||||
@@ -404,6 +405,7 @@ declare const crypto: Crypto;
|
||||
* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
|
||||
*/
|
||||
declare const caches: CacheStorage;
|
||||
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/scheduler) */
|
||||
declare const scheduler: Scheduler;
|
||||
/**
|
||||
* 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> {
|
||||
waitUntil(promise: Promise<any>): void;
|
||||
passThroughOnException(): void;
|
||||
readonly exports: Cloudflare.Exports;
|
||||
readonly props: Props;
|
||||
cache?: CacheContext;
|
||||
readonly access?: CloudflareAccessContext;
|
||||
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 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>;
|
||||
jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace<T>;
|
||||
}
|
||||
type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high";
|
||||
type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us";
|
||||
interface DurableObjectNamespaceNewUniqueIdOptions {
|
||||
jurisdiction?: DurableObjectJurisdiction;
|
||||
}
|
||||
@@ -526,6 +530,7 @@ interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = u
|
||||
}
|
||||
interface DurableObjectState<Props = unknown> {
|
||||
waitUntil(promise: Promise<any>): void;
|
||||
readonly exports: Cloudflare.Exports;
|
||||
readonly props: Props;
|
||||
readonly id: DurableObjectId;
|
||||
readonly storage: DurableObjectStorage;
|
||||
@@ -540,7 +545,7 @@ interface DurableObjectState<Props = unknown> {
|
||||
setHibernatableWebSocketEventTimeout(timeoutMs?: number): void;
|
||||
getHibernatableWebSocketEventTimeout(): number | null;
|
||||
getTags(ws: WebSocket): string[];
|
||||
abort(reason?: string): void;
|
||||
abort(reason?: string, options?: DurableObjectAbortOptions): void;
|
||||
}
|
||||
interface DurableObjectTransaction {
|
||||
get<T = unknown>(key: string, options?: DurableObjectGetOptions): Promise<T | undefined>;
|
||||
@@ -576,6 +581,9 @@ interface DurableObjectStorage {
|
||||
getBookmarkForTime(timestamp: number | Date): Promise<string>;
|
||||
onNextSessionRestoreBookmark(bookmark: string): Promise<string>;
|
||||
}
|
||||
interface DurableObjectAbortOptions {
|
||||
retryAlarm?: boolean;
|
||||
}
|
||||
interface DurableObjectListOptions {
|
||||
start?: string;
|
||||
startAfter?: string;
|
||||
@@ -626,26 +634,26 @@ interface AnalyticsEngineDataPoint {
|
||||
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)
|
||||
*/
|
||||
declare class Event {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -682,13 +690,13 @@ declare class Event {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement)
|
||||
@@ -701,45 +709,45 @@ declare class Event {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
|
||||
*/
|
||||
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
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -760,7 +768,7 @@ interface EventListenerObject<EventType extends Event = Event> {
|
||||
}
|
||||
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)
|
||||
*/
|
||||
@@ -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;
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -811,7 +819,7 @@ declare class AbortController {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -824,7 +832,7 @@ declare class AbortController {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -836,13 +844,13 @@ declare abstract class AbortSignal extends EventTarget {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -858,12 +866,17 @@ declare abstract class AbortSignal extends EventTarget {
|
||||
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */
|
||||
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)
|
||||
*/
|
||||
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 {
|
||||
wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise<void>;
|
||||
}
|
||||
@@ -871,20 +884,20 @@ interface SchedulerWaitOptions {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -923,7 +936,7 @@ declare class Blob {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -941,13 +954,13 @@ declare class Blob {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -964,13 +977,13 @@ interface BlobOptions {
|
||||
declare class File extends Blob {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -987,7 +1000,7 @@ interface FileOptions {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1020,14 +1033,14 @@ interface CacheQueryOptions {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1055,7 +1068,7 @@ declare abstract class SubtleCrypto {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1073,7 +1086,7 @@ declare abstract class SubtleCrypto {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1091,7 +1104,7 @@ declare abstract class SubtleCrypto {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1109,13 +1122,13 @@ declare abstract class SubtleCrypto {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1123,20 +1136,20 @@ declare abstract class SubtleCrypto {
|
||||
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.
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1253,12 +1266,15 @@ interface CryptoKeyArbitraryKeyAlgorithm {
|
||||
length?: number;
|
||||
}
|
||||
declare class DigestStream extends WritableStream<ArrayBuffer | ArrayBufferView> {
|
||||
constructor(algorithm: string | SubtleCryptoHashAlgorithm);
|
||||
constructor(algorithm: string | SubtleCryptoHashAlgorithm, options?: DigestStreamOptions);
|
||||
readonly digest: Promise<ArrayBuffer>;
|
||||
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)
|
||||
*/
|
||||
@@ -1275,20 +1291,20 @@ declare class TextDecoder {
|
||||
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)
|
||||
*/
|
||||
declare class TextEncoder {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1345,6 +1361,9 @@ declare class ErrorEvent extends Event {
|
||||
get error(): any;
|
||||
}
|
||||
interface ErrorEventErrorEventInit {
|
||||
bubbles?: boolean;
|
||||
cancelable?: boolean;
|
||||
composed?: boolean;
|
||||
message?: string;
|
||||
filename?: string;
|
||||
lineno?: number;
|
||||
@@ -1357,135 +1376,139 @@ interface ErrorEventErrorEventInit {
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
readonly ports: MessagePort[];
|
||||
}
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
declare class FormData {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
set(name: string, value: Blob, filename?: string): void;
|
||||
/* Returns an array of key, value pairs for every entry in the list. */
|
||||
entries(): IterableIterator<[
|
||||
key: string,
|
||||
value: File | string
|
||||
]>;
|
||||
/* Returns a list of keys in the list. */
|
||||
keys(): IterableIterator<string>;
|
||||
/* Returns a list of values in the list. */
|
||||
values(): IterableIterator<(File | string)>;
|
||||
forEach<This = unknown>(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void;
|
||||
[Symbol.iterator](): IterableIterator<[
|
||||
@@ -1564,19 +1587,19 @@ interface 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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1585,65 +1608,62 @@ declare abstract class FetchEvent extends ExtendableEvent {
|
||||
}
|
||||
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)
|
||||
*/
|
||||
declare class Headers {
|
||||
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)
|
||||
*/
|
||||
get(name: string): string | null;
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
delete(name: string): 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<[
|
||||
key: string,
|
||||
value: string
|
||||
]>;
|
||||
/* Returns an iterator allowing to go through all keys of the key/value pairs contained in this object. */
|
||||
keys(): IterableIterator<string>;
|
||||
/* Returns an iterator allowing to go through all values of the key/value pairs contained in this object. */
|
||||
values(): IterableIterator<string>;
|
||||
[Symbol.iterator](): IterableIterator<[
|
||||
key: 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 {
|
||||
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */
|
||||
get body(): ReadableStream | null;
|
||||
@@ -1699,7 +1719,7 @@ interface Response extends Body {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1717,7 +1737,7 @@ interface Response extends Body {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1725,7 +1745,7 @@ interface Response extends Body {
|
||||
webSocket: WebSocket | null;
|
||||
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)
|
||||
*/
|
||||
@@ -1756,13 +1776,13 @@ declare var Request: {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1774,7 +1794,7 @@ interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> e
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1800,13 +1820,13 @@ interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> e
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2195,7 +2215,7 @@ type ReadableStreamReadResult<R = any> = {
|
||||
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)
|
||||
*/
|
||||
@@ -2213,13 +2233,13 @@ interface ReadableStream<R = any> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2231,13 +2251,13 @@ interface ReadableStream<R = any> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2249,7 +2269,7 @@ interface ReadableStream<R = any> {
|
||||
[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)
|
||||
*/
|
||||
@@ -2281,7 +2301,7 @@ declare class ReadableStreamDefaultReader<R = any> {
|
||||
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)
|
||||
*/
|
||||
@@ -2290,13 +2310,13 @@ declare class ReadableStreamBYOBReader {
|
||||
get closed(): 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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2315,7 +2335,7 @@ interface ReadableStreamGetReaderOptions {
|
||||
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)
|
||||
*/
|
||||
@@ -2341,13 +2361,13 @@ declare abstract class ReadableStreamBYOBRequest {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2359,32 +2379,32 @@ declare abstract class ReadableStreamDefaultController<R = any> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2396,7 +2416,7 @@ declare abstract class ReadableByteStreamController {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2409,7 +2429,7 @@ declare abstract class ReadableByteStreamController {
|
||||
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)
|
||||
*/
|
||||
@@ -2421,7 +2441,7 @@ declare abstract class WritableStreamDefaultController {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2446,7 +2466,7 @@ declare abstract class TransformStreamDefaultController<O = any> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2468,14 +2488,14 @@ interface ReadableWritablePair<R = any, W = any> {
|
||||
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)
|
||||
*/
|
||||
declare class WritableStream<W = any> {
|
||||
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)
|
||||
*/
|
||||
@@ -2487,83 +2507,83 @@ declare class WritableStream<W = any> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
declare class WritableStreamDefaultWriter<W = any> {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
declare class TransformStream<I = any, O = any> {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2582,7 +2602,7 @@ interface ReadableStreamValuesOptions {
|
||||
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)
|
||||
*/
|
||||
@@ -2590,7 +2610,7 @@ declare class CompressionStream extends TransformStream<ArrayBuffer | ArrayBuffe
|
||||
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)
|
||||
*/
|
||||
@@ -2598,7 +2618,7 @@ declare class DecompressionStream extends TransformStream<ArrayBuffer | ArrayBuf
|
||||
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)
|
||||
*/
|
||||
@@ -2607,7 +2627,7 @@ declare class TextEncoderStream extends TransformStream<string, Uint8Array> {
|
||||
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)
|
||||
*/
|
||||
@@ -2756,6 +2776,12 @@ interface TraceLog {
|
||||
readonly timestamp: number;
|
||||
readonly level: string;
|
||||
readonly message: any;
|
||||
readonly errorInfo?: (TraceLogErrorInfo | null)[];
|
||||
}
|
||||
interface TraceLogErrorInfo {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
}
|
||||
interface TraceException {
|
||||
readonly timestamp: number;
|
||||
@@ -2776,7 +2802,7 @@ interface UnsafeTraceMetrics {
|
||||
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)
|
||||
*/
|
||||
@@ -2801,121 +2827,121 @@ declare class URL {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2935,13 +2961,13 @@ declare class URL {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2991,25 +3017,22 @@ declare class URLSearchParams {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
sort(): void;
|
||||
/* Returns an array of key, value pairs for every entry in the search params. */
|
||||
entries(): IterableIterator<[
|
||||
key: string,
|
||||
value: string
|
||||
]>;
|
||||
/* Returns a list of keys in the search params. */
|
||||
keys(): IterableIterator<string>;
|
||||
/* Returns a list of values in the search params. */
|
||||
values(): IterableIterator<string>;
|
||||
forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void;
|
||||
/*function toString() { [native code] }*/
|
||||
@@ -3019,18 +3042,78 @@ declare class URLSearchParams {
|
||||
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 {
|
||||
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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
interface URLPatternInit {
|
||||
@@ -3063,7 +3146,7 @@ interface URLPatternOptions {
|
||||
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)
|
||||
*/
|
||||
@@ -3082,13 +3165,16 @@ declare class CloseEvent extends Event {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
readonly wasClean: boolean;
|
||||
}
|
||||
interface CloseEventInit {
|
||||
bubbles?: boolean;
|
||||
cancelable?: boolean;
|
||||
composed?: boolean;
|
||||
code?: number;
|
||||
reason?: string;
|
||||
wasClean?: boolean;
|
||||
@@ -3100,7 +3186,7 @@ type WebSocketEventMap = {
|
||||
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)
|
||||
*/
|
||||
@@ -3117,20 +3203,20 @@ declare var WebSocket: {
|
||||
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)
|
||||
*/
|
||||
interface WebSocket extends EventTarget<WebSocketEventMap> {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -3150,13 +3236,13 @@ interface WebSocket extends EventTarget<WebSocketEventMap> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -3243,25 +3329,25 @@ interface SocketInfo {
|
||||
declare class EventSource extends EventTarget {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -3296,18 +3382,26 @@ interface ContainerExecOptions {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
user?: string;
|
||||
signal?: AbortSignal;
|
||||
pty?: boolean | ContainerExecPtyOptions;
|
||||
stdin?: ReadableStream | "pipe";
|
||||
stdout?: "pipe" | "ignore";
|
||||
stderr?: "pipe" | "ignore" | "combined";
|
||||
}
|
||||
interface ContainerExecPtyOptions {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}
|
||||
interface ExecProcess {
|
||||
readonly stdin: WritableStream | null;
|
||||
readonly stdout: ReadableStream | null;
|
||||
readonly stderr: ReadableStream | null;
|
||||
readonly pid: number;
|
||||
readonly isPty: boolean;
|
||||
readonly exitCode: Promise<number>;
|
||||
output(): Promise<ExecOutput>;
|
||||
kill(signal?: number): void;
|
||||
resize(cols: number, rows: number): void;
|
||||
}
|
||||
interface Container {
|
||||
get running(): boolean;
|
||||
@@ -3334,25 +3428,42 @@ interface ContainerDirectorySnapshotOptions {
|
||||
dir: string;
|
||||
name?: string;
|
||||
}
|
||||
interface ContainerDirectorySnapshotRestoreParams {
|
||||
type ContainerDirectorySnapshotRestoreParams = {
|
||||
snapshot: ContainerDirectorySnapshot;
|
||||
mountPoint?: string;
|
||||
}
|
||||
} | {
|
||||
snapshot?: undefined;
|
||||
mountPoint: string;
|
||||
};
|
||||
interface ContainerSnapshot {
|
||||
id: string;
|
||||
size: number;
|
||||
name?: string;
|
||||
}
|
||||
interface ContainerSnapshotRestoreParams {
|
||||
id: string;
|
||||
}
|
||||
interface ContainerSnapshotOptions {
|
||||
name?: string;
|
||||
}
|
||||
interface ContainerStartupOptions {
|
||||
type ContainerStartupOptions = {
|
||||
entrypoint?: string[];
|
||||
enableInternet: boolean;
|
||||
env?: Record<string, string>;
|
||||
instance?: "lite" | "standard-1" | "standard-2" | "standard-3" | "standard-4" | ContainerStartResources;
|
||||
labels?: Record<string, string>;
|
||||
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.
|
||||
@@ -3361,19 +3472,19 @@ interface ContainerStartupOptions {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -3389,13 +3500,13 @@ declare abstract class MessagePort extends EventTarget {
|
||||
declare class MessageChannel {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -3455,7 +3566,7 @@ interface WorkerLoaderModule {
|
||||
data?: ArrayBuffer;
|
||||
json?: any;
|
||||
py?: string;
|
||||
wasm?: ArrayBuffer;
|
||||
wasm?: ArrayBuffer | ArrayBufferView | WebAssembly.Module;
|
||||
}
|
||||
interface WorkerLoaderWorkerCode {
|
||||
compatibilityDate: string;
|
||||
@@ -3463,7 +3574,7 @@ interface WorkerLoaderWorkerCode {
|
||||
allowExperimental?: boolean;
|
||||
limits?: workerdResourceLimits;
|
||||
mainModule: string;
|
||||
modules: Record<string, WorkerLoaderModule | string>;
|
||||
modules: Record<string, string | WebAssembly.Module | WorkerLoaderModule>;
|
||||
env?: any;
|
||||
globalOutbound?: (Fetcher | null);
|
||||
tails?: Fetcher[];
|
||||
@@ -3485,7 +3596,7 @@ declare abstract class Performance {
|
||||
/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */
|
||||
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)
|
||||
*/
|
||||
@@ -3494,11 +3605,13 @@ declare abstract class Performance {
|
||||
interface Tracing {
|
||||
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;
|
||||
startSpan(name: string): Span;
|
||||
Span: typeof Span;
|
||||
}
|
||||
declare abstract class Span {
|
||||
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;
|
||||
}
|
||||
/**
|
||||
@@ -4138,6 +4251,13 @@ type AiSearchListItemsParams = {
|
||||
source?: string;
|
||||
/** JSON-encoded Vectorize filter for metadata filtering. */
|
||||
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 = {
|
||||
result: AiSearchItemInfo[];
|
||||
@@ -10035,6 +10155,163 @@ declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT {
|
||||
inputs: ChatCompletionsInput;
|
||||
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 {
|
||||
"@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification;
|
||||
"@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/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/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 = {
|
||||
/**
|
||||
@@ -10793,6 +11077,15 @@ interface BrowserRunBaseOptions {
|
||||
*/
|
||||
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.*/
|
||||
type BrowserRunCommonOptions = (BrowserRunBaseOptions & {
|
||||
/** URL to navigate to, e.g. `"https://example.com"`. */
|
||||
@@ -10827,7 +11120,7 @@ type BrowserRunScreenshotOptions = BrowserRunCommonOptions & {
|
||||
scrollPage?: boolean;
|
||||
/** @see https://pptr.dev/api/puppeteer.screenshotoptions */
|
||||
screenshotOptions?: BrowserRunPuppeteerScreenshotOptions;
|
||||
};
|
||||
} & BrowserRunAlternateBackendOptions;
|
||||
type BrowserRunPDFOptions = BrowserRunCommonOptions & {
|
||||
/** @see https://pptr.dev/api/puppeteer.pdfoptions */
|
||||
pdfOptions?: {
|
||||
@@ -10863,7 +11156,7 @@ type BrowserRunPDFOptions = BrowserRunCommonOptions & {
|
||||
/** @default 30000 */
|
||||
timeout?: number;
|
||||
};
|
||||
};
|
||||
} & BrowserRunAlternateBackendOptions;
|
||||
type BrowserRunScrapeOptions = BrowserRunCommonOptions & {
|
||||
/** CSS selectors to scrape. At least one element is required. */
|
||||
elements: Array<{
|
||||
@@ -10876,10 +11169,29 @@ type BrowserRunLinksOptions = BrowserRunCommonOptions & {
|
||||
/** When true, exclude links pointing to external domains. @default false */
|
||||
excludeExternalLinks?: boolean;
|
||||
};
|
||||
type BrowserRunSnapshotFormat = 'content' | 'screenshot' | 'markdown' | 'accessibilityTree';
|
||||
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 */
|
||||
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 {
|
||||
/** Custom AI models to try in order. Max 3. Falls back to next on error. */
|
||||
custom_ai?: Array<{
|
||||
@@ -10893,7 +11205,7 @@ interface BrowserRunJsonBaseOptions {
|
||||
* Options for the `json` quick action.
|
||||
* 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. */
|
||||
prompt: string;
|
||||
/** 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/ */
|
||||
response_format: AiTextGenerationResponseFormat;
|
||||
});
|
||||
type BrowserRunContentOptions = BrowserRunCommonOptions;
|
||||
type BrowserRunMarkdownOptions = BrowserRunCommonOptions;
|
||||
type BrowserRunContentOptions = BrowserRunCommonOptions & BrowserRunAlternateBackendOptions;
|
||||
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 = {
|
||||
/** HTTP status code of the rendered page */
|
||||
status: number;
|
||||
/** Page title */
|
||||
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. */
|
||||
type BrowserRunContentSuccessResponse = {
|
||||
success: true;
|
||||
@@ -10924,6 +11302,7 @@ type BrowserRunLinksSuccessResponse = {
|
||||
success: true;
|
||||
/** Extracted links */
|
||||
result: string[];
|
||||
meta: BrowserRunResponseMeta;
|
||||
};
|
||||
/** Success response for `scrape` action. */
|
||||
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 = {
|
||||
success: true;
|
||||
result: {
|
||||
/** HTML content of the page. */
|
||||
content: string;
|
||||
content?: string;
|
||||
/** 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;
|
||||
};
|
||||
@@ -10971,12 +11368,14 @@ type BrowserRunJsonSuccessResponse = {
|
||||
success: true;
|
||||
/** JSON data extracted from the page using an AI model */
|
||||
result: Record<string, unknown>;
|
||||
meta: BrowserRunResponseMeta;
|
||||
};
|
||||
/** Success response for `markdown` action. */
|
||||
type BrowserRunMarkdownSuccessResponse = {
|
||||
success: true;
|
||||
/** Extracted markdown content */
|
||||
result: string;
|
||||
meta: BrowserRunResponseMeta;
|
||||
};
|
||||
/** Error response for BrowserRun actions. */
|
||||
type BrowserRunErrorResponse = {
|
||||
@@ -11086,9 +11485,10 @@ declare abstract class BrowserRun {
|
||||
*/
|
||||
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 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:
|
||||
*
|
||||
* **Success (HTTP 200):**
|
||||
@@ -11136,6 +11536,26 @@ declare abstract class BrowserRun {
|
||||
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
|
||||
*/
|
||||
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
|
||||
@@ -12206,6 +12626,13 @@ interface ForwardableEmailMessage extends EmailMessage {
|
||||
* @returns A promise that resolves when the email message is replied.
|
||||
*/
|
||||
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 */
|
||||
type EmailAttachment = {
|
||||
@@ -12226,23 +12653,46 @@ interface EmailAddress {
|
||||
name: 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.
|
||||
*/
|
||||
interface SendEmail {
|
||||
send(message: EmailMessage): Promise<EmailSendResult>;
|
||||
send(builder: {
|
||||
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>;
|
||||
send(builder: EmailMessageBuilder): Promise<EmailSendResult>;
|
||||
}
|
||||
declare abstract class EmailEvent extends ExtendableEvent {
|
||||
readonly message: ForwardableEmailMessage;
|
||||
@@ -12396,6 +12846,15 @@ interface Hyperdrive {
|
||||
* for your database.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -12417,6 +12876,78 @@ interface Hyperdrive {
|
||||
*/
|
||||
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.
|
||||
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
|
||||
// https://opensource.org/licenses/Apache-2.0
|
||||
@@ -12428,6 +12959,27 @@ type ImageInfoResponse = {
|
||||
width: 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 = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
@@ -12517,6 +13069,9 @@ interface ImageUploadOptions {
|
||||
requireSignedURLs?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
creator?: string;
|
||||
/**
|
||||
* If 'base64', the input data will be decoded from base64 before processing
|
||||
*/
|
||||
encoding?: 'base64';
|
||||
}
|
||||
interface ImageUpdateOptions {
|
||||
@@ -12524,11 +13079,40 @@ interface ImageUpdateOptions {
|
||||
metadata?: Record<string, unknown>;
|
||||
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 {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
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 {
|
||||
images: ImageMetadata[];
|
||||
@@ -12546,6 +13130,13 @@ interface ImageHandle {
|
||||
* @returns ReadableStream of image bytes, or null if not found
|
||||
*/
|
||||
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
|
||||
* @param options Properties to update
|
||||
@@ -12581,6 +13172,14 @@ interface HostedImagesBinding {
|
||||
* @throws {@link ImagesError} if list fails
|
||||
*/
|
||||
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 {
|
||||
/**
|
||||
@@ -12595,6 +13194,13 @@ interface ImagesBinding {
|
||||
* @returns A transform handle
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@@ -12624,11 +13230,15 @@ interface ImageTransformer {
|
||||
type ImageTransformationOutputOptions = {
|
||||
encoding?: 'base64';
|
||||
};
|
||||
type ImageTransformationResponseOptions = {
|
||||
headers?: HeadersInit;
|
||||
};
|
||||
interface ImageTransformationResult {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -13064,6 +13674,11 @@ declare namespace CloudflareWorkersModule {
|
||||
export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
|
||||
export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number;
|
||||
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 WorkflowRetentionDuration = WorkflowSleepDuration;
|
||||
export type WorkflowBackoff = 'constant' | 'linear' | 'exponential';
|
||||
@@ -13071,12 +13686,32 @@ declare namespace CloudflareWorkersModule {
|
||||
export type WorkflowStepConfig = {
|
||||
retries?: {
|
||||
limit: number;
|
||||
delay: WorkflowDelayDuration | number;
|
||||
delay: WorkflowDelayDuration | number | WorkflowDelayFunction;
|
||||
backoff?: WorkflowBackoff;
|
||||
};
|
||||
timeout?: WorkflowTimeoutDuration | number;
|
||||
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 WorkflowCronSchedule = {
|
||||
/** Cron expression that triggered this event. */
|
||||
@@ -13097,28 +13732,51 @@ declare namespace CloudflareWorkersModule {
|
||||
type: string;
|
||||
sensitive?: WorkflowStepSensitivity;
|
||||
};
|
||||
export type WorkflowStepContext = {
|
||||
export type WorkflowStepContext<Delay = WorkflowDelayDuration | number> = {
|
||||
step: {
|
||||
name: string;
|
||||
count: 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> = {
|
||||
ctx: WorkflowStepContext;
|
||||
// The rollback handler receives the step context, so it mirrors the same
|
||||
// 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;
|
||||
output: T | undefined;
|
||||
/** @deprecated Use `ctx.step.name` and `ctx.step.count` instead. */
|
||||
stepName: string;
|
||||
};
|
||||
export type WorkflowRollbackHandler<T = unknown> = (ctx: WorkflowRollbackContext<T>) => Promise<void>;
|
||||
export type WorkflowStepRollbackOptions<T = unknown> = {
|
||||
rollback: WorkflowRollbackHandler<T>;
|
||||
export type WorkflowRollbackHandler<T = unknown, Delay = WorkflowDelayDuration | number> = (ctx: WorkflowRollbackContext<T, Delay>) => Promise<void>;
|
||||
export type WorkflowStepRollbackOptions<T = unknown, Delay = WorkflowDelayDuration | number> = {
|
||||
rollback: WorkflowRollbackHandler<T, Delay>;
|
||||
rollbackConfig?: WorkflowStepRollbackConfig;
|
||||
};
|
||||
export abstract class WorkflowStep {
|
||||
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>;
|
||||
sleep: (name: string, duration: WorkflowSleepDuration) => Promise<void>;
|
||||
sleepUntil: (name: string, timestamp: Date | number) => Promise<void>;
|
||||
@@ -13894,11 +14552,12 @@ type MarkdownDocument = {
|
||||
name: string;
|
||||
blob: Blob;
|
||||
};
|
||||
type OutputFormat = 'markdown' | 'text';
|
||||
type ConversionResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
format: 'markdown';
|
||||
format: OutputFormat;
|
||||
tokens: number;
|
||||
data: string;
|
||||
} | {
|
||||
@@ -13915,7 +14574,11 @@ type EmbeddedImageConversionOptions = ImageConversionOptions & {
|
||||
convert?: boolean;
|
||||
maxConvertedImages?: number;
|
||||
};
|
||||
type ConversionOutputOptions = {
|
||||
format?: OutputFormat;
|
||||
};
|
||||
type ConversionOptions = {
|
||||
output?: ConversionOutputOptions;
|
||||
html?: {
|
||||
images?: EmbeddedImageConversionOptions & {
|
||||
convertOGImage?: boolean;
|
||||
@@ -14010,7 +14673,7 @@ declare namespace TailStream {
|
||||
interface ConnectEventInfo {
|
||||
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 {
|
||||
readonly id: string;
|
||||
readonly tag?: string;
|
||||
@@ -14029,6 +14692,7 @@ declare namespace TailStream {
|
||||
readonly dispatchNamespace?: string;
|
||||
readonly entrypoint?: string;
|
||||
readonly executionModel: string;
|
||||
readonly durableObjectId?: string;
|
||||
readonly scriptName?: string;
|
||||
readonly scriptTags?: string[];
|
||||
readonly scriptVersion?: ScriptVersion;
|
||||
@@ -14063,11 +14727,22 @@ declare namespace TailStream {
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
}
|
||||
interface Log {
|
||||
interface TailStreamErrorInfo {
|
||||
readonly name: string;
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
}
|
||||
type Log = {
|
||||
readonly type: "log";
|
||||
readonly level: "debug" | "error" | "info" | "log" | "warn";
|
||||
readonly errorInfo?: readonly (TailStreamErrorInfo | null)[];
|
||||
} & ({
|
||||
readonly message: object;
|
||||
}
|
||||
readonly truncated?: false;
|
||||
} | {
|
||||
readonly message: string;
|
||||
readonly truncated: true;
|
||||
});
|
||||
interface DroppedEventsDiagnostic {
|
||||
readonly diagnosticsType: "droppedEvents";
|
||||
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.
|
||||
*/
|
||||
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 WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number;
|
||||
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> {
|
||||
/**
|
||||
* An id for your Workflow instance. Must be unique within the Workflow.
|
||||
@@ -14566,6 +15262,9 @@ interface WorkflowInstanceCreateOptions<PARAMS = unknown> {
|
||||
successRetention?: WorkflowRetentionDuration;
|
||||
errorRetention?: WorkflowRetentionDuration;
|
||||
};
|
||||
/** A best-effort geographic placement preference for the Workflow instance.
|
||||
* See `WorkflowInstanceLocationHint` for supported regions. */
|
||||
locationHint?: WorkflowInstanceLocationHint;
|
||||
}
|
||||
type InstanceStatus = {
|
||||
status: 'queued' // means that instance is waiting to be started (see concurrency limits)
|
||||
@@ -14583,6 +15282,13 @@ interface WorkflowError {
|
||||
code?: number;
|
||||
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 {
|
||||
/**
|
||||
* 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>;
|
||||
/**
|
||||
* 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
|
||||
* cached results for all steps before it.
|
||||
* @param options Options for the restart, including an optional step to restart from.
|
||||
*/
|
||||
public restart(options?: WorkflowInstanceRestartOptions): Promise<void>;
|
||||
/**
|
||||
* Delete the instance and its stored state.
|
||||
*/
|
||||
public delete(): Promise<void>;
|
||||
/**
|
||||
* Returns the current status of the instance.
|
||||
*/
|
||||
|
||||
@@ -37,12 +37,25 @@
|
||||
"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,
|
||||
"observability": {
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"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;
|
||||
@@ -6,6 +6,7 @@ import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo
|
||||
|
||||
import { accountRoutes } from './routes/account'
|
||||
import { avatarRoutes } from './routes/avatar'
|
||||
import { avatarStudioRoutes } from './routes/avatar-studio'
|
||||
import { configRoutes } from './routes/config'
|
||||
import { eventRoutes } from './routes/events'
|
||||
import { gameplayRoutes } from './routes/gameplay'
|
||||
@@ -56,6 +57,7 @@ const app = new Hono<App>({ strict: false })
|
||||
.route('/', configRoutes)
|
||||
.route('/', socialRoutes)
|
||||
.route('/', progressionRoutes)
|
||||
.route('/', avatarStudioRoutes)
|
||||
.route('/', avatarRoutes)
|
||||
.route('/', gameplayRoutes)
|
||||
.route('/', eventRoutes)
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */
|
||||
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql and 0035_custom_avatar_item_badge_room.sql). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
||||
data TEXT NOT NULL,
|
||||
@@ -45,6 +45,9 @@ export const SCHEMA_DDL: string[] = [
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
||||
// The room-badge lookup (`attachBadges`, packages/domain/src/rooms-db.ts) runs on every room
|
||||
// page; partial over the badge rows, keyed by the badge's room (migrations/0035).
|
||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId')) WHERE outfit_type = 1000`,
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -72,6 +75,9 @@ export interface CustomAvatarItemSave {
|
||||
UnityAssetHash: string
|
||||
UnityAsset2: string | null
|
||||
UnityAsset2Hash: string | null
|
||||
/** Studio builds persist separate target hashes; legacy imports omit these. */
|
||||
QuestUnityAssetHash?: string | null
|
||||
QuestUnityAsset2Hash?: string | null
|
||||
}
|
||||
|
||||
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
|
||||
@@ -356,6 +362,8 @@ export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarIte
|
||||
...save,
|
||||
UnityAsset: questAsset(save.UnityAsset),
|
||||
UnityAsset2: questAsset(save.UnityAsset2),
|
||||
UnityAssetHash: save.QuestUnityAssetHash ?? save.UnityAssetHash,
|
||||
UnityAsset2Hash: save.QuestUnityAsset2Hash ?? save.UnityAsset2Hash,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import {
|
||||
AVATAR_STUDIO_SLOTS,
|
||||
avatarItemAccessBit,
|
||||
avatarSaveDto,
|
||||
avatarStudioFlags,
|
||||
avatarStudioItem,
|
||||
avatarStudioSave,
|
||||
canManageRoomById,
|
||||
publishAvatarSave,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { parseMultipart } from '../../../studio/src/multipart'
|
||||
import { getCustomAvatarItem, toQuestCustomAvatarItem } from '../custom-avatar-items-db'
|
||||
import { authedId, authedRoles } from '../http'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { AvatarStudioSave } from '@repo/domain'
|
||||
import type { App } from '../context'
|
||||
|
||||
const uuid = '[0-9a-fA-F-]{36}'
|
||||
const ok = (value: unknown) => ({ success: true, error: null, value })
|
||||
const fail = (error: string) => ({ success: false, error, value: null })
|
||||
const slots = new Set(AVATAR_STUDIO_SLOTS)
|
||||
function validTags(value: unknown): boolean {
|
||||
return (
|
||||
value == null ||
|
||||
(Array.isArray(value) &&
|
||||
value.length <= 64 &&
|
||||
value.every(
|
||||
(t) =>
|
||||
t &&
|
||||
Number.isInteger(t.TagType) &&
|
||||
t.TagType >= 0 &&
|
||||
t.TagType <= 255 &&
|
||||
typeof t.Value === 'string' &&
|
||||
t.Value.length > 0 &&
|
||||
t.Value.length <= 64
|
||||
))
|
||||
)
|
||||
}
|
||||
async function allowed(c: Context<App>, accountId: number, slot: number) {
|
||||
const roles = await authedRoles(c)
|
||||
return (
|
||||
roles?.includes('betastudio') &&
|
||||
(![104, 203].includes(slot) || roles.includes('developer')) &&
|
||||
((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(slot)) !== 0
|
||||
)
|
||||
}
|
||||
const safeName = (value: unknown): value is string =>
|
||||
typeof value === 'string' &&
|
||||
/^[a-zA-Z0-9_./-]+$/.test(value) &&
|
||||
!value.split('/').some((p) => p === '..' || p === '.' || p === '')
|
||||
|
||||
export const avatarStudioRoutes = new Hono<App>()
|
||||
.get('/api/customAvatarItems/v1/me', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const rows = await c.env.DB.prepare(
|
||||
'SELECT data FROM custom_avatar_item WHERE creator_account_id=? ORDER BY created_at DESC,custom_avatar_item_id'
|
||||
)
|
||||
.bind(id)
|
||||
.all<{ data: string }>()
|
||||
const all = rows.results.map((r) => JSON.parse(r.data))
|
||||
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
|
||||
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
|
||||
return c.json({ Results: all.slice(skip, skip + take), TotalResults: all.length })
|
||||
})
|
||||
.get(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c) => {
|
||||
const item = await getCustomAvatarItem(c.env.DB, c.req.param('id'))
|
||||
if (!item) return c.notFound()
|
||||
if (item.Accessibility === 0 && item.CreatorAccountId !== (await authedId(c)))
|
||||
return c.body(null, 403)
|
||||
return c.json(c.req.query('unityAssetTarget') === '2' ? toQuestCustomAvatarItem(item) : item)
|
||||
})
|
||||
.post('/api/customAvatarItems/v1', async (c, next) => {
|
||||
// Preserve the existing in-game custom-shirt contract, including its errors.
|
||||
const form = await parseMultipart(c.req.raw.clone())
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
body = JSON.parse(String(form.metadata))
|
||||
} catch {
|
||||
return next()
|
||||
}
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== 'object' ||
|
||||
body.BaseAvatarItemId !== undefined ||
|
||||
form.design ||
|
||||
form.thumbnailImage
|
||||
)
|
||||
return next()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const slot = Number(body.OutfitType)
|
||||
if (!Number.isInteger(slot) || !slots.has(slot))
|
||||
return c.json(fail('Unsupported avatar item type.'), 400)
|
||||
if (!(await allowed(c, id, slot)))
|
||||
return c.json(fail('Avatar Studio authorization is required.'), 403)
|
||||
const badgeRoomId = Number(body.BadgeRoomId ?? 0)
|
||||
if (
|
||||
slot === 1000 &&
|
||||
(!Number.isSafeInteger(badgeRoomId) ||
|
||||
badgeRoomId < 0 ||
|
||||
(badgeRoomId > 0 && !(await canManageRoomById(c.env.DB, badgeRoomId, id))))
|
||||
)
|
||||
return c.json(fail('Badge room is not managed by this account.'), 403)
|
||||
if (
|
||||
typeof body.Name !== 'string' ||
|
||||
!body.Name.trim() ||
|
||||
body.Name.length > 128 ||
|
||||
(body.Description != null &&
|
||||
(typeof body.Description !== 'string' || body.Description.length > 4000))
|
||||
)
|
||||
return c.json(fail('Invalid item name or description.'), 400)
|
||||
if (
|
||||
body.Price !== undefined &&
|
||||
body.Price !== null &&
|
||||
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)
|
||||
)
|
||||
return c.json(fail('Invalid item price.'), 400)
|
||||
if (body.Accessibility !== undefined && ![0, 1, 2].includes(Number(body.Accessibility)))
|
||||
return c.json(fail('Invalid accessibility.'), 400)
|
||||
if (body.Accessibility && body.Accessibility !== 0)
|
||||
return c.json(fail('Create the item privately and publish a built save first.'), 400)
|
||||
if (!validTags(body.Tags)) return c.json(fail('Invalid avatar tags.'), 400)
|
||||
const now = new Date().toISOString(),
|
||||
itemId = crypto.randomUUID()
|
||||
const item = {
|
||||
CustomAvatarItemId: itemId,
|
||||
CreatorAccountId: id,
|
||||
Name: body.Name.trim(),
|
||||
Description: body.Description ?? '',
|
||||
Price: body.Price ?? 0,
|
||||
Accessibility: body.Accessibility ?? 0,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: false,
|
||||
BaseAvatarItemId: null,
|
||||
BaseAvatarItemColor: null,
|
||||
DesignFilename: null,
|
||||
ThumbnailImageFilename: null,
|
||||
CreatedAt: now,
|
||||
ModifiedAt: now,
|
||||
PreviewOrientation: body.PreviewOrientation ?? 0,
|
||||
RankingContext: null,
|
||||
OutfitType: slot,
|
||||
CurrentSaves: [],
|
||||
Tags: body.Tags ?? [],
|
||||
IsStudioAuthored: true,
|
||||
CustomBadgeMetadata: slot === 1000 ? { RoomId: badgeRoomId } : null,
|
||||
RankedEntityId: itemId,
|
||||
PurchaseInfo: null,
|
||||
}
|
||||
await c.env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
|
||||
.bind(JSON.stringify(item))
|
||||
.run()
|
||||
return c.json(ok(item))
|
||||
})
|
||||
.put(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c, next) => {
|
||||
const itemId = c.req.param('id')
|
||||
const existing = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!existing?.IsStudioAuthored) return next()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
if (existing.CreatorAccountId !== id || !(await allowed(c, id, existing.OutfitType)))
|
||||
return c.body(null, 403)
|
||||
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
|
||||
if (
|
||||
!body ||
|
||||
(body.Name != null &&
|
||||
(typeof body.Name !== 'string' || !body.Name.trim() || body.Name.length > 128)) ||
|
||||
(body.Description != null &&
|
||||
(typeof body.Description !== 'string' || body.Description.length > 4000)) ||
|
||||
(body.Price != null &&
|
||||
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)) ||
|
||||
(body.Accessibility != null && ![0, 1, 2].includes(Number(body.Accessibility))) ||
|
||||
!validTags(body.Tags)
|
||||
)
|
||||
return c.json(fail('Invalid avatar metadata.'), 400)
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const item = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!item) return c.notFound()
|
||||
if (body.Accessibility && !item.CurrentSaves?.length)
|
||||
return c.json(fail('Publish a built save before making the item public.'), 400)
|
||||
const prior = JSON.stringify(item)
|
||||
for (const key of ['Name', 'Description', 'Price', 'Accessibility', 'Tags'])
|
||||
if (body[key] != null) item[key] = key === 'Name' ? String(body[key]).trim() : body[key]
|
||||
item.ModifiedAt = new Date().toISOString()
|
||||
const result = await c.env.DB.prepare(
|
||||
'UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)'
|
||||
)
|
||||
.bind(JSON.stringify(item), itemId, prior)
|
||||
.run()
|
||||
if (result.meta.changes) return c.json(ok(item))
|
||||
}
|
||||
return c.json(fail('The item changed concurrently. Retry the edit.'), 409)
|
||||
})
|
||||
.get(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const itemId = c.req.param('id'),
|
||||
item = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!item) return c.notFound()
|
||||
if (item.CreatorAccountId !== id) return c.body(null, 403)
|
||||
const rows = await c.env.DB.prepare(
|
||||
'SELECT * FROM avatar_studio_save WHERE item_id=? ORDER BY id DESC'
|
||||
)
|
||||
.bind(itemId)
|
||||
.all<AvatarStudioSave>()
|
||||
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
|
||||
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
|
||||
return c.json(
|
||||
ok({
|
||||
Results: await Promise.all(
|
||||
rows.results.slice(skip, skip + take).map((r) => avatarSaveDto(c.env.DB, r))
|
||||
),
|
||||
TotalResults: rows.results.length,
|
||||
})
|
||||
)
|
||||
})
|
||||
.post(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const itemId = c.req.param('id'),
|
||||
item = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!item) return c.notFound()
|
||||
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
|
||||
return c.body(null, 403)
|
||||
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
|
||||
if (
|
||||
!body ||
|
||||
!Number.isInteger(body.BodyType) ||
|
||||
![0, 1, 2, 3, 4].includes(Number(body.BodyType)) ||
|
||||
!safeName(body.ThumbnailFileName) ||
|
||||
typeof body.AdditionalConfiguration !== 'string' ||
|
||||
body.AdditionalConfiguration.length > 1024 * 1024 ||
|
||||
(body.SaveDescription != null &&
|
||||
(typeof body.SaveDescription !== 'string' || body.SaveDescription.length > 4000))
|
||||
)
|
||||
return c.json(fail('Invalid avatar save.'), 400)
|
||||
try {
|
||||
JSON.parse(body.AdditionalConfiguration)
|
||||
} catch {
|
||||
return c.json(fail('Invalid additional configuration JSON.'), 400)
|
||||
}
|
||||
let filename: string, hash: string, unityId: string
|
||||
if (body.NewUnityAssetFile && typeof body.NewUnityAssetFile === 'object') {
|
||||
const file = body.NewUnityAssetFile as Record<string, unknown>
|
||||
if (!safeName(file.Filename)) return c.json(fail('Invalid source filename.'), 400)
|
||||
const object = await c.env.CDN_ASSETS.head('avatarunityasset/' + file.Filename)
|
||||
if (!object || object.customMetadata?.ownerId !== String(id))
|
||||
return c.json(fail('Source upload does not belong to this account.'), 403)
|
||||
const checksum = object.checksums.sha256
|
||||
hash = checksum ? btoa(String.fromCharCode(...new Uint8Array(checksum))) : ''
|
||||
if (!hash || file.Hash !== hash)
|
||||
return c.json(fail('Source hash does not match uploaded bytes.'), 400)
|
||||
filename = file.Filename
|
||||
unityId = crypto.randomUUID()
|
||||
} else if (typeof body.ExistingUnityAssetId === 'string') {
|
||||
const prior = await c.env.DB.prepare(
|
||||
'SELECT * FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
|
||||
)
|
||||
.bind(itemId, body.ExistingUnityAssetId)
|
||||
.first<AvatarStudioSave>()
|
||||
if (!prior) return c.json(fail('No matching source revision on this item.'), 400)
|
||||
filename = prior.source_filename
|
||||
hash = prior.source_hash
|
||||
// A source can be reused across builds, but compiled outputs belong to
|
||||
// one body/configuration. Only metadata-only revisions share their output.
|
||||
unityId =
|
||||
prior.body_type === body.BodyType && prior.configuration === body.AdditionalConfiguration
|
||||
? prior.unity_asset_id
|
||||
: crypto.randomUUID()
|
||||
} else return c.json(fail('A new or existing source asset is required.'), 400)
|
||||
if (item.OutfitType === 1000) {
|
||||
const config = JSON.parse(body.AdditionalConfiguration as string)
|
||||
const badgeRoom = config?.d?.d?.r ?? 0
|
||||
if (
|
||||
config?.v !== 1 ||
|
||||
config?.d?.o !== 1000 ||
|
||||
!Number.isSafeInteger(badgeRoom) ||
|
||||
badgeRoom < 0
|
||||
)
|
||||
return c.json(fail('Invalid badge configuration.'), 400)
|
||||
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
|
||||
return c.json(fail('Badge room is not managed by this account.'), 403)
|
||||
if (body.BodyType !== 4)
|
||||
return c.json(fail('Badge images require the body-agnostic body type.'), 400)
|
||||
const source = await c.env.CDN_ASSETS.get('avatarunityasset/' + filename)
|
||||
if (!source || source.size > 8 * 1024 * 1024)
|
||||
return c.json(fail('Missing or oversized badge source.'), 400)
|
||||
const manifest = await source
|
||||
.json<{
|
||||
Version: number
|
||||
MainAssetProjectRelativePath: string
|
||||
Files: Array<{ p: string; n: string }>
|
||||
}>()
|
||||
.catch(() => null)
|
||||
if (
|
||||
!manifest ||
|
||||
manifest.Version !== 3 ||
|
||||
!Array.isArray(manifest.Files) ||
|
||||
!manifest.Files.length ||
|
||||
manifest.Files.length > 100000 ||
|
||||
!safeName(manifest.MainAssetProjectRelativePath) ||
|
||||
!manifest.MainAssetProjectRelativePath.startsWith('Assets/') ||
|
||||
!/\.(png|jpe?g)$/i.test(manifest.MainAssetProjectRelativePath) ||
|
||||
manifest.Files.some(
|
||||
(f) =>
|
||||
!f ||
|
||||
!safeName(f.p) ||
|
||||
!/^(Assets|Packages)\//.test(f.p) ||
|
||||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
|
||||
!/^[a-z0-9]{40}$/.test(f.n)
|
||||
) ||
|
||||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
|
||||
)
|
||||
return c.json(fail('Invalid badge image source manifest.'), 400)
|
||||
const main = manifest.Files.find((f) => f.p === manifest.MainAssetProjectRelativePath)
|
||||
const image = main ? await c.env.CDN_ASSETS.get('unityasset/' + main.n) : null
|
||||
if (!image || image.size > 16 * 1024 * 1024)
|
||||
return c.json(fail('Badge source image is missing or oversized.'), 400)
|
||||
const signature = new Uint8Array(await image.arrayBuffer()).subarray(0, 8)
|
||||
if (
|
||||
!(
|
||||
signature[0] === 137 &&
|
||||
signature[1] === 80 &&
|
||||
signature[2] === 78 &&
|
||||
signature[3] === 71
|
||||
) &&
|
||||
!(signature[0] === 255 && signature[1] === 216 && signature[2] === 255)
|
||||
)
|
||||
return c.json(fail('Badge source must be a PNG or JPEG image.'), 400)
|
||||
}
|
||||
for (const name of [body.ThumbnailFileName, body.ModerationImageFileName].filter(
|
||||
(v) => v !== undefined && v !== null
|
||||
)) {
|
||||
if (!safeName(name)) return c.json(fail('Invalid image filename.'), 400)
|
||||
const image = await c.env.CDN_ASSETS.get('image/' + name)
|
||||
if (!image || image.customMetadata?.ownerId !== String(id))
|
||||
return c.json(fail('Image upload does not belong to this account.'), 403)
|
||||
if (image.size > 16 * 1024 * 1024) return c.json(fail('Avatar image exceeds limit.'), 413)
|
||||
// Storage's image upload and the Images service use different buckets.
|
||||
// Keep the SDK filename unchanged when publishing creator-owned thumbnails.
|
||||
await c.env.IMAGES.put(name, image.body, { httpMetadata: image.httpMetadata })
|
||||
}
|
||||
const row = await c.env.DB.prepare(
|
||||
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,moderation_image,configuration,description,update_current,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) RETURNING id'
|
||||
)
|
||||
.bind(
|
||||
itemId,
|
||||
body.BodyType,
|
||||
unityId,
|
||||
filename,
|
||||
hash,
|
||||
body.ThumbnailFileName,
|
||||
typeof body.ModerationImageFileName === 'string' ? body.ModerationImageFileName : null,
|
||||
body.AdditionalConfiguration,
|
||||
body.SaveDescription ?? null,
|
||||
body.UpdateCurrentSaveIfPrivate === true ? 1 : 0,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.first<{ id: number }>()
|
||||
if (body.UpdateCurrentSaveIfPrivate === true && item.Accessibility === 0)
|
||||
await publishAvatarSave(c.env.DB, itemId, row!.id, true)
|
||||
return c.json(ok(row!.id))
|
||||
})
|
||||
.post(`/api/customAvatarItems/:id{${uuid}}/saves/:saveId{[1-9][0-9]*}/publish`, async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const item = await avatarStudioItem(c.env.DB, c.req.param('id'))
|
||||
if (!item) return c.notFound()
|
||||
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
|
||||
return c.body(null, 403)
|
||||
const save = await avatarStudioSave(
|
||||
c.env.DB,
|
||||
item.CustomAvatarItemId,
|
||||
Number(c.req.param('saveId'))
|
||||
)
|
||||
if (item.OutfitType === 1000 && save) {
|
||||
const badgeRoom = JSON.parse(save.configuration)?.d?.d?.r ?? 0
|
||||
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
|
||||
return c.body(null, 403)
|
||||
}
|
||||
if (!save || !(await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id)))
|
||||
return c.json(fail('Save has not completed a build.'), 400)
|
||||
return c.json(ok(null))
|
||||
})
|
||||
@@ -0,0 +1,338 @@
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AVATAR_STUDIO_SCHEMA,
|
||||
avatarSaveDto,
|
||||
avatarStudioSave,
|
||||
publishAvatarSave,
|
||||
ROOM_SCHEMA_DDL,
|
||||
UNITY_ASSET_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
import { generateToken } from '@repo/jwt'
|
||||
|
||||
import { SCHEMA_DDL } from '../../custom-avatar-items-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
const base = 'https://example.com/api/customAvatarItems'
|
||||
async function headers(id = 42, roles = ['betastudio']) {
|
||||
return {
|
||||
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
|
||||
}
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||
for (const sql of [
|
||||
...SCHEMA_DDL,
|
||||
...AVATAR_STUDIO_SCHEMA,
|
||||
...UNITY_ASSET_SCHEMA_DDL,
|
||||
...ROOM_SCHEMA_DDL,
|
||||
])
|
||||
await env.DB.prepare(sql).run()
|
||||
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
|
||||
})
|
||||
beforeEach(async () => {
|
||||
await env.DB.prepare('DELETE FROM avatar_studio_save').run()
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
await env.DB.prepare('UPDATE avatar_studio_access SET flags=1 WHERE account_id=42').run()
|
||||
})
|
||||
it('saves and publishes image badges directly, while enforcing badge grants and room ownership', async () => {
|
||||
expect((await create(42, ['betastudio'], 1000)).status).toBe(403)
|
||||
await env.DB.prepare('UPDATE avatar_studio_access SET flags=5 WHERE account_id=42').run()
|
||||
const foreign = new FormData()
|
||||
foreign.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Foreign badge', OutfitType: 1000, BadgeRoomId: 98765 })
|
||||
)
|
||||
expect(
|
||||
(await SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(), body: foreign }))
|
||||
.status
|
||||
).toBe(403)
|
||||
const { value: item } = await (
|
||||
await create(42, ['betastudio'], 1000)
|
||||
).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const png = Uint8Array.from(
|
||||
atob(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='
|
||||
),
|
||||
(c) => c.charCodeAt(0)
|
||||
)
|
||||
const main = 'b'.repeat(40)
|
||||
const bytes = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
Version: 3,
|
||||
MainAssetProjectRelativePath: 'Assets/Badge.png',
|
||||
Files: [{ n: main, p: 'Assets/Badge.png' }],
|
||||
})
|
||||
)
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
await env.CDN_ASSETS.put('avatarunityasset/badge-source', bytes, {
|
||||
sha256: digest,
|
||||
customMetadata: { ownerId: '42' },
|
||||
})
|
||||
await env.CDN_ASSETS.put('unityasset/' + main, png)
|
||||
await env.CDN_ASSETS.put('image/badge.png', png, {
|
||||
customMetadata: { ownerId: '42' },
|
||||
httpMetadata: { contentType: 'image/png' },
|
||||
})
|
||||
const url = base + '/' + item.CustomAvatarItemId + '/saves'
|
||||
const save = await SELF.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...(await headers()), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
NewUnityAssetFile: {
|
||||
Filename: 'badge-source',
|
||||
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
|
||||
},
|
||||
BodyType: 4,
|
||||
ThumbnailFileName: 'badge.png',
|
||||
AdditionalConfiguration: JSON.stringify({
|
||||
v: 1,
|
||||
d: { n: 'Badge', o: 1000, d: { t: '000000' } },
|
||||
}),
|
||||
UpdateCurrentSaveIfPrivate: true,
|
||||
}),
|
||||
})
|
||||
expect(save.status).toBe(200)
|
||||
const { value: saveId } = await save.json<{ value: number }>()
|
||||
const detail = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
|
||||
headers: await headers(),
|
||||
})
|
||||
expect(await detail.json()).toMatchObject({
|
||||
ThumbnailImageFilename: 'badge.png',
|
||||
CurrentSaves: [{ CustomAvatarItemSaveId: saveId, BodyType: 4, UnityAsset: null }],
|
||||
})
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(url + '/' + saveId + '/publish', {
|
||||
method: 'POST',
|
||||
headers: await headers(),
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
expect(await (await env.IMAGES.get('badge.png'))!.arrayBuffer()).toEqual(png.buffer)
|
||||
const published = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await headers()), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ Accessibility: 1 }),
|
||||
})
|
||||
expect(published.status).toBe(200)
|
||||
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(200)
|
||||
})
|
||||
async function create(id = 42, roles = ['betastudio'], outfitType = 0) {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Studio Hat', OutfitType: outfitType, Accessibility: 0, Price: 0 })
|
||||
)
|
||||
return SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(id, roles), body: form })
|
||||
}
|
||||
it('accepts the SDK create request with nullable metadata fields', async () => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
Name: 'SDK item',
|
||||
OutfitType: 101,
|
||||
BaseAvatarItemColor: '#101',
|
||||
BadgeRoomId: null,
|
||||
PreviewOrientation: null,
|
||||
Description: null,
|
||||
Tags: null,
|
||||
Price: null,
|
||||
Accessibility: null,
|
||||
})
|
||||
)
|
||||
const response = await SELF.fetch(base + '/v1', {
|
||||
method: 'POST',
|
||||
headers: await headers(),
|
||||
body: form,
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
value: { Description: '', Tags: [], Price: 0, Accessibility: 0, CurrentSaves: [] },
|
||||
})
|
||||
})
|
||||
it('keeps grants per-account, rejects forbidden item categories, and creates metadata-only Studio items', async () => {
|
||||
expect((await create(43)).status).toBe(403)
|
||||
expect((await create(42, [])).status).toBe(403)
|
||||
expect((await create(42, ['betastudio'], 500)).status).toBe(403)
|
||||
const response = await create()
|
||||
expect(response.status).toBe(200)
|
||||
const { value: item } = await response.json<{
|
||||
value: { CustomAvatarItemId: string; OutfitType: number; CurrentSaves: unknown[] }
|
||||
}>()
|
||||
expect(item.OutfitType).toBe(0)
|
||||
expect(item.CurrentSaves).toEqual([])
|
||||
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(403)
|
||||
expect(
|
||||
(await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, { headers: await headers() })).status
|
||||
).toBe(200)
|
||||
const page = await SELF.fetch(base + '/v1/me', { headers: await headers() })
|
||||
expect(await page.json()).toMatchObject({
|
||||
TotalResults: 1,
|
||||
Results: [{ CustomAvatarItemId: item.CustomAvatarItemId }],
|
||||
})
|
||||
})
|
||||
it('protects source history, verifies upload ownership and hash, and never publishes unbuilt revisions', async () => {
|
||||
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const url = base + '/' + item.CustomAvatarItemId + '/saves'
|
||||
const bytes = new TextEncoder().encode('{"Version":3}'),
|
||||
digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
await env.CDN_ASSETS.put('avatarunityasset/2026-10-18/source', bytes, {
|
||||
sha256: digest,
|
||||
customMetadata: { ownerId: '42' },
|
||||
})
|
||||
await env.CDN_ASSETS.put('image/thumb.png', 'thumbnail', {
|
||||
customMetadata: { ownerId: '42' },
|
||||
httpMetadata: { contentType: 'image/png' },
|
||||
})
|
||||
const body = {
|
||||
NewUnityAssetFile: {
|
||||
Filename: '2026-10-18/source',
|
||||
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
|
||||
},
|
||||
BodyType: 1,
|
||||
ThumbnailFileName: 'thumb.png',
|
||||
AdditionalConfiguration: '{}',
|
||||
UpdateCurrentSaveIfPrivate: true,
|
||||
}
|
||||
const post = async (value: unknown, id = 42) =>
|
||||
SELF.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value),
|
||||
})
|
||||
expect((await post(body, 43)).status).toBe(403)
|
||||
expect(
|
||||
(await post({ ...body, NewUnityAssetFile: { ...body.NewUnityAssetFile, Hash: 'wrong' } }))
|
||||
.status
|
||||
).toBe(400)
|
||||
const response = await post(body)
|
||||
expect(response.status).toBe(200)
|
||||
const { value: saveId } = await response.json<{ value: number }>()
|
||||
expect(await (await env.IMAGES.get('thumb.png'))?.text()).toBe('thumbnail')
|
||||
expect((await SELF.fetch(url, { headers: await headers(43) })).status).toBe(403)
|
||||
const history = await SELF.fetch(url, { headers: await headers() })
|
||||
expect(await history.json()).toMatchObject({
|
||||
success: true,
|
||||
value: {
|
||||
TotalResults: 1,
|
||||
Results: [
|
||||
{
|
||||
CustomAvatarItemSaveId: saveId,
|
||||
SourceUnityAssetFilename: body.NewUnityAssetFile.Filename,
|
||||
UnityAsset: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(url + '/' + saveId + '/publish', {
|
||||
method: 'POST',
|
||||
headers: await headers(),
|
||||
})
|
||||
).status
|
||||
).toBe(400)
|
||||
expect(
|
||||
(await post({ ...body, NewUnityAssetFile: null, ExistingUnityAssetId: crypto.randomUUID() }))
|
||||
.status
|
||||
).toBe(400)
|
||||
})
|
||||
it('allows creator metadata edits without exposing unbuilt items or changing ownership', async () => {
|
||||
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const edit = async (body: unknown, id = 42) =>
|
||||
SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
expect((await edit({ Name: 'Other owner' }, 43)).status).toBe(403)
|
||||
expect((await edit({ Accessibility: 1 })).status).toBe(400)
|
||||
expect((await edit({ Tags: [{ TagType: 1, Value: '' }] })).status).toBe(400)
|
||||
const response = await edit({
|
||||
Name: ' Updated hat ',
|
||||
Description: 'Edited in Studio',
|
||||
Tags: [{ TagType: 1, Value: 'hat' }],
|
||||
CreatorAccountId: 43,
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
value: {
|
||||
Name: 'Updated hat',
|
||||
Description: 'Edited in Studio',
|
||||
CreatorAccountId: 42,
|
||||
Accessibility: 0,
|
||||
Tags: [{ TagType: 1, Value: 'hat' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('isolates outputs across reused-source bodies/configurations and preserves original restore', async () => {
|
||||
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const asset = crypto.randomUUID()
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) VALUES (100,?,1,?,'same-source','same-hash','thumb.png','{}','now')"
|
||||
)
|
||||
.bind(item.CustomAvatarItemId, asset)
|
||||
.run()
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO unity_asset VALUES (?,0,5,'main','original.bundle','original-hash')"
|
||||
)
|
||||
.bind(asset)
|
||||
.run()
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
|
||||
await env.CDN_ASSETS.put('image/thumb.png', 'fixture', { customMetadata: { ownerId: '42' } })
|
||||
const url = base + '/' + item.CustomAvatarItemId + '/saves'
|
||||
const save = async (bodyType: number, config: string) => {
|
||||
const response = await SELF.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...(await headers()), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ExistingUnityAssetId: asset,
|
||||
BodyType: bodyType,
|
||||
AdditionalConfiguration: config,
|
||||
ThumbnailFileName: 'thumb.png',
|
||||
UpdateCurrentSaveIfPrivate: true,
|
||||
}),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const { value: id } = await response.json<{ value: number }>()
|
||||
return (await avatarStudioSave(env.DB, item.CustomAvatarItemId, id))!
|
||||
}
|
||||
const metadata = await save(1, '{}')
|
||||
expect(metadata.unity_asset_id).toBe(asset)
|
||||
const otherBody = await save(2, '{}'),
|
||||
otherConfig = await save(1, '{"variant":2}')
|
||||
for (const revision of [otherBody, otherConfig]) {
|
||||
expect(revision.unity_asset_id).not.toBe(asset)
|
||||
expect(revision.source_filename).toBe('same-source')
|
||||
expect(revision.source_hash).toBe('same-hash')
|
||||
expect((await avatarSaveDto(env.DB, revision)).UnityAsset).toBeNull()
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, revision.id)).toBe(false)
|
||||
}
|
||||
expect(otherBody.unity_asset_id).not.toBe(otherConfig.unity_asset_id)
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO unity_asset VALUES (?,0,5,'main','other-body.bundle','other-hash')"
|
||||
)
|
||||
.bind(otherBody.unity_asset_id)
|
||||
.run()
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, otherBody.id)).toBe(true)
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
|
||||
const stored = JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
|
||||
.bind(item.CustomAvatarItemId)
|
||||
.first<{ data: string }>())!.data
|
||||
)
|
||||
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 1).UnityAsset).toBe(
|
||||
'original.bundle'
|
||||
)
|
||||
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 2).UnityAsset).toBe(
|
||||
'other-body.bundle'
|
||||
)
|
||||
})
|
||||
@@ -15,6 +15,8 @@ authenticates players and issues the JWTs every other worker verifies.
|
||||
| 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 | `/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/moderator/:id` | Moderator role lookup; a bare JSON boolean |
|
||||
| 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`.
|
||||
|
||||
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload,
|
||||
and developer does not include it. It is stamped from the account's `hasStudio` flag.
|
||||
Staff add and remove people at `/settings/recroomstudio` on the website (or
|
||||
and developer does not include it. It is stamped from the account's `hasStudio` flag,
|
||||
or from a Discord link holding a role named in `STUDIO_ROLLOUT`
|
||||
(`RECFLARE_STUDIO_ROLLOUT` in the root `.env`) — a staged Studio rollout to a Discord
|
||||
role, read from the same role snapshot as the build rollout gate. Staff add and remove
|
||||
individual people at `/settings/recroomstudio` on the website (or
|
||||
`runx admin grant-studio`). A player already signed into Studio keeps the old token
|
||||
until the next sign-in or refresh.
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -4,11 +4,16 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
beginEmailCode,
|
||||
completeEmailCode,
|
||||
countAccountsByDeviceId,
|
||||
countAccountsBySignupIp,
|
||||
createAccount,
|
||||
EMAIL_CODE_TTL_SECONDS,
|
||||
emailSender,
|
||||
GAME_VERSION,
|
||||
getAccount,
|
||||
getAccountByEmail,
|
||||
getAccountByUsername,
|
||||
getAccountsByIds,
|
||||
getPasswordHash,
|
||||
@@ -16,6 +21,7 @@ import {
|
||||
hashPassword,
|
||||
isRolloutGameVersion,
|
||||
isSupportedGameVersion,
|
||||
isValidEmail,
|
||||
rolloutGameBuilds,
|
||||
RoomInstanceType,
|
||||
setLastLoginTime,
|
||||
@@ -37,7 +43,12 @@ import {
|
||||
withNotFound,
|
||||
withOnError,
|
||||
} from '@repo/hono-helpers'
|
||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||
import {
|
||||
generateToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
validateAndGetAccountId,
|
||||
validateIdentityAssertion,
|
||||
} from '@repo/jwt'
|
||||
|
||||
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
|
||||
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
|
||||
@@ -58,6 +69,9 @@ import {
|
||||
OAuthError,
|
||||
PlatformIdsRequest,
|
||||
PlatformType,
|
||||
RecoverPasswordRequest,
|
||||
RecoverPasswordResponse,
|
||||
ResetPasswordRequest,
|
||||
RestrictionDto,
|
||||
roleFilter,
|
||||
roleLookup,
|
||||
@@ -65,14 +79,18 @@ import {
|
||||
TokenResponse,
|
||||
} from './openapi'
|
||||
import {
|
||||
accountHoldsDiscordRole,
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForAccount,
|
||||
getLinksForIdentity,
|
||||
getLinksForPlatformId,
|
||||
getLinksForPlatformIdentity,
|
||||
hasStudioAccess,
|
||||
isPlatformIdentityLinked,
|
||||
linkPlatformIdentity,
|
||||
parseRoleIdList,
|
||||
} from './platform-db'
|
||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||
import { recoveryEmail } from './recovery-email'
|
||||
import { consumeRefreshToken, issueRefreshToken, revokeRefreshTokens } from './refresh-db'
|
||||
import { verifySteamTicket } from './steam-ticket'
|
||||
import {
|
||||
approveDeviceGrant,
|
||||
@@ -108,6 +126,21 @@ const TOKEN_SCOPE =
|
||||
*/
|
||||
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
||||
|
||||
/**
|
||||
* The website's "Sign in with Discord". `www` completes the Discord OAuth2 exchange (it
|
||||
* alone holds the client secret), then posts an `assertion` — an HS256 token signed with
|
||||
* the shared JWT_SECRET naming the platform identity it just proved (see `@repo/jwt`'s
|
||||
* `generateIdentityAssertion`). This grant trusts the assertion the way `cached_login`
|
||||
* trusts a Steam ticket, resolves it through the link table, and issues the ordinary
|
||||
* session. No account linked to the identity means a NEW one is made and linked, so a
|
||||
* Discord account is a way to join without Steam or Meta; the player then sets a password
|
||||
* on the website and signs in with it anywhere — a sideloaded Quest included.
|
||||
*/
|
||||
const IDENTITY_ASSERTION_GRANT = 'identity_assertion'
|
||||
|
||||
/** What the grant answers when one identity opens several accounts; never true today. */
|
||||
const AMBIGUOUS_IDENTITY_DESCRIPTION = 'platform identity is linked to more than one account'
|
||||
|
||||
/**
|
||||
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
|
||||
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
|
||||
@@ -209,16 +242,11 @@ const UNSUPPORTED_BUILD_DESCRIPTION =
|
||||
|
||||
/**
|
||||
* `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or
|
||||
* whitespace (the same grammar `www`'s parseRoleIds reads DISCORD_BENEFITS_ROLE_IDS with).
|
||||
* Unset or blank parses to an empty list, which `accountMayUseRolloutBuild` treats as a
|
||||
* gate closed to everyone but staff.
|
||||
* whitespace (`parseRoleIdList`, the grammar `www`'s parseRoleIds reads
|
||||
* DISCORD_BENEFITS_ROLE_IDS with). Unset or blank parses to an empty list, which
|
||||
* `accountMayUseRolloutBuild` treats as a gate closed to everyone but staff.
|
||||
*/
|
||||
function rolloutRoleIds(raw: string | undefined): string[] {
|
||||
return (raw ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id !== '')
|
||||
}
|
||||
const rolloutRoleIds = parseRoleIdList
|
||||
|
||||
/**
|
||||
* Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator
|
||||
@@ -239,11 +267,7 @@ async function accountMayUseRolloutBuild(
|
||||
): Promise<boolean> {
|
||||
const account = await getAccount(db, accountId)
|
||||
if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true
|
||||
if (roleIds.length === 0) return false
|
||||
const links = await getLinksForAccount(db, accountId)
|
||||
return links.some(
|
||||
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
|
||||
)
|
||||
return accountHoldsDiscordRole(db, accountId, roleIds)
|
||||
}
|
||||
|
||||
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
|
||||
@@ -317,8 +341,8 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
|
||||
/** Whether one account row holds a role. Absent/false both mean no role. */
|
||||
function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean {
|
||||
// Matches accountTokenRoles: screenshare is a feature gate on every account token.
|
||||
if (role === 'screenshare') return true
|
||||
// Match the same staff override used when minting account tokens.
|
||||
if (role === 'screenshare') return account.hasScreenshare === true
|
||||
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
|
||||
}
|
||||
|
||||
@@ -368,30 +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
|
||||
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
|
||||
* client gates the screen-share feature on it and nothing grants it per-account, so it
|
||||
* is unconditional (even with no account resolved). The rest are the operator-granted
|
||||
* extras, plus `junior` off the account's own `isJunior` flag.
|
||||
* (gameClient) are added by generateToken. `screenshare` requires an explicit staff
|
||||
* grant via `hasScreenshare`. The other roles come from account flags and Studio access.
|
||||
*
|
||||
* `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it.
|
||||
* RecFlare Studio treats that exact claim as Full access, which is what lets the editor
|
||||
* upload; staff set the flag from the website. `influencer` rides on `isInfluencer`
|
||||
* (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup
|
||||
* answers it. Order is stable so tokens are deterministic: screenshare, developer,
|
||||
* moderator, junior, betastudio, then influencer.
|
||||
* `betastudio` rides on `studio` — the account's Studio access as `hasStudioAccess`
|
||||
* resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding
|
||||
* a role in the `STUDIO_ROLLOUT` var — and developer does not imply it. RecFlare Studio
|
||||
* treats that exact claim as Full access, which is what lets the editor upload. The
|
||||
* caller resolves it (it costs a link read) so this stays a pure projection of the
|
||||
* account. `influencer` rides on `isInfluencer` (`runx admin grant-influencer`) and lives
|
||||
* only in the token — no `/role/*` lookup answers it. Order is stable so tokens are
|
||||
* deterministic: screenshare, developer, moderator, junior, betastudio, then influencer.
|
||||
*/
|
||||
function accountRoles(
|
||||
account: Pick<
|
||||
Account,
|
||||
'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer'
|
||||
> | null
|
||||
'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer' | 'hasScreenshare'
|
||||
> | null,
|
||||
studio: boolean
|
||||
): string[] {
|
||||
const roles = ['screenshare']
|
||||
const roles: string[] = account?.hasScreenshare === true ? ['screenshare'] : []
|
||||
if (!account) return roles
|
||||
if (account.isDeveloper) roles.push('developer')
|
||||
if (account.isModerator) roles.push('moderator')
|
||||
if (account.isJunior) roles.push('junior')
|
||||
if (account.hasStudio) roles.push('betastudio')
|
||||
if (studio) roles.push('betastudio')
|
||||
if (account.isInfluencer) roles.push('influencer')
|
||||
return roles
|
||||
}
|
||||
@@ -443,7 +468,13 @@ function oauthError(
|
||||
* already been authenticated — here, because the player approved a Studio code
|
||||
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
|
||||
*/
|
||||
async function issueSession(c: Context<App>, accountId: number, grantType: string) {
|
||||
async function issueSession(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
grantType: string,
|
||||
/** Extra top-level fields on the token body — `created: true` on an identity signup. */
|
||||
extra: Record<string, unknown> = {}
|
||||
) {
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
const ban = await resolveBan(c.env.DB, accountId, {
|
||||
identity: { ip: clientIp },
|
||||
@@ -473,7 +504,7 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
|
||||
roleAccount.platformId ?? '',
|
||||
accountPlatform(roleAccount),
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount),
|
||||
accountRoles(roleAccount, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)),
|
||||
accountPrivileges(roleAccount),
|
||||
GAME_VERSION,
|
||||
roleAccount.hasPlus === true
|
||||
@@ -516,9 +547,109 @@ async function issueSession(c: Context<App>, accountId: number, grantType: strin
|
||||
refresh_token: refreshToken,
|
||||
scope: TOKEN_SCOPE,
|
||||
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `identity_assertion` grant: sign into — or create — the account behind a platform
|
||||
* identity another worker has verified and signed for. See IDENTITY_ASSERTION_GRANT.
|
||||
*
|
||||
* Handled before the password fallback and outside the version/rollout gates, like the
|
||||
* Studio device grant: nothing here posts a `ver`, and the credential is the assertion
|
||||
* alone. The assertion names an IDENTITY, not an account; which account that is (or that
|
||||
* there is none yet) is decided here, from the same table `cached_login` reads, so the
|
||||
* website can never choose an account id for itself.
|
||||
*
|
||||
* Creating an account when the identity opens none is deliberate — it is the Discord
|
||||
* sign-up — and takes the same guards `create_account` does for a verified platform
|
||||
* identity: the evasion arms (a banned player's next move is a new account), the per-IP
|
||||
* cap (`www` forwards the browser's real address over the service binding) and the
|
||||
* per-identity cap. NOT the PASSWORD_SIGNUP switch: that exists because a password account
|
||||
* has no identity but its address, and this one has a Discord user Discord itself vouched
|
||||
* for, one account per user. The new account carries no `platform`/`platformId` on its
|
||||
* blob — only the link — so its DTO and token read like a password account's and the
|
||||
* Discord snowflake stays off the wire, exactly as the benefits claim keeps it.
|
||||
*/
|
||||
async function identityAssertionGrant(c: Context<App>, body: Record<string, unknown>) {
|
||||
const assertion = typeof body.assertion === 'string' ? body.assertion : ''
|
||||
const jwtSecret = await c.env.JWT_SECRET.get()
|
||||
if (jwtSecret === '') {
|
||||
logger.error('refusing an identity assertion: JWT_SECRET is empty')
|
||||
return oauthError(c, 'server_error', 'token signing is not configured', 500)
|
||||
}
|
||||
const identity = assertion ? await validateIdentityAssertion(assertion, jwtSecret) : null
|
||||
if (identity === null) {
|
||||
return oauthError(c, 'invalid_grant', 'invalid or expired identity assertion')
|
||||
}
|
||||
|
||||
const links = await getLinksForIdentity(c.env.DB, identity.platform, identity.platformId)
|
||||
if (links.length > 1) {
|
||||
// The benefits claim keeps a Discord identity on one account, so this is a data
|
||||
// problem rather than a player's choice to make; refused rather than guessed.
|
||||
logger.error('identity assertion names an identity linked to several accounts', {
|
||||
platform: identity.platform,
|
||||
accountIds: links.map((l) => l.accountId),
|
||||
})
|
||||
return oauthError(c, 'invalid_grant', AMBIGUOUS_IDENTITY_DESCRIPTION)
|
||||
}
|
||||
if (links.length === 1) {
|
||||
return issueSession(c, links[0].accountId, IDENTITY_ASSERTION_GRANT)
|
||||
}
|
||||
|
||||
// Nobody yet: the sign-up. Everything below mirrors create_account's platform path.
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
const blocked = await resolveBan(c.env.DB, null, {
|
||||
identity: { ip: clientIp, platform: identity.platform, platformId: identity.platformId },
|
||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||
})
|
||||
if (blocked) {
|
||||
logger.info('identity signup refused: player banned', {
|
||||
via: blocked.via,
|
||||
bannedAccountId: blocked.bannedAccountId,
|
||||
ip: clientIp,
|
||||
platform: identity.platform,
|
||||
})
|
||||
return oauthError(c, 'invalid_grant', BLOCKED_DESCRIPTION)
|
||||
}
|
||||
const maxPerPlatformId = intVar(
|
||||
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
|
||||
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
|
||||
)
|
||||
if (
|
||||
maxPerPlatformId > 0 &&
|
||||
(await countAccountsForPlatformIdentity(c.env.DB, identity.platform, identity.platformId)) >=
|
||||
maxPerPlatformId
|
||||
) {
|
||||
logger.info('identity signup rejected: platform account limit', { platform: identity.platform })
|
||||
return oauthError(c, 'invalid_grant', 'account limit reached for this platform account')
|
||||
}
|
||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||
if (
|
||||
maxPerIp > 0 &&
|
||||
clientIp !== '' &&
|
||||
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
|
||||
) {
|
||||
logger.info('identity signup rejected: per-IP account limit', { ip: clientIp })
|
||||
return oauthError(c, 'invalid_grant', 'too many accounts created from this network')
|
||||
}
|
||||
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: 0,
|
||||
lastLoginTime: new Date().toISOString(),
|
||||
signupIp: clientIp || undefined,
|
||||
lastLoginIp: clientIp || undefined,
|
||||
})
|
||||
await linkPlatformIdentity(c.env.DB, account.accountId, identity.platform, identity.platformId)
|
||||
await placeNewPlayerInOrientation(c.env, account.accountId, 0)
|
||||
logger.info('created an account from an identity assertion', {
|
||||
accountId: account.accountId,
|
||||
platform: identity.platform,
|
||||
ip: clientIp,
|
||||
})
|
||||
return issueSession(c, account.accountId, IDENTITY_ASSERTION_GRANT, { created: true })
|
||||
}
|
||||
|
||||
async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
@@ -922,7 +1053,7 @@ const app = new Hono<App>()
|
||||
tags: ['Token'],
|
||||
summary: 'OAuth token endpoint — issues a JWT',
|
||||
description: [
|
||||
'Issues an access token (plus a single-use refresh token) for one of four grants,',
|
||||
'Issues an access token (plus a single-use refresh token) for one of six grants,',
|
||||
'selected by `grant_type`. Every grant returns the same body on success.',
|
||||
'',
|
||||
'**`create_account`** — mints a new account with an auto-assigned random username',
|
||||
@@ -945,6 +1076,17 @@ const app = new Hono<App>()
|
||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
||||
'platform and platform id come from what was stored at issue time, not the body.',
|
||||
'',
|
||||
'**`identity_assertion`** — the website’s "Sign in with Discord". `www` completes the',
|
||||
'Discord OAuth2 code exchange (it alone holds the client secret) and posts an',
|
||||
'`assertion`: an HS256 token signed with the shared JWT_SECRET naming the platform',
|
||||
'(`101`) and platform id it proved, good for a minute. The identity is resolved through',
|
||||
'the same link table as `cached_login`: one linked account is signed into; none means a',
|
||||
'NEW account is created and linked (the body then carries `created: true`), under the',
|
||||
'evasion check and the per-IP and per-identity signup caps but NOT the PASSWORD_SIGNUP',
|
||||
'switch — the Discord user is the identity a password account lacks. The new account',
|
||||
'has no password until its owner sets one on the website; with one, it signs in',
|
||||
'anywhere the `password` grant does. Several linked accounts is refused.',
|
||||
'',
|
||||
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
||||
'identifies the account by `username` or numeric `account_id` and requires the',
|
||||
'matching `password`. An account with no stored hash cannot be logged into at all,',
|
||||
@@ -972,9 +1114,10 @@ const app = new Hono<App>()
|
||||
'',
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
'powers refresh on every login and every refresh grant. `junior` rides along for an',
|
||||
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
|
||||
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
|
||||
'from the account’s `hasStudio` flag (staff set it on the website). RecFlare Studio',
|
||||
'account flagged `isJunior`. `screenshare` requires a staff grant on the Studio Access',
|
||||
'page. `betastudio` is stamped',
|
||||
'from the account’s `hasStudio` flag (staff set it on the website) or from a Discord',
|
||||
'link holding a role in the `STUDIO_ROLLOUT` var. RecFlare Studio',
|
||||
'treats that claim as permission to upload; `developer` does not confer it. A junior',
|
||||
'also carries',
|
||||
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
|
||||
@@ -1072,6 +1215,9 @@ const app = new Hono<App>()
|
||||
}
|
||||
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT)
|
||||
}
|
||||
// The website's Discord sign-in, likewise before the fallback — and before the
|
||||
// version gates, which it never posts a `ver` for. See identityAssertionGrant.
|
||||
if (grantType === IDENTITY_ASSERTION_GRANT) return identityAssertionGrant(c, body)
|
||||
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
||||
// grant overrides them below with what was stored when the token was issued.
|
||||
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
||||
@@ -1565,7 +1711,10 @@ const app = new Hono<App>()
|
||||
platformId,
|
||||
platform,
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount),
|
||||
accountRoles(
|
||||
roleAccount,
|
||||
await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)
|
||||
),
|
||||
accountPrivileges(roleAccount),
|
||||
version,
|
||||
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
|
||||
@@ -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:
|
||||
// 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
|
||||
|
||||
@@ -19,6 +19,22 @@ export type Env = SharedHonoEnv & {
|
||||
// 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.
|
||||
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).
|
||||
// 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
|
||||
@@ -64,6 +80,18 @@ export type Env = SharedHonoEnv & {
|
||||
* Logins on every other build are never touched by this.
|
||||
*/
|
||||
DISCORD_ROLLOUT_ROLE_IDS?: string
|
||||
/**
|
||||
* The Discord role ids whose holders get Rec Room Studio upload access — stamped into the
|
||||
* token as `betastudio` exactly as the account's `hasStudio` flag is, so a whole Discord
|
||||
* role can be let into Studio without touching anyone's account. Same grammar as
|
||||
* DISCORD_ROLLOUT_ROLE_IDS above (snowflakes, commas and/or whitespace; ANY one qualifies),
|
||||
* read from the same Discord link snapshot. Set for every worker from the root `.env`
|
||||
* (`RECFLARE_STUDIO_ROLLOUT`): `studio` and `www` read it through the same
|
||||
* `hasStudioAccess`, so what the token says, what the editor's access lookup says and
|
||||
* what the website's `/api/studio-access` says agree. Unset, or naming no role, grants
|
||||
* nobody — the flag alone decides. The flag still works beside it.
|
||||
*/
|
||||
STUDIO_ROLLOUT?: string
|
||||
/**
|
||||
* The client builds a `/connect/token` grant may post as `ver` and still get a token:
|
||||
* a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means
|
||||
|
||||
@@ -143,10 +143,12 @@ export const TokenRequest = z.object({
|
||||
'refresh_token',
|
||||
'password',
|
||||
'urn:ietf:params:oauth:grant-type:device_code',
|
||||
'identity_assertion',
|
||||
])
|
||||
.describe(
|
||||
'Anything unrecognised (including absent) is treated as a password grant, except the ' +
|
||||
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization'
|
||||
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization, ' +
|
||||
'and identity_assertion, the website’s Discord sign-in'
|
||||
),
|
||||
account_id: z.string().optional().describe('Numeric account id, as a string'),
|
||||
username: z
|
||||
@@ -183,6 +185,13 @@ export const TokenRequest = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
|
||||
assertion: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'identity_assertion grant: the HS256 assertion `www` minted with the shared JWT_SECRET ' +
|
||||
'after completing a Discord OAuth2 exchange, naming the platform and platform_id it proved'
|
||||
),
|
||||
device_id: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -240,6 +249,30 @@ export const ChangePasswordResponse = z.object({
|
||||
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.
|
||||
* 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`,
|
||||
description:
|
||||
role === 'screenshare'
|
||||
? 'Returns a bare JSON boolean. Screenshare is enabled for every existing account, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
|
||||
? 'Returns a bare JSON boolean. Screenshare is disabled by default and requires an explicit staff grant, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
|
||||
: `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
|
||||
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
|
||||
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
|
||||
|
||||
@@ -314,6 +314,147 @@ export async function getLinksForPlatformId(
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every account a platform identity is linked to, on ANY platform — including the ones
|
||||
* {@link CACHED_LOGIN_PLATFORMS} keeps out of the picker.
|
||||
*
|
||||
* This is the unfiltered twin of {@link getLinksForPlatformIdentity}, and it must stay off
|
||||
* every public route: the filter there exists so nobody can learn which account a Discord
|
||||
* user owns by naming their snowflake. The one caller is `auth`'s `identity_assertion`
|
||||
* grant, where the identity being asked about has just been PROVEN by the worker that
|
||||
* minted the assertion — the website's Discord sign-in — so the answer goes to the person
|
||||
* it is about and nobody else. Add a caller only under the same condition.
|
||||
*/
|
||||
export async function getLinksForIdentity(
|
||||
db: D1Database,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
||||
)
|
||||
.bind(platform, platformId)
|
||||
.all<LinkRow>()
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any of the account's links on `platform` has roles RECORDED — a non-null `role`
|
||||
* column, which only the website's benefits claim (and the sweep behind it) ever writes.
|
||||
*
|
||||
* This is what "has this account claimed its Discord benefits" means now that a Discord
|
||||
* link can exist WITHOUT a claim: signing in with Discord (`auth`'s `identity_assertion`
|
||||
* grant) links the identity on a brand-new account with `role` NULL. The claim's one-time
|
||||
* gift and its page both ask this rather than "is there a link", so a player who made
|
||||
* their account from Discord is still owed, and still shown, their first claim.
|
||||
* An EMPTY role list counts as recorded: that is a claim that found no roles.
|
||||
*/
|
||||
export async function hasRecordedRoles(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number
|
||||
): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT 1 AS ok FROM platform_account
|
||||
WHERE account_id = ?1 AND platform = ?2 AND role IS NOT NULL LIMIT 1`
|
||||
)
|
||||
.bind(accountId, platform)
|
||||
.first<{ ok: number }>()
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Record roles on an existing link that has NONE yet (`role` NULL), atomically. True when
|
||||
* this call was the one that did it; false when the link is missing or already carries a
|
||||
* role list — in which case nothing is written, and {@link linkPlatformIdentity}'s refresh
|
||||
* is the caller's next step.
|
||||
*
|
||||
* The one-shot flip is the point: the benefits claim pays its first-claim gift on the
|
||||
* INSERT of a fresh link, which two racing claims cannot both win. A link made by a Discord
|
||||
* sign-in is already inserted by the time the claim comes, so this gives the claim the
|
||||
* same single-winner write on the roles column instead.
|
||||
*/
|
||||
export async function recordFirstRoles(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string,
|
||||
roles: readonly string[]
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const res = await db
|
||||
.prepare(
|
||||
`UPDATE platform_account SET role = ?4
|
||||
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3 AND role IS NULL`
|
||||
)
|
||||
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
|
||||
.run()
|
||||
return res.meta.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of Discord role ids out of an operator var — numeric snowflakes separated by
|
||||
* commas and/or whitespace, the grammar every `*_ROLE_IDS`-shaped var here shares
|
||||
* (`auth`'s DISCORD_ROLLOUT_ROLE_IDS, `STUDIO_ROLLOUT`; `www` reads
|
||||
* DISCORD_BENEFITS_ROLE_IDS the same way). Unset or blank parses to an empty list.
|
||||
*/
|
||||
export function parseRoleIdList(raw: string | undefined): string[] {
|
||||
return (raw ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account's Discord link records ANY of `roleIds`. False with none configured —
|
||||
* the link read is skipped, so a var that names no role grants nobody anything. Roles are
|
||||
* the SNAPSHOT the website's benefits claim and `www`'s daily sweep keep (see
|
||||
* {@link PlatformLink.roles}), so a role granted in Discord today counts once the player
|
||||
* re-claims or the sweep runs — not instantly.
|
||||
*/
|
||||
export async function accountHoldsDiscordRole(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
roleIds: readonly string[]
|
||||
): Promise<boolean> {
|
||||
if (roleIds.length === 0) return false
|
||||
const links = await getLinksForAccount(db, accountId)
|
||||
return links.some(
|
||||
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account has Rec Room Studio upload access — what `auth` stamps into the
|
||||
* token as the `betastudio` role, `studio` answers on its access lookups and `www`'s
|
||||
* `/api/studio-access` reports. Two grants, either suffices:
|
||||
*
|
||||
* - the account's own `hasStudio` flag, set by staff on the website or
|
||||
* `runx admin grant-studio`;
|
||||
* - the STUDIO ROLLOUT: a Discord link holding a role named in the `STUDIO_ROLLOUT` var
|
||||
* (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`, reaching every worker), which hands a
|
||||
* whole Discord role the same access without touching anyone's account. Unset, or
|
||||
* naming no role, it grants nobody — the flag alone decides, as before.
|
||||
*
|
||||
* Every reader goes through this so the three surfaces cannot disagree: a role holder
|
||||
* whom `auth` stamps `betastudio` for must also be told "granted" by the website, and
|
||||
* `studio`'s lookups (which rebuild the role from the account rather than a token) must
|
||||
* see the same answer. The flag is checked first so an account that has it costs no
|
||||
* link read.
|
||||
*/
|
||||
export async function hasStudioAccess(
|
||||
db: D1Database,
|
||||
account: { accountId: number; hasStudio?: boolean } | null | undefined,
|
||||
studioRolloutRoleIds: string | undefined
|
||||
): Promise<boolean> {
|
||||
if (!account) return false
|
||||
if (account.hasStudio === true) return true
|
||||
return accountHoldsDiscordRole(db, account.accountId, parseRoleIdList(studioRolloutRoleIds))
|
||||
}
|
||||
|
||||
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
||||
export async function getLinksForAccount(
|
||||
db: D1Database,
|
||||
@@ -348,6 +489,40 @@ export async function isPlatformIdentityLinked(
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove EVERY link an account holds on one platform, and return the links that were
|
||||
* removed (empty when it held none). The website's Discord unlink is the one caller:
|
||||
* a player taking their Discord off their account, which gives the identity back —
|
||||
* "Sign in with Discord" with it afterwards makes a NEW account, and the benefits claim's
|
||||
* once-only guard lets it claim elsewhere again.
|
||||
*
|
||||
* The removed rows are returned rather than a count because the caller needs to know what
|
||||
* they carried: a link whose `role` was recorded is a CLAIM, and the account must remember
|
||||
* having made one after the row is gone (see `account.hasClaimedDiscord`), or the
|
||||
* first-link gift would pay again on every link/unlink cycle. Read-then-delete, not
|
||||
* `DELETE … RETURNING`, so it runs on every D1 the tests do.
|
||||
*
|
||||
* Not for Steam or Meta: those links are what a cached login redeems, and nothing on the
|
||||
* website removes one. The helper doesn't refuse them — the table has no idea what a
|
||||
* platform is for — so a caller must only ever name a platform nobody logs in with.
|
||||
*/
|
||||
export async function unlinkPlatform(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number
|
||||
): Promise<PlatformLink[]> {
|
||||
const { results } = await db
|
||||
.prepare(`${SELECT_LINK} WHERE account_id = ?1 AND platform = ?2 ORDER BY linked_at`)
|
||||
.bind(accountId, platform)
|
||||
.all<LinkRow>()
|
||||
if (results.length === 0) return []
|
||||
await db
|
||||
.prepare(`DELETE FROM platform_account WHERE account_id = ?1 AND platform = ?2`)
|
||||
.bind(accountId, platform)
|
||||
.run()
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many accounts one platform identity already opens — the count both signup caps
|
||||
* and link caps are enforced against, so an identity can't accumulate accounts by
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -99,3 +99,17 @@ export async function consumeRefreshToken(db: D1Database, token: string): Promis
|
||||
if (!row || row.expiresAt < now) return null
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import '../../auth.app'
|
||||
|
||||
import {
|
||||
AUDIT_LOG_SCHEMA_DDL,
|
||||
EMAIL_CODE_SCHEMA_DDL,
|
||||
GAME_VERSION,
|
||||
getAccount,
|
||||
getAccountsByDeviceId,
|
||||
getPasswordHash,
|
||||
hashPassword,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
@@ -16,8 +19,14 @@ import {
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
|
||||
import {
|
||||
generateIdentityAssertion,
|
||||
generateToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
validateIdentityAssertion,
|
||||
} from '@repo/jwt'
|
||||
|
||||
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||
import {
|
||||
banFromReport,
|
||||
createReport,
|
||||
@@ -31,6 +40,7 @@ import {
|
||||
PLATFORM_BACKFILL_SQL,
|
||||
PLATFORM_SCHEMA_DDL,
|
||||
} from '../../platform-db'
|
||||
import { resetPasswordUrl } from '../../recovery-email'
|
||||
import { consumeRefreshToken, issueRefreshToken, REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
||||
import {
|
||||
STUDIO_CLIENT_ID,
|
||||
@@ -72,6 +82,8 @@ beforeAll(async () => {
|
||||
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 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
|
||||
// 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()
|
||||
@@ -102,6 +114,9 @@ beforeAll(async () => {
|
||||
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
|
||||
// the token grant reads it for the evasion arms.
|
||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Custom avatar items (owned by the api worker) — every room read joins the published
|
||||
// room badges from this table.
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
@@ -682,8 +697,8 @@ describe('auth worker routes', () => {
|
||||
expect(payload.iss).toBe('https://auth.recflare.net')
|
||||
expect(payload.aud).toBe('https://auth.recflare.net')
|
||||
expect(payload.role).toContain('gameClient')
|
||||
// screenshare is a feature gate, not a grant — every token carries it.
|
||||
expect(payload.role).toContain('screenshare')
|
||||
// Screen sharing is disabled until staff explicitly grant it.
|
||||
expect(payload.role).not.toContain('screenshare')
|
||||
// A plain adult account carries nothing beyond those — no elevated roles.
|
||||
expect(payload.role).not.toContain('developer')
|
||||
expect(payload.role).not.toContain('moderator')
|
||||
@@ -740,6 +755,37 @@ describe('auth worker routes', () => {
|
||||
// refresh, and the Studio device grant — and a refresh after a revoke drops it. A
|
||||
// token that kept the role for the rest of the day would leave a removed player
|
||||
// uploading.
|
||||
test('screen sharing requires a staff grant on login, refresh, and role lookup', async () => {
|
||||
await seedAccount(9819, 'ScreenSharePlayer')
|
||||
expect((await tokenFor(`account_id=9819&password=${LOGIN_PASSWORD}`)).role).not.toContain(
|
||||
'screenshare'
|
||||
)
|
||||
await updateAccount(env.DB, 9819, { hasScreenshare: true })
|
||||
const granted = await tokenFor(`account_id=9819&password=${LOGIN_PASSWORD}`)
|
||||
expect(granted.role).toContain('screenshare')
|
||||
expect(await (await exports.default.fetch(`${ORIGIN}/role/screenshare/9819`)).json()).toBe(true)
|
||||
const session = await postToken(`account_id=9819&password=${LOGIN_PASSWORD}`)
|
||||
const sessionBody = session.json as { refresh_token: string }
|
||||
await updateAccount(env.DB, 9819, { hasScreenshare: false })
|
||||
const refresh = await exports.default.fetch(`${ORIGIN}/connect/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: sessionBody.refresh_token,
|
||||
}),
|
||||
})
|
||||
expect(refresh.status).toBe(200)
|
||||
const refreshed = (await refresh.json()) as { access_token: string }
|
||||
const payload = JSON.parse(
|
||||
atob(refreshed.access_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
|
||||
)
|
||||
expect(payload.role).not.toContain('screenshare')
|
||||
expect(await (await exports.default.fetch(`${ORIGIN}/role/screenshare/9819`)).json()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /connect/token stamps betastudio only while the account has hasStudio', async () => {
|
||||
// 9811/9812, not 9601–9605: those ids belong to the staff-login audit tests,
|
||||
// which insert with OR IGNORE and then count `staff_login` rows.
|
||||
@@ -751,7 +797,6 @@ describe('auth worker routes', () => {
|
||||
await updateAccount(env.DB, 9811, { hasStudio: true })
|
||||
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
|
||||
'gameClient',
|
||||
'screenshare',
|
||||
'betastudio',
|
||||
])
|
||||
|
||||
@@ -770,7 +815,6 @@ describe('auth worker routes', () => {
|
||||
.run()
|
||||
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
|
||||
'gameClient',
|
||||
'screenshare',
|
||||
'developer',
|
||||
'moderator',
|
||||
'betastudio',
|
||||
@@ -803,7 +847,6 @@ describe('auth worker routes', () => {
|
||||
expect(signedIn.status).toBe(200)
|
||||
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
|
||||
'gameClient',
|
||||
'screenshare',
|
||||
'betastudio',
|
||||
])
|
||||
|
||||
@@ -818,6 +861,53 @@ describe('auth worker routes', () => {
|
||||
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
|
||||
})
|
||||
|
||||
// STUDIO_ROLLOUT opens Studio to a Discord ROLE: a holder is stamped `betastudio` exactly
|
||||
// as a flagged account is, with no account flag involved, from the same Discord link
|
||||
// snapshot the build rollout gate reads. Unset, the flag alone decides.
|
||||
test('POST /connect/token stamps betastudio for a Discord role in STUDIO_ROLLOUT', async () => {
|
||||
const studioRole = '1077000000000000031'
|
||||
const otherRole = '1077000000000000032'
|
||||
await seedAccount(9813, 'StudioRoleHolder')
|
||||
await linkPlatformIdentity(env.DB, 9813, PlatformType.Discord, '500000000000009813', [
|
||||
studioRole,
|
||||
])
|
||||
await seedAccount(9814, 'StudioRoleOutsider')
|
||||
await linkPlatformIdentity(env.DB, 9814, PlatformType.Discord, '500000000000009814', [
|
||||
otherRole,
|
||||
])
|
||||
const login = (id: number) => tokenFor(`account_id=${id}&password=${LOGIN_PASSWORD}`)
|
||||
|
||||
const original = env.STUDIO_ROLLOUT
|
||||
try {
|
||||
// Unset, or naming no role: the link grants nothing.
|
||||
delete env.STUDIO_ROLLOUT
|
||||
expect((await login(9813)).role).not.toContain('betastudio')
|
||||
env.STUDIO_ROLLOUT = ' , '
|
||||
expect((await login(9813)).role).not.toContain('betastudio')
|
||||
|
||||
// Same list grammar as DISCORD_ROLLOUT_ROLE_IDS; any one named role qualifies.
|
||||
env.STUDIO_ROLLOUT = ` ${otherRole}x ,\n${studioRole} `
|
||||
expect((await login(9813)).role).toEqual(['gameClient', 'betastudio'])
|
||||
// A Discord link holding only other roles is not in the rollout.
|
||||
expect((await login(9814)).role).not.toContain('betastudio')
|
||||
// The account flag never moved: this is the var, not a grant written anywhere.
|
||||
expect((await getAccount(env.DB, 9813))?.hasStudio).toBeUndefined()
|
||||
|
||||
// The refresh grant re-resolves it, so ending the rollout drops the claim on the
|
||||
// next refresh — the same path a revoked flag takes.
|
||||
const session = await postToken(`account_id=9813&password=${LOGIN_PASSWORD}`)
|
||||
delete env.STUDIO_ROLLOUT
|
||||
const refreshed = await postToken(
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(session.json.refresh_token as string)}`
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
|
||||
} finally {
|
||||
if (original === undefined) delete env.STUDIO_ROLLOUT
|
||||
else env.STUDIO_ROLLOUT = original
|
||||
}
|
||||
})
|
||||
|
||||
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
|
||||
// the website's Discord benefits claim sets. `econ` decides the CampusCard and the
|
||||
// subscriber discount from this claim ALONE and never reads the account, so if this
|
||||
@@ -888,7 +978,7 @@ describe('auth worker routes', () => {
|
||||
)
|
||||
.run()
|
||||
const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'screenshare', 'junior']))
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'junior']))
|
||||
expect(payload.role).not.toContain('developer')
|
||||
// `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`.
|
||||
expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat'])
|
||||
@@ -907,9 +997,7 @@ describe('auth worker routes', () => {
|
||||
)
|
||||
.run()
|
||||
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload.role).toEqual(
|
||||
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
|
||||
)
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'influencer']))
|
||||
expect(payload.role).not.toContain('developer')
|
||||
|
||||
// Off the flag, off the token — no other flag implies it.
|
||||
@@ -1036,7 +1124,9 @@ describe('auth worker routes', () => {
|
||||
.bind(77)
|
||||
.first<{ data: string }>()
|
||||
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
|
||||
@@ -1938,6 +2028,205 @@ describe('auth worker routes', () => {
|
||||
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 () => {
|
||||
const token = await accessTokenFor('grant_type=create_account&platform_id=steam-restrict1')
|
||||
const res = await exports.default.fetch(`${ORIGIN}/privileges/me/restrictions`, {
|
||||
@@ -1959,7 +2248,7 @@ describe('auth worker routes', () => {
|
||||
for (const id of [42, 77]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(true)
|
||||
expect(await res.json()).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2184,6 +2473,8 @@ describe('auth worker routes', () => {
|
||||
'GET /role/moderator/{id}',
|
||||
'GET /role/screenshare/{id}',
|
||||
'POST /account/me/changepassword',
|
||||
'POST /account/recoverpassword',
|
||||
'POST /account/resetpassword',
|
||||
'POST /cachedlogin/forplatformid/{platform}/{id}',
|
||||
'POST /cachedlogin/forplatformids',
|
||||
'POST /connect/device/approve',
|
||||
@@ -2579,3 +2870,145 @@ describe('staff login audit', () => {
|
||||
expect(await staffLogins(9605)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// The website's "Sign in with Discord". `www` proves the Discord user (it holds the client
|
||||
// secret) and posts an identity ASSERTION signed with the shared JWT_SECRET; this grant
|
||||
// resolves it through the link table like a cached_login, or makes the account when there
|
||||
// is none. Everything here mints the assertion the way www does, with the test key.
|
||||
describe('identity_assertion grant', () => {
|
||||
const SIGNING_KEY = 'test-signing-key'
|
||||
const assertionFor = (platformId: string, platform = PlatformType.Discord) =>
|
||||
generateIdentityAssertion({ platform, platformId }, SIGNING_KEY)
|
||||
const redeem = async (assertion: string, ip?: string) =>
|
||||
postToken(`grant_type=identity_assertion&assertion=${encodeURIComponent(assertion)}`, ip)
|
||||
|
||||
test('an assertion round-trips, and nothing else passes as one', async () => {
|
||||
const assertion = await assertionFor('700000000000000001')
|
||||
await expect(validateIdentityAssertion(assertion, SIGNING_KEY)).resolves.toEqual({
|
||||
platform: PlatformType.Discord,
|
||||
platformId: '700000000000000001',
|
||||
})
|
||||
// A different key, and an access token signed with the RIGHT key: both refused. The
|
||||
// second is the one that matters — the same secret signs sessions, so the audience is
|
||||
// what keeps a stolen token from being replayed as proof of a platform identity.
|
||||
await expect(validateIdentityAssertion(assertion, 'another-key')).resolves.toBeNull()
|
||||
const session = await generateToken('42', '', 4, SIGNING_KEY)
|
||||
await expect(validateIdentityAssertion(session, SIGNING_KEY)).resolves.toBeNull()
|
||||
// And the other direction: an assertion carries no `sub`, so it is no session.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Bearer ${assertion}`,
|
||||
},
|
||||
body: 'user_code=ABCDEFGH',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('signs into the one account the identity is linked to', async () => {
|
||||
await seedAccount(9400, 'DiscordLinked')
|
||||
// Linked the way the benefits claim links: with roles recorded.
|
||||
await linkPlatformIdentity(env.DB, 9400, PlatformType.Discord, '700000000000009400', [])
|
||||
const res = await redeem(await assertionFor('700000000000009400'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string).sub).toBe('9400')
|
||||
expect(res.json).not.toHaveProperty('created')
|
||||
expect(typeof res.json.refresh_token).toBe('string')
|
||||
})
|
||||
|
||||
test('refuses a missing, forged or replayed-session assertion', async () => {
|
||||
expect((await postToken('grant_type=identity_assertion')).status).toBe(400)
|
||||
const forged = await generateIdentityAssertion(
|
||||
{ platform: PlatformType.Discord, platformId: '700000000000009400' },
|
||||
'not-the-key'
|
||||
)
|
||||
const res = await redeem(forged)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('invalid or expired identity assertion')
|
||||
// An access token for the linked account, presented as the assertion.
|
||||
const session = await generateToken('9400', '', 4, SIGNING_KEY)
|
||||
expect((await redeem(session)).status).toBe(400)
|
||||
})
|
||||
|
||||
test('creates and links an account when the identity opens none, once', async () => {
|
||||
const discordId = '700000000000009401'
|
||||
const first = await redeem(await assertionFor(discordId))
|
||||
expect(first.status).toBe(200)
|
||||
expect(first.json.created).toBe(true)
|
||||
const accountId = Number(decodePayload(first.json.access_token as string).sub)
|
||||
expect(accountId).toBeGreaterThan(0)
|
||||
|
||||
// Linked on the Discord platform, and ONLY there: the blob carries no platform
|
||||
// identity (the snowflake stays off the DTO and the token), like a password account.
|
||||
const links = await getLinksForAccount(env.DB, accountId)
|
||||
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
|
||||
[PlatformType.Discord, discordId],
|
||||
])
|
||||
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ data: string }>()
|
||||
const blob = JSON.parse(row!.data) as Record<string, unknown>
|
||||
expect(blob.platformId).toBeUndefined()
|
||||
expect(blob.passwordHash).toBeUndefined()
|
||||
// Placed in Orientation like any signup.
|
||||
const presence = await env.DB.prepare('SELECT 1 AS ok FROM presence WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first()
|
||||
expect(presence).not.toBeNull()
|
||||
|
||||
// The same Discord again is a sign-IN to that account, not a second account.
|
||||
const again = await redeem(await assertionFor(discordId))
|
||||
expect(again.status).toBe(200)
|
||||
expect(again.json).not.toHaveProperty('created')
|
||||
expect(decodePayload(again.json.access_token as string).sub).toBe(String(accountId))
|
||||
await expect(
|
||||
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
|
||||
).resolves.toBe(1)
|
||||
})
|
||||
|
||||
// The stretch of the feature: a Discord-made account has no password, and a password is
|
||||
// what signs it into the game (a sideloaded Quest has no Meta nonce to offer). The
|
||||
// website's password form posts with an EMPTY old password the first time.
|
||||
test('a Discord-made account can set a password and then sign in with it', async () => {
|
||||
const created = await redeem(await assertionFor('700000000000009402'))
|
||||
const accountId = decodePayload(created.json.access_token as string).sub as string
|
||||
expect((await postToken(`account_id=${accountId}&password=hunter22`)).status).toBe(400)
|
||||
|
||||
const set = await changePassword(
|
||||
'oldPassword=&newPassword=hunter22',
|
||||
created.json.access_token as string
|
||||
)
|
||||
expect(set.status).toBe(200)
|
||||
const login = await postToken(`grant_type=password&account_id=${accountId}&password=hunter22`)
|
||||
expect(login.status).toBe(200)
|
||||
expect(decodePayload(login.json.access_token as string).sub).toBe(accountId)
|
||||
})
|
||||
|
||||
test('refuses an identity that opens several accounts rather than guessing', async () => {
|
||||
await seedAccount(9403, 'TwoLinksA')
|
||||
await seedAccount(9404, 'TwoLinksB')
|
||||
await linkPlatformIdentity(env.DB, 9403, PlatformType.Discord, '700000000000009403', [])
|
||||
await linkPlatformIdentity(env.DB, 9404, PlatformType.Discord, '700000000000009403', [])
|
||||
const res = await redeem(await assertionFor('700000000000009403'))
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('platform identity is linked to more than one account')
|
||||
})
|
||||
|
||||
// The signup caps reach this path as they reach create_account — www forwards the
|
||||
// browser's real address over the service binding, so the per-IP cap counts browsers.
|
||||
test('the per-IP signup cap applies to accounts made this way', async () => {
|
||||
const ip = '198.51.100.77'
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await redeem(await assertionFor(`70000000000000950${i}`), ip)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.json.created).toBe(true)
|
||||
}
|
||||
const fourth = await redeem(await assertionFor('700000000000009509'), ip)
|
||||
expect(fourth.status).toBe(400)
|
||||
expect(fourth.json.error_description).toBe('too many accounts created from this network')
|
||||
// Signing IN from that network is untouched: the cap is on creation.
|
||||
const existing = await redeem(await assertionFor('700000000000009500'), ip)
|
||||
expect(existing.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* 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
|
||||
/*! *****************************************************************************
|
||||
Copyright (c) Cloudflare. All rights reserved.
|
||||
@@ -19,26 +19,26 @@ and limitations under the License.
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
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)
|
||||
*/
|
||||
declare class DOMException extends Error {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)
|
||||
@@ -96,61 +96,61 @@ interface Console {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -168,23 +168,24 @@ interface Console {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* The **`console.trace()`** static method outputs a stack trace to the console.
|
||||
@@ -193,7 +194,7 @@ interface Console {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -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 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)
|
||||
*/
|
||||
@@ -404,6 +405,7 @@ declare const crypto: Crypto;
|
||||
* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
|
||||
*/
|
||||
declare const caches: CacheStorage;
|
||||
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/scheduler) */
|
||||
declare const scheduler: Scheduler;
|
||||
/**
|
||||
* 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> {
|
||||
waitUntil(promise: Promise<any>): void;
|
||||
passThroughOnException(): void;
|
||||
readonly exports: Cloudflare.Exports;
|
||||
readonly props: Props;
|
||||
cache?: CacheContext;
|
||||
readonly access?: CloudflareAccessContext;
|
||||
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 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>;
|
||||
jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace<T>;
|
||||
}
|
||||
type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high";
|
||||
type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us";
|
||||
interface DurableObjectNamespaceNewUniqueIdOptions {
|
||||
jurisdiction?: DurableObjectJurisdiction;
|
||||
}
|
||||
@@ -526,6 +530,7 @@ interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = u
|
||||
}
|
||||
interface DurableObjectState<Props = unknown> {
|
||||
waitUntil(promise: Promise<any>): void;
|
||||
readonly exports: Cloudflare.Exports;
|
||||
readonly props: Props;
|
||||
readonly id: DurableObjectId;
|
||||
readonly storage: DurableObjectStorage;
|
||||
@@ -540,7 +545,7 @@ interface DurableObjectState<Props = unknown> {
|
||||
setHibernatableWebSocketEventTimeout(timeoutMs?: number): void;
|
||||
getHibernatableWebSocketEventTimeout(): number | null;
|
||||
getTags(ws: WebSocket): string[];
|
||||
abort(reason?: string): void;
|
||||
abort(reason?: string, options?: DurableObjectAbortOptions): void;
|
||||
}
|
||||
interface DurableObjectTransaction {
|
||||
get<T = unknown>(key: string, options?: DurableObjectGetOptions): Promise<T | undefined>;
|
||||
@@ -576,6 +581,9 @@ interface DurableObjectStorage {
|
||||
getBookmarkForTime(timestamp: number | Date): Promise<string>;
|
||||
onNextSessionRestoreBookmark(bookmark: string): Promise<string>;
|
||||
}
|
||||
interface DurableObjectAbortOptions {
|
||||
retryAlarm?: boolean;
|
||||
}
|
||||
interface DurableObjectListOptions {
|
||||
start?: string;
|
||||
startAfter?: string;
|
||||
@@ -626,26 +634,26 @@ interface AnalyticsEngineDataPoint {
|
||||
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)
|
||||
*/
|
||||
declare class Event {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -682,13 +690,13 @@ declare class Event {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement)
|
||||
@@ -701,45 +709,45 @@ declare class Event {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
|
||||
*/
|
||||
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
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -760,7 +768,7 @@ interface EventListenerObject<EventType extends Event = Event> {
|
||||
}
|
||||
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)
|
||||
*/
|
||||
@@ -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;
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -811,7 +819,7 @@ declare class AbortController {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -824,7 +832,7 @@ declare class AbortController {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -836,13 +844,13 @@ declare abstract class AbortSignal extends EventTarget {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -858,12 +866,17 @@ declare abstract class AbortSignal extends EventTarget {
|
||||
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */
|
||||
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)
|
||||
*/
|
||||
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 {
|
||||
wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise<void>;
|
||||
}
|
||||
@@ -871,20 +884,20 @@ interface SchedulerWaitOptions {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -923,7 +936,7 @@ declare class Blob {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -941,13 +954,13 @@ declare class Blob {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -964,13 +977,13 @@ interface BlobOptions {
|
||||
declare class File extends Blob {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -987,7 +1000,7 @@ interface FileOptions {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1020,14 +1033,14 @@ interface CacheQueryOptions {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1055,7 +1068,7 @@ declare abstract class SubtleCrypto {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1073,7 +1086,7 @@ declare abstract class SubtleCrypto {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1091,7 +1104,7 @@ declare abstract class SubtleCrypto {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1109,13 +1122,13 @@ declare abstract class SubtleCrypto {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1123,20 +1136,20 @@ declare abstract class SubtleCrypto {
|
||||
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.
|
||||
*
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1253,12 +1266,15 @@ interface CryptoKeyArbitraryKeyAlgorithm {
|
||||
length?: number;
|
||||
}
|
||||
declare class DigestStream extends WritableStream<ArrayBuffer | ArrayBufferView> {
|
||||
constructor(algorithm: string | SubtleCryptoHashAlgorithm);
|
||||
constructor(algorithm: string | SubtleCryptoHashAlgorithm, options?: DigestStreamOptions);
|
||||
readonly digest: Promise<ArrayBuffer>;
|
||||
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)
|
||||
*/
|
||||
@@ -1275,20 +1291,20 @@ declare class TextDecoder {
|
||||
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)
|
||||
*/
|
||||
declare class TextEncoder {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1345,6 +1361,9 @@ declare class ErrorEvent extends Event {
|
||||
get error(): any;
|
||||
}
|
||||
interface ErrorEventErrorEventInit {
|
||||
bubbles?: boolean;
|
||||
cancelable?: boolean;
|
||||
composed?: boolean;
|
||||
message?: string;
|
||||
filename?: string;
|
||||
lineno?: number;
|
||||
@@ -1357,135 +1376,139 @@ interface ErrorEventErrorEventInit {
|
||||
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
readonly ports: MessagePort[];
|
||||
}
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
declare class FormData {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
set(name: string, value: Blob, filename?: string): void;
|
||||
/* Returns an array of key, value pairs for every entry in the list. */
|
||||
entries(): IterableIterator<[
|
||||
key: string,
|
||||
value: File | string
|
||||
]>;
|
||||
/* Returns a list of keys in the list. */
|
||||
keys(): IterableIterator<string>;
|
||||
/* Returns a list of values in the list. */
|
||||
values(): IterableIterator<(File | string)>;
|
||||
forEach<This = unknown>(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void;
|
||||
[Symbol.iterator](): IterableIterator<[
|
||||
@@ -1564,19 +1587,19 @@ interface 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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1585,65 +1608,62 @@ declare abstract class FetchEvent extends ExtendableEvent {
|
||||
}
|
||||
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)
|
||||
*/
|
||||
declare class Headers {
|
||||
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)
|
||||
*/
|
||||
get(name: string): string | null;
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
delete(name: string): 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<[
|
||||
key: string,
|
||||
value: string
|
||||
]>;
|
||||
/* Returns an iterator allowing to go through all keys of the key/value pairs contained in this object. */
|
||||
keys(): IterableIterator<string>;
|
||||
/* Returns an iterator allowing to go through all values of the key/value pairs contained in this object. */
|
||||
values(): IterableIterator<string>;
|
||||
[Symbol.iterator](): IterableIterator<[
|
||||
key: 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 {
|
||||
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */
|
||||
get body(): ReadableStream | null;
|
||||
@@ -1699,7 +1719,7 @@ interface Response extends Body {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1717,7 +1737,7 @@ interface Response extends Body {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1725,7 +1745,7 @@ interface Response extends Body {
|
||||
webSocket: WebSocket | null;
|
||||
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)
|
||||
*/
|
||||
@@ -1756,13 +1776,13 @@ declare var Request: {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1774,7 +1794,7 @@ interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> e
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -1800,13 +1820,13 @@ interface Request<CfHostMetadata = unknown, Cf = CfProperties<CfHostMetadata>> e
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2195,7 +2215,7 @@ type ReadableStreamReadResult<R = any> = {
|
||||
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)
|
||||
*/
|
||||
@@ -2213,13 +2233,13 @@ interface ReadableStream<R = any> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2231,13 +2251,13 @@ interface ReadableStream<R = any> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2249,7 +2269,7 @@ interface ReadableStream<R = any> {
|
||||
[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)
|
||||
*/
|
||||
@@ -2281,7 +2301,7 @@ declare class ReadableStreamDefaultReader<R = any> {
|
||||
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)
|
||||
*/
|
||||
@@ -2290,13 +2310,13 @@ declare class ReadableStreamBYOBReader {
|
||||
get closed(): 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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2315,7 +2335,7 @@ interface ReadableStreamGetReaderOptions {
|
||||
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)
|
||||
*/
|
||||
@@ -2341,13 +2361,13 @@ declare abstract class ReadableStreamBYOBRequest {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2359,32 +2379,32 @@ declare abstract class ReadableStreamDefaultController<R = any> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2396,7 +2416,7 @@ declare abstract class ReadableByteStreamController {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2409,7 +2429,7 @@ declare abstract class ReadableByteStreamController {
|
||||
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)
|
||||
*/
|
||||
@@ -2421,7 +2441,7 @@ declare abstract class WritableStreamDefaultController {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2446,7 +2466,7 @@ declare abstract class TransformStreamDefaultController<O = any> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2468,14 +2488,14 @@ interface ReadableWritablePair<R = any, W = any> {
|
||||
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)
|
||||
*/
|
||||
declare class WritableStream<W = any> {
|
||||
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)
|
||||
*/
|
||||
@@ -2487,83 +2507,83 @@ declare class WritableStream<W = any> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
declare class WritableStreamDefaultWriter<W = any> {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
declare class TransformStream<I = any, O = any> {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2582,7 +2602,7 @@ interface ReadableStreamValuesOptions {
|
||||
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)
|
||||
*/
|
||||
@@ -2590,7 +2610,7 @@ declare class CompressionStream extends TransformStream<ArrayBuffer | ArrayBuffe
|
||||
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)
|
||||
*/
|
||||
@@ -2598,7 +2618,7 @@ declare class DecompressionStream extends TransformStream<ArrayBuffer | ArrayBuf
|
||||
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)
|
||||
*/
|
||||
@@ -2607,7 +2627,7 @@ declare class TextEncoderStream extends TransformStream<string, Uint8Array> {
|
||||
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)
|
||||
*/
|
||||
@@ -2756,6 +2776,12 @@ interface TraceLog {
|
||||
readonly timestamp: number;
|
||||
readonly level: string;
|
||||
readonly message: any;
|
||||
readonly errorInfo?: (TraceLogErrorInfo | null)[];
|
||||
}
|
||||
interface TraceLogErrorInfo {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
}
|
||||
interface TraceException {
|
||||
readonly timestamp: number;
|
||||
@@ -2776,7 +2802,7 @@ interface UnsafeTraceMetrics {
|
||||
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)
|
||||
*/
|
||||
@@ -2801,121 +2827,121 @@ declare class URL {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2935,13 +2961,13 @@ declare class URL {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -2991,25 +3017,22 @@ declare class URLSearchParams {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
sort(): void;
|
||||
/* Returns an array of key, value pairs for every entry in the search params. */
|
||||
entries(): IterableIterator<[
|
||||
key: string,
|
||||
value: string
|
||||
]>;
|
||||
/* Returns a list of keys in the search params. */
|
||||
keys(): IterableIterator<string>;
|
||||
/* Returns a list of values in the search params. */
|
||||
values(): IterableIterator<string>;
|
||||
forEach<This = unknown>(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void;
|
||||
/*function toString() { [native code] }*/
|
||||
@@ -3019,18 +3042,78 @@ declare class URLSearchParams {
|
||||
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 {
|
||||
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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
interface URLPatternInit {
|
||||
@@ -3063,7 +3146,7 @@ interface URLPatternOptions {
|
||||
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)
|
||||
*/
|
||||
@@ -3082,13 +3165,16 @@ declare class CloseEvent extends Event {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
readonly wasClean: boolean;
|
||||
}
|
||||
interface CloseEventInit {
|
||||
bubbles?: boolean;
|
||||
cancelable?: boolean;
|
||||
composed?: boolean;
|
||||
code?: number;
|
||||
reason?: string;
|
||||
wasClean?: boolean;
|
||||
@@ -3100,7 +3186,7 @@ type WebSocketEventMap = {
|
||||
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)
|
||||
*/
|
||||
@@ -3117,20 +3203,20 @@ declare var WebSocket: {
|
||||
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)
|
||||
*/
|
||||
interface WebSocket extends EventTarget<WebSocketEventMap> {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -3150,13 +3236,13 @@ interface WebSocket extends EventTarget<WebSocketEventMap> {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -3243,25 +3329,25 @@ interface SocketInfo {
|
||||
declare class EventSource extends EventTarget {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -3296,18 +3382,26 @@ interface ContainerExecOptions {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
user?: string;
|
||||
signal?: AbortSignal;
|
||||
pty?: boolean | ContainerExecPtyOptions;
|
||||
stdin?: ReadableStream | "pipe";
|
||||
stdout?: "pipe" | "ignore";
|
||||
stderr?: "pipe" | "ignore" | "combined";
|
||||
}
|
||||
interface ContainerExecPtyOptions {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}
|
||||
interface ExecProcess {
|
||||
readonly stdin: WritableStream | null;
|
||||
readonly stdout: ReadableStream | null;
|
||||
readonly stderr: ReadableStream | null;
|
||||
readonly pid: number;
|
||||
readonly isPty: boolean;
|
||||
readonly exitCode: Promise<number>;
|
||||
output(): Promise<ExecOutput>;
|
||||
kill(signal?: number): void;
|
||||
resize(cols: number, rows: number): void;
|
||||
}
|
||||
interface Container {
|
||||
get running(): boolean;
|
||||
@@ -3334,25 +3428,42 @@ interface ContainerDirectorySnapshotOptions {
|
||||
dir: string;
|
||||
name?: string;
|
||||
}
|
||||
interface ContainerDirectorySnapshotRestoreParams {
|
||||
type ContainerDirectorySnapshotRestoreParams = {
|
||||
snapshot: ContainerDirectorySnapshot;
|
||||
mountPoint?: string;
|
||||
}
|
||||
} | {
|
||||
snapshot?: undefined;
|
||||
mountPoint: string;
|
||||
};
|
||||
interface ContainerSnapshot {
|
||||
id: string;
|
||||
size: number;
|
||||
name?: string;
|
||||
}
|
||||
interface ContainerSnapshotRestoreParams {
|
||||
id: string;
|
||||
}
|
||||
interface ContainerSnapshotOptions {
|
||||
name?: string;
|
||||
}
|
||||
interface ContainerStartupOptions {
|
||||
type ContainerStartupOptions = {
|
||||
entrypoint?: string[];
|
||||
enableInternet: boolean;
|
||||
env?: Record<string, string>;
|
||||
instance?: "lite" | "standard-1" | "standard-2" | "standard-3" | "standard-4" | ContainerStartResources;
|
||||
labels?: Record<string, string>;
|
||||
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.
|
||||
@@ -3361,19 +3472,19 @@ interface ContainerStartupOptions {
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -3389,13 +3500,13 @@ declare abstract class MessagePort extends EventTarget {
|
||||
declare class MessageChannel {
|
||||
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)
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
@@ -3455,7 +3566,7 @@ interface WorkerLoaderModule {
|
||||
data?: ArrayBuffer;
|
||||
json?: any;
|
||||
py?: string;
|
||||
wasm?: ArrayBuffer;
|
||||
wasm?: ArrayBuffer | ArrayBufferView | WebAssembly.Module;
|
||||
}
|
||||
interface WorkerLoaderWorkerCode {
|
||||
compatibilityDate: string;
|
||||
@@ -3463,7 +3574,7 @@ interface WorkerLoaderWorkerCode {
|
||||
allowExperimental?: boolean;
|
||||
limits?: workerdResourceLimits;
|
||||
mainModule: string;
|
||||
modules: Record<string, WorkerLoaderModule | string>;
|
||||
modules: Record<string, string | WebAssembly.Module | WorkerLoaderModule>;
|
||||
env?: any;
|
||||
globalOutbound?: (Fetcher | null);
|
||||
tails?: Fetcher[];
|
||||
@@ -3485,7 +3596,7 @@ declare abstract class Performance {
|
||||
/* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */
|
||||
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)
|
||||
*/
|
||||
@@ -3494,11 +3605,13 @@ declare abstract class Performance {
|
||||
interface Tracing {
|
||||
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;
|
||||
startSpan(name: string): Span;
|
||||
Span: typeof Span;
|
||||
}
|
||||
declare abstract class Span {
|
||||
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;
|
||||
}
|
||||
/**
|
||||
@@ -4138,6 +4251,13 @@ type AiSearchListItemsParams = {
|
||||
source?: string;
|
||||
/** JSON-encoded Vectorize filter for metadata filtering. */
|
||||
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 = {
|
||||
result: AiSearchItemInfo[];
|
||||
@@ -10035,6 +10155,163 @@ declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT {
|
||||
inputs: ChatCompletionsInput;
|
||||
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 {
|
||||
"@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification;
|
||||
"@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/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/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 = {
|
||||
/**
|
||||
@@ -10793,6 +11077,15 @@ interface BrowserRunBaseOptions {
|
||||
*/
|
||||
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.*/
|
||||
type BrowserRunCommonOptions = (BrowserRunBaseOptions & {
|
||||
/** URL to navigate to, e.g. `"https://example.com"`. */
|
||||
@@ -10827,7 +11120,7 @@ type BrowserRunScreenshotOptions = BrowserRunCommonOptions & {
|
||||
scrollPage?: boolean;
|
||||
/** @see https://pptr.dev/api/puppeteer.screenshotoptions */
|
||||
screenshotOptions?: BrowserRunPuppeteerScreenshotOptions;
|
||||
};
|
||||
} & BrowserRunAlternateBackendOptions;
|
||||
type BrowserRunPDFOptions = BrowserRunCommonOptions & {
|
||||
/** @see https://pptr.dev/api/puppeteer.pdfoptions */
|
||||
pdfOptions?: {
|
||||
@@ -10863,7 +11156,7 @@ type BrowserRunPDFOptions = BrowserRunCommonOptions & {
|
||||
/** @default 30000 */
|
||||
timeout?: number;
|
||||
};
|
||||
};
|
||||
} & BrowserRunAlternateBackendOptions;
|
||||
type BrowserRunScrapeOptions = BrowserRunCommonOptions & {
|
||||
/** CSS selectors to scrape. At least one element is required. */
|
||||
elements: Array<{
|
||||
@@ -10876,10 +11169,29 @@ type BrowserRunLinksOptions = BrowserRunCommonOptions & {
|
||||
/** When true, exclude links pointing to external domains. @default false */
|
||||
excludeExternalLinks?: boolean;
|
||||
};
|
||||
type BrowserRunSnapshotFormat = 'content' | 'screenshot' | 'markdown' | 'accessibilityTree';
|
||||
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 */
|
||||
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 {
|
||||
/** Custom AI models to try in order. Max 3. Falls back to next on error. */
|
||||
custom_ai?: Array<{
|
||||
@@ -10893,7 +11205,7 @@ interface BrowserRunJsonBaseOptions {
|
||||
* Options for the `json` quick action.
|
||||
* 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. */
|
||||
prompt: string;
|
||||
/** 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/ */
|
||||
response_format: AiTextGenerationResponseFormat;
|
||||
});
|
||||
type BrowserRunContentOptions = BrowserRunCommonOptions;
|
||||
type BrowserRunMarkdownOptions = BrowserRunCommonOptions;
|
||||
type BrowserRunContentOptions = BrowserRunCommonOptions & BrowserRunAlternateBackendOptions;
|
||||
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 = {
|
||||
/** HTTP status code of the rendered page */
|
||||
status: number;
|
||||
/** Page title */
|
||||
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. */
|
||||
type BrowserRunContentSuccessResponse = {
|
||||
success: true;
|
||||
@@ -10924,6 +11302,7 @@ type BrowserRunLinksSuccessResponse = {
|
||||
success: true;
|
||||
/** Extracted links */
|
||||
result: string[];
|
||||
meta: BrowserRunResponseMeta;
|
||||
};
|
||||
/** Success response for `scrape` action. */
|
||||
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 = {
|
||||
success: true;
|
||||
result: {
|
||||
/** HTML content of the page. */
|
||||
content: string;
|
||||
content?: string;
|
||||
/** 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;
|
||||
};
|
||||
@@ -10971,12 +11368,14 @@ type BrowserRunJsonSuccessResponse = {
|
||||
success: true;
|
||||
/** JSON data extracted from the page using an AI model */
|
||||
result: Record<string, unknown>;
|
||||
meta: BrowserRunResponseMeta;
|
||||
};
|
||||
/** Success response for `markdown` action. */
|
||||
type BrowserRunMarkdownSuccessResponse = {
|
||||
success: true;
|
||||
/** Extracted markdown content */
|
||||
result: string;
|
||||
meta: BrowserRunResponseMeta;
|
||||
};
|
||||
/** Error response for BrowserRun actions. */
|
||||
type BrowserRunErrorResponse = {
|
||||
@@ -11086,9 +11485,10 @@ declare abstract class BrowserRun {
|
||||
*/
|
||||
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 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:
|
||||
*
|
||||
* **Success (HTTP 200):**
|
||||
@@ -11136,6 +11536,26 @@ declare abstract class BrowserRun {
|
||||
* - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500)
|
||||
*/
|
||||
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
|
||||
@@ -12206,6 +12626,13 @@ interface ForwardableEmailMessage extends EmailMessage {
|
||||
* @returns A promise that resolves when the email message is replied.
|
||||
*/
|
||||
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 */
|
||||
type EmailAttachment = {
|
||||
@@ -12226,23 +12653,46 @@ interface EmailAddress {
|
||||
name: 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.
|
||||
*/
|
||||
interface SendEmail {
|
||||
send(message: EmailMessage): Promise<EmailSendResult>;
|
||||
send(builder: {
|
||||
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>;
|
||||
send(builder: EmailMessageBuilder): Promise<EmailSendResult>;
|
||||
}
|
||||
declare abstract class EmailEvent extends ExtendableEvent {
|
||||
readonly message: ForwardableEmailMessage;
|
||||
@@ -12396,6 +12846,15 @@ interface Hyperdrive {
|
||||
* for your database.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -12417,6 +12876,78 @@ interface Hyperdrive {
|
||||
*/
|
||||
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.
|
||||
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
|
||||
// https://opensource.org/licenses/Apache-2.0
|
||||
@@ -12428,6 +12959,27 @@ type ImageInfoResponse = {
|
||||
width: 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 = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
@@ -12517,6 +13069,9 @@ interface ImageUploadOptions {
|
||||
requireSignedURLs?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
creator?: string;
|
||||
/**
|
||||
* If 'base64', the input data will be decoded from base64 before processing
|
||||
*/
|
||||
encoding?: 'base64';
|
||||
}
|
||||
interface ImageUpdateOptions {
|
||||
@@ -12524,11 +13079,40 @@ interface ImageUpdateOptions {
|
||||
metadata?: Record<string, unknown>;
|
||||
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 {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
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 {
|
||||
images: ImageMetadata[];
|
||||
@@ -12546,6 +13130,13 @@ interface ImageHandle {
|
||||
* @returns ReadableStream of image bytes, or null if not found
|
||||
*/
|
||||
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
|
||||
* @param options Properties to update
|
||||
@@ -12581,6 +13172,14 @@ interface HostedImagesBinding {
|
||||
* @throws {@link ImagesError} if list fails
|
||||
*/
|
||||
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 {
|
||||
/**
|
||||
@@ -12595,6 +13194,13 @@ interface ImagesBinding {
|
||||
* @returns A transform handle
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@@ -12624,11 +13230,15 @@ interface ImageTransformer {
|
||||
type ImageTransformationOutputOptions = {
|
||||
encoding?: 'base64';
|
||||
};
|
||||
type ImageTransformationResponseOptions = {
|
||||
headers?: HeadersInit;
|
||||
};
|
||||
interface ImageTransformationResult {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -13064,6 +13674,11 @@ declare namespace CloudflareWorkersModule {
|
||||
export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
|
||||
export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number;
|
||||
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 WorkflowRetentionDuration = WorkflowSleepDuration;
|
||||
export type WorkflowBackoff = 'constant' | 'linear' | 'exponential';
|
||||
@@ -13071,12 +13686,32 @@ declare namespace CloudflareWorkersModule {
|
||||
export type WorkflowStepConfig = {
|
||||
retries?: {
|
||||
limit: number;
|
||||
delay: WorkflowDelayDuration | number;
|
||||
delay: WorkflowDelayDuration | number | WorkflowDelayFunction;
|
||||
backoff?: WorkflowBackoff;
|
||||
};
|
||||
timeout?: WorkflowTimeoutDuration | number;
|
||||
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 WorkflowCronSchedule = {
|
||||
/** Cron expression that triggered this event. */
|
||||
@@ -13097,28 +13732,51 @@ declare namespace CloudflareWorkersModule {
|
||||
type: string;
|
||||
sensitive?: WorkflowStepSensitivity;
|
||||
};
|
||||
export type WorkflowStepContext = {
|
||||
export type WorkflowStepContext<Delay = WorkflowDelayDuration | number> = {
|
||||
step: {
|
||||
name: string;
|
||||
count: 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> = {
|
||||
ctx: WorkflowStepContext;
|
||||
// The rollback handler receives the step context, so it mirrors the same
|
||||
// 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;
|
||||
output: T | undefined;
|
||||
/** @deprecated Use `ctx.step.name` and `ctx.step.count` instead. */
|
||||
stepName: string;
|
||||
};
|
||||
export type WorkflowRollbackHandler<T = unknown> = (ctx: WorkflowRollbackContext<T>) => Promise<void>;
|
||||
export type WorkflowStepRollbackOptions<T = unknown> = {
|
||||
rollback: WorkflowRollbackHandler<T>;
|
||||
export type WorkflowRollbackHandler<T = unknown, Delay = WorkflowDelayDuration | number> = (ctx: WorkflowRollbackContext<T, Delay>) => Promise<void>;
|
||||
export type WorkflowStepRollbackOptions<T = unknown, Delay = WorkflowDelayDuration | number> = {
|
||||
rollback: WorkflowRollbackHandler<T, Delay>;
|
||||
rollbackConfig?: WorkflowStepRollbackConfig;
|
||||
};
|
||||
export abstract class WorkflowStep {
|
||||
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>;
|
||||
sleep: (name: string, duration: WorkflowSleepDuration) => Promise<void>;
|
||||
sleepUntil: (name: string, timestamp: Date | number) => Promise<void>;
|
||||
@@ -13894,11 +14552,12 @@ type MarkdownDocument = {
|
||||
name: string;
|
||||
blob: Blob;
|
||||
};
|
||||
type OutputFormat = 'markdown' | 'text';
|
||||
type ConversionResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
format: 'markdown';
|
||||
format: OutputFormat;
|
||||
tokens: number;
|
||||
data: string;
|
||||
} | {
|
||||
@@ -13915,7 +14574,11 @@ type EmbeddedImageConversionOptions = ImageConversionOptions & {
|
||||
convert?: boolean;
|
||||
maxConvertedImages?: number;
|
||||
};
|
||||
type ConversionOutputOptions = {
|
||||
format?: OutputFormat;
|
||||
};
|
||||
type ConversionOptions = {
|
||||
output?: ConversionOutputOptions;
|
||||
html?: {
|
||||
images?: EmbeddedImageConversionOptions & {
|
||||
convertOGImage?: boolean;
|
||||
@@ -14010,7 +14673,7 @@ declare namespace TailStream {
|
||||
interface ConnectEventInfo {
|
||||
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 {
|
||||
readonly id: string;
|
||||
readonly tag?: string;
|
||||
@@ -14029,6 +14692,7 @@ declare namespace TailStream {
|
||||
readonly dispatchNamespace?: string;
|
||||
readonly entrypoint?: string;
|
||||
readonly executionModel: string;
|
||||
readonly durableObjectId?: string;
|
||||
readonly scriptName?: string;
|
||||
readonly scriptTags?: string[];
|
||||
readonly scriptVersion?: ScriptVersion;
|
||||
@@ -14063,11 +14727,22 @@ declare namespace TailStream {
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
}
|
||||
interface Log {
|
||||
interface TailStreamErrorInfo {
|
||||
readonly name: string;
|
||||
readonly message: string;
|
||||
readonly stack?: string;
|
||||
}
|
||||
type Log = {
|
||||
readonly type: "log";
|
||||
readonly level: "debug" | "error" | "info" | "log" | "warn";
|
||||
readonly errorInfo?: readonly (TailStreamErrorInfo | null)[];
|
||||
} & ({
|
||||
readonly message: object;
|
||||
}
|
||||
readonly truncated?: false;
|
||||
} | {
|
||||
readonly message: string;
|
||||
readonly truncated: true;
|
||||
});
|
||||
interface DroppedEventsDiagnostic {
|
||||
readonly diagnosticsType: "droppedEvents";
|
||||
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.
|
||||
*/
|
||||
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 WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number;
|
||||
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> {
|
||||
/**
|
||||
* An id for your Workflow instance. Must be unique within the Workflow.
|
||||
@@ -14566,6 +15262,9 @@ interface WorkflowInstanceCreateOptions<PARAMS = unknown> {
|
||||
successRetention?: WorkflowRetentionDuration;
|
||||
errorRetention?: WorkflowRetentionDuration;
|
||||
};
|
||||
/** A best-effort geographic placement preference for the Workflow instance.
|
||||
* See `WorkflowInstanceLocationHint` for supported regions. */
|
||||
locationHint?: WorkflowInstanceLocationHint;
|
||||
}
|
||||
type InstanceStatus = {
|
||||
status: 'queued' // means that instance is waiting to be started (see concurrency limits)
|
||||
@@ -14583,6 +15282,13 @@ interface WorkflowError {
|
||||
code?: number;
|
||||
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 {
|
||||
/**
|
||||
* 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>;
|
||||
/**
|
||||
* 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
|
||||
* cached results for all steps before it.
|
||||
* @param options Options for the restart, including an optional step to restart from.
|
||||
*/
|
||||
public restart(options?: WorkflowInstanceRestartOptions): Promise<void>;
|
||||
/**
|
||||
* Delete the instance and its stored state.
|
||||
*/
|
||||
public delete(): Promise<void>;
|
||||
/**
|
||||
* Returns the current status of the instance.
|
||||
*/
|
||||
|
||||
@@ -46,6 +46,15 @@
|
||||
"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,
|
||||
"observability": {
|
||||
"enabled": false
|
||||
|
||||
@@ -362,6 +362,13 @@ const app = new Hono<App>()
|
||||
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
|
||||
)
|
||||
|
||||
// SDK source downloads use an opaque upload capability (no bearer on this
|
||||
// request). Only creator-authenticated save history exposes these filenames.
|
||||
.get('/avatarunityasset/:filename{.+}', (c) => {
|
||||
const name = c.req.param('filename')
|
||||
if (!/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}$/.test(name)) return c.notFound()
|
||||
return serveAsset(c, `avatarunityasset/${name}`)
|
||||
})
|
||||
// Editable Unity source manifests. Baked bundles are served separately.
|
||||
.get(
|
||||
'/roomunityasset/:filename{.+}',
|
||||
@@ -430,15 +437,22 @@ const app = new Hono<App>()
|
||||
* a build is stored under it. Null when no build is, or the table has not been migrated
|
||||
* yet (the rooms worker owns it).
|
||||
*/
|
||||
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
|
||||
async function unityAssetR2Key(env: StudioAssetEnv, filename: string): Promise<string | null> {
|
||||
try {
|
||||
// Inlined rather than imported so this worker does not take a dependency on the
|
||||
// domain package.
|
||||
const row = await db
|
||||
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`
|
||||
)
|
||||
.bind(filename)
|
||||
.first<{ stored: number }>()
|
||||
return row ? filename : null
|
||||
if (!row) return null
|
||||
if (/^(quest\/)?studio-avatar\//.test(filename)) return `avatar/${filename}`
|
||||
// Room builds and consumable exports use the game's /room namespace.
|
||||
// Retain older installations that stored the same filename at the bucket root.
|
||||
if (filename.startsWith('studio/') && (await env.CDN_ASSETS.head(`room/${filename}`)))
|
||||
return `room/${filename}`
|
||||
return filename
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.toLowerCase().includes('no such table')) return null
|
||||
@@ -450,14 +464,14 @@ async function unityAssetR2Key(db: D1Database, filename: string): Promise<string
|
||||
async function serveUnityAsset(c: Context<App>) {
|
||||
const name = c.req.param('filename') ?? ''
|
||||
if (name.includes('..')) return c.body(null, 400)
|
||||
const key = await unityAssetR2Key(c.env.DB, name)
|
||||
const key = await unityAssetR2Key(c.env, name)
|
||||
return serveAsset(c, key ?? `unityasset/${name}`)
|
||||
}
|
||||
|
||||
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
|
||||
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
|
||||
if (name.includes('..')) return new Response(null, { status: 400 })
|
||||
const key = await unityAssetR2Key(env.DB, name)
|
||||
const key = await unityAssetR2Key(env, name)
|
||||
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
|
||||
if (!head) return new Response(null, { status: 404 })
|
||||
|
||||
|
||||
@@ -12,6 +12,32 @@ declare module 'cloudflare:test' {
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
test('serves opaque avatar source manifests and SDK avatar bundle GET/HEAD/ranges', async () => {
|
||||
const source = '2026-10-18/11111111-1111-1111-1111-111111111111'
|
||||
await env.CDN_ASSETS.put('avatarunityasset/' + source, '{"Version":3}')
|
||||
expect(await (await exports.default.fetch(ORIGIN + '/avatarunityasset/' + source)).text()).toBe(
|
||||
'{"Version":3}'
|
||||
)
|
||||
await env.DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS unity_asset(unity_asset_id TEXT NOT NULL,target INTEGER NOT NULL,version INTEGER NOT NULL,kind TEXT NOT NULL,filename TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(unity_asset_id,target,version,kind))'
|
||||
).run()
|
||||
const name = 'quest/studio-avatar/test/item/lease.material.assetbundle'
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO unity_asset(unity_asset_id,target,version,kind,filename,hash) VALUES (?,2,6,?,?,?)'
|
||||
)
|
||||
.bind('avatar-fixture', 'material', name, 'hash')
|
||||
.run()
|
||||
await env.CDN_ASSETS.put('avatar/' + name, 'UnityFS\0avatar')
|
||||
expect(
|
||||
(await exports.default.fetch(ORIGIN + '/unityasset/' + name, { method: 'HEAD' })).status
|
||||
).toBe(200)
|
||||
const response = await exports.default.fetch(ORIGIN + '/unityasset/' + name, {
|
||||
headers: { Range: 'bytes=0-7' },
|
||||
})
|
||||
expect(response.status).toBe(206)
|
||||
expect(await response.text()).toBe('UnityFS\0')
|
||||
})
|
||||
|
||||
describe('cdn endpoints', () => {
|
||||
test('GET / reports service status', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/`)
|
||||
@@ -357,6 +383,18 @@ describe('cdn endpoints', () => {
|
||||
expect(head.status).toBe(200)
|
||||
expect(head.headers.get('content-length')).toBe('4')
|
||||
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
|
||||
// Current room/consumable builds live in the game's room namespace.
|
||||
await env.CDN_ASSETS.put('room/' + filename, new Uint8Array([5, 6, 7, 8, 9]))
|
||||
const current = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
|
||||
headers: { Range: 'bytes=1-2' },
|
||||
})
|
||||
expect(current.status).toBe(206)
|
||||
expect(new Uint8Array(await current.arrayBuffer())).toEqual(new Uint8Array([6, 7]))
|
||||
const currentHead = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
|
||||
method: 'HEAD',
|
||||
})
|
||||
expect(currentHead.status).toBe(200)
|
||||
expect(currentHead.headers.get('content-length')).toBe('5')
|
||||
|
||||
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
|
||||
expect(
|
||||
|
||||
@@ -17,11 +17,13 @@ export const SCHEMA_DDL: string[] = [
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) 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 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 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. */
|
||||
|
||||
@@ -106,6 +106,8 @@ import {
|
||||
ConsumeConsumableRequest,
|
||||
ConsumeEnvelope,
|
||||
ConsumeGiftRequest,
|
||||
ConsumeRoomConsumableRequest,
|
||||
ConsumeRoomConsumableResponse,
|
||||
CreatePurchaseOfferRequest,
|
||||
CreateRoomCurrencyRequest,
|
||||
CreateRoomKeyRequest,
|
||||
@@ -177,6 +179,7 @@ import {
|
||||
import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
|
||||
import {
|
||||
awardRoomConsumable,
|
||||
consumeRoomConsumable,
|
||||
getRoomConsumable,
|
||||
getRoomConsumables,
|
||||
getRoomInventory,
|
||||
@@ -4220,6 +4223,65 @@ const app = new Hono<App>({ strict: false })
|
||||
return c.json(await getRoomInventory(c.env.DB, roomId, id))
|
||||
}
|
||||
)
|
||||
// Use up one of a room consumable the CALLER holds — drinking the Health Potion. Auth-gated
|
||||
// (401); a refusal is a 200 in the same shape, as the purchase's is.
|
||||
.put(
|
||||
'/api/roomconsumables/v1/roomConsumable/:id/consume',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Consume a room consumable',
|
||||
description: [
|
||||
'Takes ONE of the consumable named in the path from what the caller holds. The body',
|
||||
'is only `{ CurrentConcurrencyCode, NewConcurrencyCode }`, at the top level; there is',
|
||||
'no quantity. `NewConcurrencyCode` is stored and served back as the item’s',
|
||||
'`ConcurrencyCode`; `CurrentConcurrencyCode` is accepted and NOT checked.',
|
||||
'',
|
||||
'Answers `{ Status, InventoryItem }` — the purchase’s `OperationResult` — with the',
|
||||
'`RoomConsumableInventoryItem` the caller now holds: `Count` the RESULTING total, 0',
|
||||
'when they used their last (after which `…/room/{roomId}/me` omits it).',
|
||||
'',
|
||||
'A consumable that does not exist, or one the caller holds none of, is `Status` 1',
|
||||
'with a null `InventoryItem`, and nothing is written. The refusal form is an',
|
||||
'ASSUMPTION — only success has been observed.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The consumable to use — a `room_consumable` id',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
requestBody: jsonBody(ConsumeRoomConsumableRequest, 'The concurrency pair'),
|
||||
responses: {
|
||||
200: json(ConsumeRoomConsumableResponse, 'What the caller now holds, or a refusal'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.json<{ NewConcurrencyCode?: unknown }>().catch(() => null)
|
||||
const newCode =
|
||||
body !== null && typeof body === 'object' && typeof body.NewConcurrencyCode === 'string'
|
||||
? body.NewConcurrencyCode
|
||||
: null
|
||||
// Every key is always sent: an omitted `Status` reads as its enum's 0, success.
|
||||
const refused = () => c.json({ Status: ROOM_KEY_STATUS_FAILED, InventoryItem: null })
|
||||
|
||||
const consumable = await getRoomConsumable(c.env.DB, c.req.param('id'))
|
||||
if (!consumable) return refused()
|
||||
const held = await consumeRoomConsumable(c.env.DB, id, consumable.RoomConsumableId, newCode)
|
||||
if (!held) return refused()
|
||||
return c.json({
|
||||
Status: ROOM_KEY_STATUS_OK,
|
||||
InventoryItem: toInventoryItem(id, held, consumable),
|
||||
})
|
||||
}
|
||||
)
|
||||
// The custom currencies a room has minted. Public, like the room itself: a room's
|
||||
// currencies are shown to every player who walks into it, so the list is not a secret
|
||||
// the way its ban list is. A room with none — or an unknown room — is an empty list.
|
||||
|
||||
@@ -1159,6 +1159,27 @@ export const PurchaseRoomConsumableResponse = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` — just the concurrency pair, at the
|
||||
* TOP level (not nested under `ConcurrencyCodes` as the purchase sends it). Uses up one.
|
||||
*/
|
||||
export const ConsumeRoomConsumableRequest = z.object({
|
||||
CurrentConcurrencyCode: z.string().nullable().optional().describe('Accepted and NOT checked'),
|
||||
NewConcurrencyCode: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Stored and echoed back as the item’s `ConcurrencyCode`'),
|
||||
})
|
||||
|
||||
/** The consume's answer: the purchase's `OperationResult`, alone. */
|
||||
export const ConsumeRoomConsumableResponse = z.object({
|
||||
Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'),
|
||||
InventoryItem: RoomConsumableInventoryItemDto.nullable().describe(
|
||||
'What the caller holds afterwards (`Count` the resulting total, 0 included); null on a refusal'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* The envelope the consumable write answers in — the same
|
||||
* `{ Value, Success, Error, error_id }` the room-currency writes use.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* - `POST /api/roomconsumables/v1/roomConsumable/awardBulk` gives one to a player
|
||||
* - `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens` and `…/purchase/currency`
|
||||
* sell one, in tokens or the listing's room currency
|
||||
* - `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` uses up one the caller holds
|
||||
*
|
||||
* `PurchaseCurrencyId` is a `room_currency`, so a room prices its goods in its own money. It
|
||||
* is nullable — `Guid?` in the client's model — for a listing that names no currency, and
|
||||
@@ -344,6 +345,42 @@ export async function awardRoomConsumable(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use up ONE of a consumable a player holds, returning what they hold afterwards — or null
|
||||
* when they hold none, and nothing was written. `concurrencyCode` is the request's
|
||||
* `NewConcurrencyCode`, stored to be served back; null leaves the stored one.
|
||||
*
|
||||
* A single conditional UPDATE: the `quantity >= 1` guard is what refuses, so two consumes
|
||||
* racing over the last one cannot both succeed. Here an empty `RETURNING` is exactly the
|
||||
* answer wanted — "you had none" — unlike the award, where it would be ambiguous.
|
||||
*/
|
||||
export async function consumeRoomConsumable(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
consumableId: string,
|
||||
concurrencyCode: string | null = null
|
||||
): Promise<RoomInventoryHolding | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`UPDATE room_inventory SET
|
||||
quantity = quantity - 1,
|
||||
concurrency_code = COALESCE(?3, concurrency_code),
|
||||
modified_at = ?4
|
||||
WHERE player_id = ?1 AND consumable_id = ?2 AND quantity >= 1
|
||||
RETURNING quantity, concurrency_code, modified_at`
|
||||
)
|
||||
.bind(playerId, consumableId, concurrencyCode, new Date().toISOString())
|
||||
.first<{ quantity: number; concurrency_code: string | null; modified_at: string | null }>()
|
||||
|
||||
return row
|
||||
? {
|
||||
quantity: row.quantity,
|
||||
concurrencyCode: row.concurrency_code,
|
||||
modifiedAt: row.modified_at,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a player owns of one room's consumables, in the shop's order. A listing they own
|
||||
* none of is absent — including one they held and spent down to zero.
|
||||
|
||||
@@ -1773,6 +1773,77 @@ describe('econ endpoints', () => {
|
||||
await env.DB.prepare('DELETE FROM room_balance WHERE currency_id = ?1').bind(coin).run()
|
||||
})
|
||||
|
||||
test('PUT roomConsumable/:id/consume uses up one the caller holds', async () => {
|
||||
const potion = (
|
||||
await envOf(await save({ ...body, Name: 'Drinkable potion' }, await bearer('1')))
|
||||
).Value!
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO room_inventory (player_id, consumable_id, quantity) VALUES (7801, ?1, 2)'
|
||||
)
|
||||
.bind(potion.RoomConsumableId)
|
||||
.run()
|
||||
|
||||
const consume = async (sub: string | null, consumableId: string, code: string) =>
|
||||
exports.default.fetch(
|
||||
`${ORIGIN}/api/roomconsumables/v1/roomConsumable/${consumableId}/consume`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(sub === null ? {} : await bearer(sub)),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
CurrentConcurrencyCode: '872adb91-b163-4f2c-aa8f-4f5d828078cb',
|
||||
NewConcurrencyCode: code,
|
||||
}),
|
||||
}
|
||||
)
|
||||
const held = async () =>
|
||||
(
|
||||
await env.DB.prepare(
|
||||
'SELECT quantity FROM room_inventory WHERE player_id = 7801 AND consumable_id = ?1'
|
||||
)
|
||||
.bind(potion.RoomConsumableId)
|
||||
.first<{ quantity: number }>()
|
||||
)?.quantity
|
||||
|
||||
expect((await consume(null, potion.RoomConsumableId, 'a')).status).toBe(401)
|
||||
|
||||
const res = await consume('7801', potion.RoomConsumableId, 'code-1')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
Status: 0,
|
||||
InventoryItem: {
|
||||
RoomConsumableId: potion.RoomConsumableId,
|
||||
AccountId: 7801,
|
||||
Count: 1,
|
||||
ConcurrencyCode: 'code-1',
|
||||
ModifiedAt: expect.any(String),
|
||||
Consumable: { ...potion, ImageName: '', IsGiftable: false, Visibility: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
// The last one answers Count 0; after that there is nothing to use.
|
||||
const last = (await (await consume('7801', potion.RoomConsumableId, 'code-2')).json()) as {
|
||||
Status: number
|
||||
InventoryItem: { Count: number; ConcurrencyCode: string } | null
|
||||
}
|
||||
expect(last).toMatchObject({
|
||||
Status: 0,
|
||||
InventoryItem: { Count: 0, ConcurrencyCode: 'code-2' },
|
||||
})
|
||||
const refusal = { Status: 1, InventoryItem: null }
|
||||
expect(await (await consume('7801', potion.RoomConsumableId, 'code-3')).json()).toEqual(
|
||||
refusal
|
||||
)
|
||||
expect(await held()).toBe(0)
|
||||
// Someone who never had one, and an unknown consumable, are refused too.
|
||||
expect(await (await consume('7802', potion.RoomConsumableId, 'x')).json()).toEqual(refusal)
|
||||
expect(await (await consume('7801', 'nope', 'x')).json()).toEqual(refusal)
|
||||
|
||||
await env.DB.prepare('DELETE FROM room_inventory').run()
|
||||
})
|
||||
|
||||
test('GET roomConsumable/room/:id is public and empty for an unknown room', async () => {
|
||||
expect(await shopOf(99999)).toEqual([])
|
||||
const res = await exports.default.fetch(
|
||||
@@ -8047,6 +8118,7 @@ describe('econ endpoints', () => {
|
||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||
'PUT /api/equipment/v1/update',
|
||||
'PUT /api/roomconsumables/v1/roomConsumable',
|
||||
'PUT /api/roomconsumables/v1/roomConsumable/{id}/consume',
|
||||
'PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{via}',
|
||||
'PUT /api/roomkeys/v1/updateAll',
|
||||
])
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||
import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
|
||||
import {
|
||||
banFromReport,
|
||||
@@ -289,6 +290,9 @@ beforeAll(async () => {
|
||||
// Platform identity links (owned by the auth worker) — a ban also reaches the
|
||||
// accounts sharing a proven identity with the banned one.
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Custom avatar items (owned by the api worker) — every room read joins the published
|
||||
// room badges from this table.
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,13 @@ Player-settings worker served on the `playersettings` subdomain.
|
||||
> Emptying the map with DELETE puts the player back to a first read: the next
|
||||
> `GET` re-seeds the defaults.
|
||||
|
||||
> Keys under `Growth.YIR.` (the client's Year-In-Review telemetry — distance
|
||||
> drawn, time with friends, …) and `MakerPenSessionCount` are **dropped** on
|
||||
> `PUT`, not stored. The client posts them with a new value every time, so they
|
||||
> were over half of all writes and nothing reads them. Real maker pen preferences
|
||||
> (`MakerPen_SnappingMode`, …) are kept. See `IGNORED_SETTING_KEYS` /
|
||||
> `IGNORED_SETTING_PREFIXES` in `default-settings.ts`.
|
||||
|
||||
## KV namespace
|
||||
|
||||
```sh
|
||||
|
||||
@@ -8,3 +8,21 @@ export const DEFAULT_SETTINGS: Array<{ Key: string; Value: string }> = [
|
||||
{ Key: 'TUTORIAL_COMPLETE_MASK', Value: '11' },
|
||||
{ Key: 'FIRST_TIME_IN_FLAGS', Value: '0' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Settings the server DROPS on write, by exact key and by prefix. The client posts its
|
||||
* Year-In-Review telemetry (`Growth.YIR.UGC.TubeDistance`, `Growth.YIR.DistanceTraveled`, …)
|
||||
* and the `MakerPenSessionCount` counter through this endpoint as the player plays, and the
|
||||
* values change every time, so they can never be deduplicated — measured at ~500 CCU they
|
||||
* were over half of all settings writes. Nothing server-side reads them and the client works
|
||||
* without them (a new player has none), so a PUT naming one is answered 200 and nothing is
|
||||
* stored. Real maker pen preferences (`MakerPen_SnappingMode` and the other `MakerPen_*`
|
||||
* keys) are kept: the client reads those back.
|
||||
*/
|
||||
export const IGNORED_SETTING_KEYS: ReadonlySet<string> = new Set(['MakerPenSessionCount'])
|
||||
export const IGNORED_SETTING_PREFIXES: readonly string[] = ['Growth.YIR.']
|
||||
|
||||
/** Whether a posted setting is one the server drops rather than stores. */
|
||||
export function isIgnoredSetting(key: string): boolean {
|
||||
return IGNORED_SETTING_KEYS.has(key) || IGNORED_SETTING_PREFIXES.some((p) => key.startsWith(p))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { mergePlayerSettings } from '@repo/domain'
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import { DEFAULT_SETTINGS } from './default-settings'
|
||||
import { DEFAULT_SETTINGS, isIgnoredSetting } from './default-settings'
|
||||
import {
|
||||
AUTHED,
|
||||
formOrJson,
|
||||
@@ -196,7 +196,9 @@ const app = new Hono<App>()
|
||||
'player’s other settings alone. A JSON body is also accepted, as one object or an',
|
||||
'array, in either `key`/`value` or `Key`/`Value` casing; entries with an empty key are',
|
||||
'dropped. An unparseable or empty body is a no-op 200, not a 400, and so is a PUT whose',
|
||||
'values are already stored — nothing is written to KV. Empty body on success.',
|
||||
'values are already stored — nothing is written to KV. Keys under `Growth.YIR.` (the',
|
||||
'client’s Year-In-Review telemetry) and `MakerPenSessionCount`, which change on every',
|
||||
'post and nothing reads, are dropped rather than stored. Empty body on success.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
|
||||
@@ -209,7 +211,7 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const incoming = await parseSettings(c)
|
||||
const incoming = (await parseSettings(c)).filter((s) => !isIgnoredSetting(s.key))
|
||||
if (incoming.length === 0) return c.body(null, 200)
|
||||
|
||||
const patch: Record<string, string> = {}
|
||||
|
||||
@@ -164,6 +164,42 @@ describe('playersettings endpoints', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('PUT /playersettings drops Growth.YIR and MakerPenSessionCount without writing', async () => {
|
||||
const stored = '{ "PlayerSessionCount": "5" }'
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put('player:11', stored)
|
||||
|
||||
const form = await SELF.fetch(
|
||||
`${ORIGIN}/playersettings`,
|
||||
putForm({ key: 'Growth.YIR.UGC.TubeDistance', value: '1234.5' }, await bearer('11'))
|
||||
)
|
||||
expect(form.status).toBe(200)
|
||||
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'text')).toBe(stored)
|
||||
|
||||
// A JSON batch keeps the real settings (a maker pen PREFERENCE included) and drops
|
||||
// only the telemetry and the session counter.
|
||||
const json = await SELF.fetch(`${ORIGIN}/playersettings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json', ...(await bearer('11')) },
|
||||
body: JSON.stringify([
|
||||
{ key: 'Growth.YIR.DistanceTraveled', value: '99' },
|
||||
{ key: 'MakerPenSessionCount', value: '3' },
|
||||
{ key: 'MakerPen_SnappingMode', value: '2' },
|
||||
]),
|
||||
})
|
||||
expect(json.status).toBe(200)
|
||||
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'json')).toEqual({
|
||||
PlayerSessionCount: '5',
|
||||
MakerPen_SnappingMode: '2',
|
||||
})
|
||||
|
||||
// A player with nothing stored stays unseeded: no key is created for telemetry alone.
|
||||
await SELF.fetch(
|
||||
`${ORIGIN}/playersettings`,
|
||||
putForm({ key: 'Growth.YIR.TimeSpentWithFriends', value: '7' }, await bearer('12'))
|
||||
)
|
||||
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:12', 'text')).toBeNull()
|
||||
})
|
||||
|
||||
it('PUT /playersettings 200s with no parseable settings', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9')))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- The searchable rooms by lowercased name, for the website's header search
|
||||
-- (`GET /rooms/search/name`, searchRoomsByName).
|
||||
--
|
||||
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- PARTIAL over exactly SEARCHABLE_WHERE, so a prefix seek visits only rooms it can serve
|
||||
-- and the LIMIT stops it: D1 bills the rows returned, not the room count. The existing
|
||||
-- `idx_rooms_name_lower` holds every dorm and private room, and each one the seek passed
|
||||
-- would be read to test the filters. Built from the VIRTUAL columns, so no backfill.
|
||||
CREATE INDEX IF NOT EXISTS idx_room_search_name ON room (name_lower)
|
||||
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1;
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
roomRoles,
|
||||
saveSubRoomData,
|
||||
searchRooms,
|
||||
searchRoomsByName,
|
||||
setPlayerData,
|
||||
setRoomDescription,
|
||||
setRoomImage,
|
||||
@@ -83,6 +84,7 @@ import {
|
||||
transferRoomOwnership,
|
||||
unbanPlayerFromRoom,
|
||||
updateRoomFields,
|
||||
withConfirmedRoles,
|
||||
writeAuditLog,
|
||||
} from '@repo/domain'
|
||||
import {
|
||||
@@ -665,16 +667,14 @@ function parseMaxPlayerCalculationMode(value: unknown): number | undefined {
|
||||
return Object.values(MAX_PLAYER_CALCULATION_MODES).includes(ordinal) ? ordinal : undefined
|
||||
}
|
||||
|
||||
async function pushRoomUpdate(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
room: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
async function pushRoomUpdate(c: Context<App>, playerId: number, room: Room): Promise<void> {
|
||||
try {
|
||||
// The pushed room is the client's room, like any served one: pending invites are not
|
||||
// part of its `Roles` (see `serveRoom`).
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
NotificationType.SubscriptionUpdateRoom,
|
||||
room
|
||||
withConfirmedRoles(room)
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push RoomUpdate notification', {
|
||||
@@ -702,7 +702,7 @@ async function pushRoomUpdate(
|
||||
async function pushRoomUpdateToRoom(
|
||||
c: Context<App>,
|
||||
roomId: number,
|
||||
room: Record<string, unknown>,
|
||||
room: Room,
|
||||
alsoNotify: number[] = []
|
||||
): Promise<void> {
|
||||
const playerIds = new Set(await getPlayerIdsInRoom(c.env.DB, roomId))
|
||||
@@ -962,9 +962,15 @@ function pickBakedBundle(
|
||||
target: number | null
|
||||
): { filename: string; hash: string | null } | null {
|
||||
if (!Array.isArray(save.UnitySubAssets)) return null
|
||||
const rootId = typeof save.UnityAssetId === 'string' ? save.UnityAssetId.toLowerCase() : null
|
||||
const rows = save.UnitySubAssets.filter(
|
||||
(item): item is { Target?: unknown; Filename?: unknown; Hash?: unknown } =>
|
||||
typeof item === 'object' && item !== null
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
(rootId === null ||
|
||||
('UnityAssetId' in item &&
|
||||
typeof item.UnityAssetId === 'string' &&
|
||||
item.UnityAssetId.toLowerCase() === rootId))
|
||||
)
|
||||
const chosen =
|
||||
target === null
|
||||
@@ -1108,16 +1114,44 @@ function toSaveDetail(save: Record<string, unknown>, target: number | null) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Client envelope for room mutations: `{ success, error, value }` (lowercase). */
|
||||
function roomEnvelope(c: Context<App>, value: unknown, error = '') {
|
||||
return c.json({ success: error === '', error, value })
|
||||
/**
|
||||
* A room as the client is shown it. The stored room is served whole EXCEPT for its pending
|
||||
* role invites: a `Roles` entry with `Role` 0 and an `InvitedRole` is an offer the player
|
||||
* has not answered, and served as-is it put the invited player on the room's member list
|
||||
* (and, on the 2025 client, in its co-owner list) before they had accepted anything. The
|
||||
* offer reaches them as a `RoomCoOwnerInvited` message; the entry stays in the STORED room
|
||||
* for the accept to promote, which is why this is applied where the room leaves the worker
|
||||
* and never to a room about to be written back.
|
||||
*
|
||||
* Every room this worker answers — alone, in a list, in the mutation envelope or pushed as
|
||||
* a `RoomUpdate` — goes through this (or {@link serveRooms}).
|
||||
*/
|
||||
const serveRoom = withConfirmedRoles
|
||||
|
||||
/** {@link serveRoom} over a list. */
|
||||
const serveRooms = (rooms: Room[]): Room[] => rooms.map(serveRoom)
|
||||
|
||||
/** {@link serveRoom} over a `{ Results, TotalResults }` page. */
|
||||
const serveRoomPage = <T extends { Results: Room[] }>(page: T): T => ({
|
||||
...page,
|
||||
Results: serveRooms(page.Results),
|
||||
})
|
||||
|
||||
/**
|
||||
* Client envelope for room mutations: `{ success, error, value }` (lowercase). `value` is
|
||||
* the updated room (served through {@link serveRoom}) or null on a refusal.
|
||||
*/
|
||||
function roomEnvelope(c: Context<App>, value: Room | null, error = '') {
|
||||
return c.json({ success: error === '', error, value: value && serveRoom(value) })
|
||||
}
|
||||
|
||||
/**
|
||||
* The same envelope for the ban write, whose `value` is the BAN rather than the room —
|
||||
* a ban isn't part of the room the client renders, so there is no updated room to send.
|
||||
*/
|
||||
const banEnvelope = roomEnvelope
|
||||
function banEnvelope(c: Context<App>, value: unknown, error = '') {
|
||||
return c.json({ success: error === '', error, value })
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope both leaderboard routes answer: `{ Success, Error, error_id }`, carrying
|
||||
@@ -1156,7 +1190,7 @@ const experienceEnvelope = leaderboardEnvelope
|
||||
async function ownedRooms(c: Context<App>) {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
return c.json(await getRoomsByCreator(c.env.DB, accountId))
|
||||
return c.json(serveRooms(await getRoomsByCreator(c.env.DB, accountId)))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1169,12 +1203,15 @@ async function ownedRoomsExcludingDorm(c: Context<App>) {
|
||||
const rooms = (await getContributedRooms(c.env.DB, accountId)).filter((room) =>
|
||||
canManageRoom(room, accountId)
|
||||
)
|
||||
return c.json(rooms.filter((r) => r.IsDorm !== true))
|
||||
return c.json(serveRooms(rooms.filter((r) => r.IsDorm !== true)))
|
||||
}
|
||||
|
||||
/** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */
|
||||
const DEFAULT_SUGGESTION_COUNT = 10
|
||||
|
||||
/** Cap on `/rooms/search/name`'s `take`: a type-ahead list, so the seek stays a few rows. */
|
||||
const NAME_SEARCH_MAX = 10
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -1325,10 +1362,10 @@ const app = new Hono<App>()
|
||||
if (idParam) {
|
||||
const id = firstId(idParam)
|
||||
const room = id === undefined ? null : await getRoomById(c.env.DB, id)
|
||||
return c.json(room ?? (c.req.query('include') === '525581' ? null : {}))
|
||||
return c.json(room ? serveRoom(room) : c.req.query('include') === '525581' ? null : {})
|
||||
}
|
||||
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
||||
return c.json(room ?? (c.req.query('include') === '525581' ? null : {}))
|
||||
return c.json(room ? serveRoom(room) : c.req.query('include') === '525581' ? null : {})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1360,7 +1397,7 @@ const app = new Hono<App>()
|
||||
return c.json({ error: `You can only have ${maxRooms} rooms.` }, 403)
|
||||
const room = await createStudioRoom(c.env.DB, name, accountId, version)
|
||||
if (!room) return c.json({ error: 'A room with that name already exists.' }, 409)
|
||||
return c.json(room)
|
||||
return c.json(serveRoom(room))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1398,7 +1435,39 @@ const app = new Hono<App>()
|
||||
const query = c.req.query('query') ?? ''
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30
|
||||
return c.json(await searchRooms(c.env.DB, query, skip, take))
|
||||
return c.json(serveRoomPage(await searchRooms(c.env.DB, query, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
// The website's header search: searchable rooms whose name STARTS with `query`, A–Z,
|
||||
// as a bare array. Not the game's search (`/rooms/search` is a substring match that
|
||||
// reads every searchable room); this is an index seek that reads only the rows it
|
||||
// returns, which is what lets the site ask on every pause in typing. Un-hydrated rooms.
|
||||
.get(
|
||||
'/rooms/search/name',
|
||||
describeRoute({
|
||||
tags: ['Discovery'],
|
||||
summary: 'Prefix-search rooms by name',
|
||||
description: [
|
||||
'Searchable rooms (public, non-dorm, not `ExcludeFromSearch`) whose name begins',
|
||||
'with `query`, case-insensitive, alphabetically — a bare array. An index seek, so',
|
||||
'it reads only the rooms it returns. Rooms are served without subrooms, tags or',
|
||||
'badges. The game’s substring search is `/rooms/search`.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
stringQuery('query', 'Name prefix'),
|
||||
intQuery('take', `How many rooms to return (default and max ${NAME_SEARCH_MAX})`),
|
||||
],
|
||||
responses: { 200: json(RoomDto.array(), 'The matching rooms, A–Z') },
|
||||
}),
|
||||
async (c) => {
|
||||
const take = Number.parseInt(c.req.query('take') ?? '', 10)
|
||||
const rooms = await searchRoomsByName(
|
||||
c.env.DB,
|
||||
c.req.query('query') ?? '',
|
||||
Number.isNaN(take) ? NAME_SEARCH_MAX : Math.min(take, NAME_SEARCH_MAX)
|
||||
)
|
||||
return c.json(serveRooms(rooms))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1483,7 +1552,7 @@ const app = new Hono<App>()
|
||||
const tag = c.req.query('tag') ?? ''
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getHotRooms(c.env.DB, tag, skip, take))
|
||||
return c.json(serveRoomPage(await getHotRooms(c.env.DB, tag, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1535,7 +1604,7 @@ const app = new Hono<App>()
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getTrendingRooms(c.env.DB, skip, take))
|
||||
return c.json(serveRoomPage(await getTrendingRooms(c.env.DB, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1557,7 +1626,7 @@ const app = new Hono<App>()
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getBaseRooms(c.env.DB, skip, take))
|
||||
return c.json(serveRooms(await getBaseRooms(c.env.DB, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1585,7 +1654,7 @@ const app = new Hono<App>()
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getRecommendedRooms(c.env.DB, skip, take))
|
||||
return c.json(serveRooms(await getRecommendedRooms(c.env.DB, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1680,13 +1749,15 @@ const app = new Hono<App>()
|
||||
if (ids.length > MAX_BULK_ROOM_IDS) return tooManyIds(c)
|
||||
const rooms = await getRoomsByIds(c.env.DB, ids)
|
||||
return c.json(
|
||||
excludePrivateRooms(c.req.query('excludePrivateRooms'))
|
||||
? rooms.filter((r) => r.Accessibility === 1)
|
||||
: rooms
|
||||
serveRooms(
|
||||
excludePrivateRooms(c.req.query('excludePrivateRooms'))
|
||||
? rooms.filter((r) => r.Accessibility === 1)
|
||||
: rooms
|
||||
)
|
||||
)
|
||||
}
|
||||
const room = await getRoomByName(c.env.DB, nameParam ?? '')
|
||||
return c.json(room ? [room] : [])
|
||||
return c.json(room ? [serveRoom(room)] : [])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1738,7 +1809,7 @@ const app = new Hono<App>()
|
||||
const rooms = await getRoomsByIds(c.env.DB, ids)
|
||||
|
||||
const excludePrivate = excludePrivateRooms(field('excludePrivateRooms')[0])
|
||||
return c.json(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms)
|
||||
return c.json(serveRooms(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1829,7 +1900,7 @@ const app = new Hono<App>()
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
return c.json(await getContributedRooms(c.env.DB, accountId))
|
||||
return c.json(serveRooms(await getContributedRooms(c.env.DB, accountId)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1886,7 +1957,11 @@ const app = new Hono<App>()
|
||||
responses: { 200: json(RoomDto.array(), 'That account’s public rooms') },
|
||||
}),
|
||||
async (c) =>
|
||||
c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
|
||||
c.json(
|
||||
serveRooms(
|
||||
await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// A player's showcased rooms — the hand-picked rail the client draws on a profile,
|
||||
@@ -1934,7 +2009,7 @@ const app = new Hono<App>()
|
||||
if (accountId === null) return unauthorized(c)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getFavoritedRooms(c.env.DB, accountId, skip, take))
|
||||
return c.json(serveRooms(await getFavoritedRooms(c.env.DB, accountId, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1958,7 +2033,7 @@ const app = new Hono<App>()
|
||||
if (accountId === null) return unauthorized(c)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getVisitedRooms(c.env.DB, accountId, skip, take))
|
||||
return c.json(serveRooms(await getVisitedRooms(c.env.DB, accountId, skip, take)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2004,9 +2079,11 @@ const app = new Hono<App>()
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(
|
||||
own
|
||||
? await getVisitedRooms(c.env.DB, playerId, skip, take)
|
||||
: await getPublicVisitedRooms(c.env.DB, playerId, skip, take)
|
||||
serveRooms(
|
||||
own
|
||||
? await getVisitedRooms(c.env.DB, playerId, skip, take)
|
||||
: await getPublicVisitedRooms(c.env.DB, playerId, skip, take)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -2617,9 +2694,14 @@ const app = new Hono<App>()
|
||||
summary: 'A room’s roles',
|
||||
description: [
|
||||
'Everyone with a role in the room — its creator plus whoever has been granted Host,',
|
||||
'Moderator or CoOwner — as whole `RoomRole` records. This is the same array the',
|
||||
'room DTO serves under `Roles`, on its own endpoint, so it is public exactly like the',
|
||||
'room is: a role list says who runs a room, which the room page already shows.',
|
||||
'Moderator or CoOwner — as whole `RoomRole` records, PLUS every pending invite. It is',
|
||||
'public exactly like the room is: a role list says who runs a room, which the room',
|
||||
'page already shows.',
|
||||
'',
|
||||
'Unlike the room DTO’s `Roles`, this list INCLUDES unanswered co-owner invites (`Role`',
|
||||
'0, the offer on `InvitedRole`): the client reads it to show the players with an',
|
||||
'invite pending. The room DTO leaves those entries out, because there an invited',
|
||||
'player read as a member before they had accepted anything.',
|
||||
'',
|
||||
'A bare array, NOT the `{ success, error, value }` envelope the role WRITE answers. An',
|
||||
'unknown room is an empty list rather than an error — it reads the same as a room',
|
||||
@@ -4305,7 +4387,7 @@ const app = new Hono<App>()
|
||||
return c.json({
|
||||
success: true,
|
||||
error: null,
|
||||
value: { room: result.room, subRoomDataSave: toSaveResponse(result.save) },
|
||||
value: { room: serveRoom(result.room), subRoomDataSave: toSaveResponse(result.save) },
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -4856,7 +4938,9 @@ const app = new Hono<App>()
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(
|
||||
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
|
||||
serveRoomPage(
|
||||
await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -5124,7 +5208,9 @@ const app = new Hono<App>()
|
||||
tags: ['Rooms'],
|
||||
summary: 'A room by id',
|
||||
description: [
|
||||
'The room as stored, with its `SubRooms` re-attached. Unlike `GET /rooms?id=`, an',
|
||||
'The room as stored, with its `SubRooms` re-attached and pending role invites left',
|
||||
'out of `Roles` (an unanswered invite is `Role` 0 with the offer on `InvitedRole`; the',
|
||||
'invited player is not a member until they accept). Unlike `GET /rooms?id=`, an',
|
||||
'unknown room here is a 404, not `{}`. A save that has a Rec Room Studio build',
|
||||
'lists its main bundles on `CurrentSave.UnitySubAssets` (`Target` 0 Windows, 2',
|
||||
'Android/Quest, `Filename` downloaded from the CDN at `/unityasset/{filename}`).',
|
||||
@@ -5147,7 +5233,7 @@ const app = new Hono<App>()
|
||||
const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
|
||||
if (!room) return c.notFound()
|
||||
filterRoomUnityAssets(room, unityAssetTargetQuery(c))
|
||||
return c.json(room)
|
||||
return c.json(serveRoom(room))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
answerRoomRoleInvite,
|
||||
AUDIT_LOG_SCHEMA_DDL,
|
||||
countRoomsUsingInvention,
|
||||
createRoomInstance,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
UNITY_ASSET_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
||||
import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db'
|
||||
import { NotificationType } from '../../../../notify/src/notification-types'
|
||||
@@ -134,6 +136,7 @@ beforeAll(async () => {
|
||||
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Inventions (owned by the api worker) — a room save writes each one's room count.
|
||||
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves.
|
||||
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
@@ -538,6 +541,20 @@ describe('rooms endpoints', () => {
|
||||
Roles: [{ AccountId: 822, Role: 30 }],
|
||||
})
|
||||
await seed({ RoomId: 30405, Name: 'ContribNoRoles', CreatorAccountId: 821, SubRooms: [] })
|
||||
// ...and one where 820 has only been INVITED to co-own — `Role` 0 with the offer on
|
||||
// `InvitedRole`. They have no job in the room until they accept, so it stays out of
|
||||
// both of their lists, and the pending entry is not served as part of the room.
|
||||
await seed({
|
||||
RoomId: 30408,
|
||||
Name: 'ContribInvited',
|
||||
CreatorAccountId: 821,
|
||||
Accessibility: 1,
|
||||
SubRooms: [],
|
||||
Roles: [
|
||||
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
{ AccountId: 820, Role: 0, LastChangedByAccountId: 821, InvitedRole: 30 },
|
||||
],
|
||||
})
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
|
||||
headers: await bearer('820'),
|
||||
@@ -552,6 +569,46 @@ describe('rooms endpoints', () => {
|
||||
expect(rooms.some((r) => r.RoomId === 30407)).toBe(false)
|
||||
expect(rooms[0]).toMatchObject({ Name: expect.any(String), Accessibility: expect.any(Number) })
|
||||
|
||||
// The unanswered invite lists the room for NOBODY: not under "contributed" (above),
|
||||
// not under "owned".
|
||||
const owned = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, { headers: await bearer('820') })
|
||||
const ownedIds = ((await owned.json()) as Array<{ RoomId: number }>).map((r) => r.RoomId)
|
||||
expect(ownedIds).toContain(30401)
|
||||
expect(ownedIds).not.toContain(30408)
|
||||
|
||||
// Nor is the invited player part of the room as served: the room DTO carries only HELD
|
||||
// roles. `GET /rooms/:id/roles` DOES list the invite — the client shows pending invites
|
||||
// from it — and the stored room keeps the entry for the accept.
|
||||
type Served = { Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }> }
|
||||
const servedRoles = async () =>
|
||||
((await (await SELF.fetch(`${ORIGIN}/rooms/30408`)).json()) as Served).Roles
|
||||
expect(await servedRoles()).toEqual([
|
||||
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
])
|
||||
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30408/roles`)).json()).toEqual([
|
||||
{ AccountId: 821, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
{ AccountId: 820, Role: 0, LastChangedByAccountId: 821, InvitedRole: 30 },
|
||||
])
|
||||
expect(
|
||||
await env.DB.prepare(
|
||||
'SELECT role, invited_role FROM room_role WHERE room_id = 30408 AND account_id = 820'
|
||||
).first()
|
||||
).toEqual({ role: 0, invited_role: 30 })
|
||||
|
||||
// Accepting is what puts them in: the room appears in both lists and the entry is served.
|
||||
const invitedRoom = (await getRoomById(env.DB, 30408))!
|
||||
expect(await answerRoomRoleInvite(env.DB, 30408, 820, 30, invitedRoom)).not.toBeNull()
|
||||
expect(await contributed('820')).toContain(30408)
|
||||
const ownedAfter = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
|
||||
headers: await bearer('820'),
|
||||
})
|
||||
expect(((await ownedAfter.json()) as Array<{ RoomId: number }>).map((r) => r.RoomId)).toContain(
|
||||
30408
|
||||
)
|
||||
expect(await servedRoles()).toContainEqual(
|
||||
expect.objectContaining({ AccountId: 820, Role: 30, InvitedRole: 0 })
|
||||
)
|
||||
|
||||
// A player who contributes to nothing gets an empty array, not a 404.
|
||||
const none = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
|
||||
headers: await bearer('829'),
|
||||
@@ -577,8 +634,8 @@ describe('rooms endpoints', () => {
|
||||
|
||||
// The DB is shared across this file, and these are the only player-made public rooms
|
||||
// in it — leaving them behind changes what the `new`/`community` room feeds serve.
|
||||
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run()
|
||||
await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30407').run()
|
||||
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30408').run()
|
||||
await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30408').run()
|
||||
})
|
||||
|
||||
it('GET /rooms/:roomId/experience serves the room’s stored XP settings, no auth', async () => {
|
||||
@@ -624,6 +681,46 @@ describe('rooms endpoints', () => {
|
||||
expect(body.Results.some((r) => r.Name === 'RecCenter')).toBe(true)
|
||||
})
|
||||
|
||||
it('GET /rooms/search/name prefix-matches searchable rooms A–Z through the partial index', async () => {
|
||||
const search = async (query: string, extra = '') =>
|
||||
(await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/search/name?query=${encodeURIComponent(query)}${extra}`)
|
||||
).json()) as Array<{ Name: string; IsDorm?: boolean; Stats: { VisitCount: number } }>
|
||||
|
||||
// A bare array of whole rooms, case-insensitive, every name starting with the query, A–Z.
|
||||
const rec = await search('REC', '&take=10')
|
||||
expect(rec.some((r) => r.Name === 'RecCenter')).toBe(true)
|
||||
for (const r of rec) expect(r.Name.toLowerCase().startsWith('rec')).toBe(true)
|
||||
const names = rec.map((r) => r.Name.toLowerCase())
|
||||
expect(names).toEqual([...names].sort())
|
||||
expect(typeof rec[0]?.Stats.VisitCount).toBe('number')
|
||||
|
||||
// A prefix, not a substring: "center" is inside RecCenter but doesn't start it.
|
||||
expect((await search('center')).some((r) => r.Name === 'RecCenter')).toBe(false)
|
||||
// `take` caps it, and is itself capped at 10; empty and wildcard queries find nothing.
|
||||
expect((await search('r', '&take=2')).length).toBeLessThanOrEqual(2)
|
||||
expect(await search('', '&take=500')).toEqual([])
|
||||
expect(await search('%')).toEqual([])
|
||||
expect((await search('r', '&take=500')).length).toBeLessThanOrEqual(10)
|
||||
// Dorms (named `@user's Dorm`) are never served.
|
||||
expect((await search('@')).every((r) => r.IsDorm !== true)).toBe(true)
|
||||
|
||||
// The point of the endpoint: a SEEK on the partial index, so D1 reads only the rows it
|
||||
// returns. Fall back to a scan (or the unfiltered name index) and every call is billed
|
||||
// for every room again.
|
||||
const plan = await env.DB.prepare(
|
||||
`EXPLAIN QUERY PLAN SELECT data FROM room
|
||||
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1
|
||||
AND name_lower >= ?1 AND name_lower < ?2 AND name_lower LIKE ?3 ESCAPE '\\'
|
||||
ORDER BY name_lower LIMIT 5`
|
||||
)
|
||||
.bind('r', 'r\u{10FFFF}', 'r%')
|
||||
.all<{ detail: string }>()
|
||||
const detail = plan.results.map((r) => r.detail).join('\n')
|
||||
expect(detail).toContain('SEARCH room USING INDEX idx_room_search_name')
|
||||
expect(detail).not.toContain('TEMP B-TREE')
|
||||
})
|
||||
|
||||
it('GET /rooms/autocomplete_search suggests names and tags as plain strings', async () => {
|
||||
const suggest = async (query: string, extra = '') =>
|
||||
(await (
|
||||
@@ -1673,6 +1770,18 @@ describe('rooms endpoints', () => {
|
||||
type RoomEnv = { success: boolean; error: string; value: Record<string, unknown> | null }
|
||||
const envOf = async (res: Response) => (await res.json()) as RoomEnv
|
||||
|
||||
// A role entry as STORED, read off the `room_role` index — pending invites included.
|
||||
// The served room carries only held roles; `GET /rooms/:id/roles` lists invites too.
|
||||
type StoredRole = { Role: number; InvitedRole: number; LastChangedByAccountId: number | null }
|
||||
const storedRoleOf = async (roomId: number, accountId: number) =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT role AS Role, invited_role AS InvitedRole,
|
||||
last_changed_by_account_id AS LastChangedByAccountId
|
||||
FROM room_role WHERE room_id = ?1 AND account_id = ?2`
|
||||
)
|
||||
.bind(roomId, accountId)
|
||||
.first<StoredRole>()) ?? undefined
|
||||
|
||||
// A subroom as the client sees it. There is no GET for a single subroom — the client
|
||||
// reads them off the room — so tests do the same.
|
||||
const subRoomOf = async (
|
||||
@@ -2089,7 +2198,9 @@ describe('rooms endpoints', () => {
|
||||
success: false,
|
||||
error: 'You must provide a valid role!',
|
||||
})
|
||||
expect(await roleOf(885)).toMatchObject({ InvitedRole: 30 })
|
||||
expect(await storedRoleOf(2, 885)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
// The role list still shows the invite as pending.
|
||||
expect(await roleOf(885)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
})
|
||||
|
||||
it('a revoke pushes a RoomUpdate to the room and to the player who lost the role', async () => {
|
||||
@@ -2148,9 +2259,13 @@ describe('rooms endpoints', () => {
|
||||
)
|
||||
|
||||
// RecCenter (room 2) is owned by account 1. The owner invites 850 to co-ownership,
|
||||
// leaving a pending offer and no role yet.
|
||||
// leaving a pending offer and no role yet. The role list shows the pending invite, but
|
||||
// the room DTO does not list them: they are not a member until they accept.
|
||||
expect((await putForm('/rooms/2/roles/850/invite', { role: '30' }, '1')).status).toBe(200)
|
||||
expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Roles: Role[] }
|
||||
expect(room2.Roles.some((r) => r.AccountId === 850)).toBe(false)
|
||||
|
||||
// No token → 401 (auth gate).
|
||||
expect((await putForm('/rooms/2/roles/850', { role: '30' })).status).toBe(401)
|
||||
@@ -2163,7 +2278,7 @@ describe('rooms endpoints', () => {
|
||||
await envOf(await putForm('/rooms/2/roles/850', { role: '30' }, caller))
|
||||
).toMatchObject({ success: false, error: 'Co-ownership must be invited, not granted!' })
|
||||
}
|
||||
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
// Unknown room → failure envelope.
|
||||
expect(
|
||||
await envOf(await putForm('/rooms/99999/roles/850', { role: '30' }, '850'))
|
||||
@@ -2180,7 +2295,7 @@ describe('rooms endpoints', () => {
|
||||
expect(await envOf(await putForm('/rooms/2/roles/850', { role: '255' }, '850'))).toMatchObject({
|
||||
success: false,
|
||||
})
|
||||
expect(await roleOf(850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
expect(await storedRoleOf(2, 850)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
|
||||
// A player with no entry in the room at all is the same rejection — probing tells
|
||||
// them nothing.
|
||||
@@ -2227,11 +2342,13 @@ describe('rooms endpoints', () => {
|
||||
// Invited, then declined: the whole entry goes, leaving `Roles` as it was before the
|
||||
// offer rather than an entry with nothing pending on it.
|
||||
expect((await putForm('/rooms/2/roles/851/invite', { role: '30' }, '1')).status).toBe(200)
|
||||
expect(await storedRoleOf(2, 851)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
expect(await roleOf(851)).toMatchObject({ Role: 0, InvitedRole: 30 })
|
||||
|
||||
const declined = await putForm('/rooms/2/roles/851', { role: '0' }, '851')
|
||||
expect(declined.status).toBe(200)
|
||||
expect(await envOf(declined)).toMatchObject({ success: true, error: '' })
|
||||
expect(await storedRoleOf(2, 851)).toBeUndefined()
|
||||
expect(await roleOf(851)).toBeUndefined()
|
||||
|
||||
// And the decline can't be replayed either — there is no entry left to answer.
|
||||
@@ -2246,6 +2363,7 @@ describe('rooms endpoints', () => {
|
||||
expect(await roleOf(852)).toMatchObject({ Role: 10, InvitedRole: 0 })
|
||||
expect((await putForm('/rooms/2/roles/852/invite', { role: '30' }, '1')).status).toBe(200)
|
||||
expect((await putForm('/rooms/2/roles/852', { role: '0' }, '852')).status).toBe(200)
|
||||
expect(await storedRoleOf(2, 852)).toBeUndefined()
|
||||
expect(await roleOf(852)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -2493,12 +2611,23 @@ describe('rooms endpoints', () => {
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// A new entry, holding no role yet — only the pending offer, stamped with the inviter.
|
||||
// The role list shows it as a pending invite (the client lists pending invites from
|
||||
// it); the room DTO's `Roles` does not, until they accept.
|
||||
expect(await storedRoleOf(2, 187)).toEqual({
|
||||
Role: 0,
|
||||
InvitedRole: 30,
|
||||
LastChangedByAccountId: 1,
|
||||
})
|
||||
expect(await rolesOf(2)).toContainEqual({
|
||||
AccountId: 187,
|
||||
Role: 0,
|
||||
InvitedRole: 30,
|
||||
LastChangedByAccountId: 1,
|
||||
})
|
||||
const room2 = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
||||
Roles: Array<{ AccountId: number }>
|
||||
}
|
||||
expect(room2.Roles.some((r) => r.AccountId === 187)).toBe(false)
|
||||
|
||||
// One MessageReceived to the INVITED player carrying a type 62 RoomCoOwnerInvited —
|
||||
// the entry alone is silent, this is what raises the invite on their client.
|
||||
@@ -6162,6 +6291,7 @@ describe('rooms endpoints', () => {
|
||||
'GET /rooms/ownedby/{accountId}',
|
||||
'GET /rooms/recommendations',
|
||||
'GET /rooms/search',
|
||||
'GET /rooms/search/name',
|
||||
'GET /rooms/visitedby/me',
|
||||
'GET /rooms/visitedby/{playerId}',
|
||||
'GET /rooms/{roomId}',
|
||||
@@ -6329,6 +6459,111 @@ describe('studio room bundles', () => {
|
||||
)
|
||||
}
|
||||
|
||||
it('serves partitioned prefab and shared-material exports as save dependencies', async () => {
|
||||
await reset()
|
||||
await storeFiles()
|
||||
const prefab = '00111111-2222-4333-8444-555555555555'
|
||||
const shared = '00222222-2222-4333-8444-555555555555'
|
||||
await env.DB.prepare(
|
||||
"UPDATE subroom_save SET data=json_set(data,'$.ServerData',json(?1)) WHERE sub_room_id=99011"
|
||||
)
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
Exports: [
|
||||
{ e: 'prefab', u: prefab.toUpperCase() },
|
||||
{ e: 'shared', u: shared },
|
||||
{ e: 'duplicate', u: prefab },
|
||||
],
|
||||
})
|
||||
)
|
||||
.run()
|
||||
for (const id of [prefab, shared]) {
|
||||
for (const target of [0, 2]) {
|
||||
for (const kind of ['main', 'stripped'] as const) {
|
||||
await putUnityAsset(
|
||||
env.DB,
|
||||
{
|
||||
UnityAssetId: id,
|
||||
Target: target,
|
||||
Version: 5,
|
||||
Filename: publicStudioBundleFilename(
|
||||
9901,
|
||||
id,
|
||||
target === 0 ? 'windows' : 'android',
|
||||
kind
|
||||
),
|
||||
Hash: winHash,
|
||||
},
|
||||
kind
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const target of [0, 2]) {
|
||||
const response = await SELF.fetch(`${ORIGIN}/rooms/9901?unityAssetTarget=${target}`)
|
||||
const room = (await response.json()) as {
|
||||
SubRooms: Array<{
|
||||
CurrentSave: {
|
||||
SubRoomDataSaveId: number
|
||||
UnitySubAssets: Array<{ UnityAssetId: string; Target: number }>
|
||||
}
|
||||
}>
|
||||
}
|
||||
const current = room.SubRooms[0]!.CurrentSave
|
||||
expect(current.UnitySubAssets.map((asset) => asset.UnityAssetId).sort()).toEqual(
|
||||
[ASSET, prefab, shared].sort()
|
||||
)
|
||||
expect(current.UnitySubAssets.every((asset) => asset.Target === target)).toBe(true)
|
||||
const detailResponse = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${current.SubRoomDataSaveId}?unityAssetTarget=${target}`,
|
||||
{ headers: await bearer('1') }
|
||||
)
|
||||
expect(await detailResponse.json()).toMatchObject({
|
||||
UnityAsset: target === 0 ? winName : androidName,
|
||||
UnitySubAssets: [prefab, shared].map((id) => ({
|
||||
UnityAssetId: id,
|
||||
UnityAsset: publicStudioBundleFilename(
|
||||
9901,
|
||||
id,
|
||||
target === 0 ? 'windows' : 'android',
|
||||
'main'
|
||||
),
|
||||
UnityAssetHash: winHash,
|
||||
})),
|
||||
})
|
||||
}
|
||||
// In-game saves reuse the compiled Studio asset. They must keep the export
|
||||
// manifest when staging, saving again, and publishing the new scene data.
|
||||
for (const autoPublish of [false, false, true]) {
|
||||
const response = await SELF.fetch(`${ORIGIN}/rooms/9901/subrooms/99011/data`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('1')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
UnityAssetId: ASSET.toUpperCase(),
|
||||
SubRoomData: { Filename: 'in-game-save' },
|
||||
AutoPublish: autoPublish,
|
||||
}),
|
||||
})
|
||||
const result = (await response.json()) as {
|
||||
success: boolean
|
||||
value: { subRoomDataSave: { subRoomDataSaveId: number; unityAsset: string } }
|
||||
}
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.value.subRoomDataSave.unityAsset).toBe(winName)
|
||||
const detail = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/9901/subrooms/99011/saves/${result.value.subRoomDataSave.subRoomDataSaveId}?unityAssetTarget=0`,
|
||||
{ headers: await bearer('1') }
|
||||
)
|
||||
expect(await detail.json()).toMatchObject({
|
||||
UnityAsset: winName,
|
||||
UnitySubAssets: [prefab, shared].map((UnityAssetId) => ({ UnityAssetId })),
|
||||
})
|
||||
}
|
||||
await env.DB.prepare('DELETE FROM unity_asset WHERE unity_asset_id IN (?1,?2)')
|
||||
.bind(prefab, shared)
|
||||
.run()
|
||||
})
|
||||
|
||||
it('loads baked bundles onto the room and serves the unity asset', async () => {
|
||||
await reset()
|
||||
|
||||
@@ -7185,3 +7420,63 @@ describe('Studio source revision permissions', () => {
|
||||
expect(await source.json()).toMatchObject({ filename: 'first-draft.json' })
|
||||
})
|
||||
})
|
||||
|
||||
it('omits hydrated Badges when persisting a room settings edit', async () => {
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 987670,
|
||||
Name: 'BadgeSerialization',
|
||||
CreatorAccountId: 987670,
|
||||
SubRooms: [],
|
||||
})
|
||||
await env.DB.prepare(
|
||||
"UPDATE room SET data=json_set(data,'$.Badges',json(?)) WHERE room_id=987670"
|
||||
)
|
||||
.bind(JSON.stringify([{ Name: 'derived badge' }]))
|
||||
.run()
|
||||
const response = await SELF.fetch(`${ORIGIN}/rooms/987670/warning`, {
|
||||
method: 'PUT',
|
||||
headers: await bearer('987670'),
|
||||
body: new URLSearchParams({ warningMask: '1' }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const stored = await env.DB.prepare('SELECT data FROM room WHERE room_id=987670').first<{
|
||||
data: string
|
||||
}>()
|
||||
expect(JSON.parse(stored!.data)).not.toHaveProperty('Badges')
|
||||
})
|
||||
|
||||
// The badge lookup runs on every room page, served by a partial index over the badge rows
|
||||
// (api migrations/0035, mirrored in SCHEMA_DDL above). Only a PUBLISHED badge with a built save
|
||||
// hydrates; a draft stays on its creator's Studio shelf.
|
||||
it("GET /rooms/:id hydrates the room's published badges", async () => {
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
RoomId: 987671,
|
||||
Name: 'BadgeHydration',
|
||||
CreatorAccountId: 987671,
|
||||
SubRooms: [],
|
||||
})
|
||||
const badge = (id: string, roomId: number, accessibility: number, saves: unknown[]) =>
|
||||
JSON.stringify({
|
||||
CustomAvatarItemId: id,
|
||||
CreatorAccountId: 987671,
|
||||
Name: id,
|
||||
OutfitType: 1000,
|
||||
Accessibility: accessibility,
|
||||
CurrentSaves: saves,
|
||||
CustomBadgeMetadata: { RoomId: roomId },
|
||||
})
|
||||
const insert = env.DB.prepare('INSERT INTO custom_avatar_item (data) VALUES (?)')
|
||||
await env.DB.batch([
|
||||
insert.bind(badge('badge-published', 987671, 1, [{ BodyType: 0 }])),
|
||||
insert.bind(badge('badge-draft', 987671, 0, [{ BodyType: 0 }])),
|
||||
insert.bind(badge('badge-unbuilt', 987671, 1, [])),
|
||||
insert.bind(badge('badge-other-room', 987670, 1, [{ BodyType: 0 }])),
|
||||
])
|
||||
// Twice: the second read takes the path that remembers the table exists.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/987671`)
|
||||
expect(res.status).toBe(200)
|
||||
const room = (await res.json()) as { Badges: Array<{ CustomAvatarItemId: string }> }
|
||||
expect(room.Badges.map((b) => b.CustomAvatarItemId)).toEqual(['badge-published'])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -56,6 +56,8 @@ const UPLOAD_SUBFOLDER: Record<number, string> = {
|
||||
8: 'room',
|
||||
9: 'roomunityasset',
|
||||
10: 'unityasset',
|
||||
13: 'avatar',
|
||||
14: 'avatarunityasset',
|
||||
}
|
||||
|
||||
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
|
||||
@@ -380,6 +382,7 @@ const app = new Hono<App>()
|
||||
contentType = image.contentType
|
||||
}
|
||||
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
|
||||
customMetadata: { ownerId: String(id) },
|
||||
httpMetadata: { contentType },
|
||||
// Record the SHA-256 on the object. R2 stores an md5 on its own, but the
|
||||
// hashes the client is served (an invention's `BlobHash`) are SHA-256, and
|
||||
|
||||
@@ -21,6 +21,27 @@ beforeAll(async () => {
|
||||
// storage worker's validation accepts it.
|
||||
const TEST_SECRET = 'test-signing-key'
|
||||
|
||||
it('stores avatar source and baked uploads with immutable account ownership metadata', async () => {
|
||||
for (const [type, prefix] of [
|
||||
[13, 'avatar'],
|
||||
[14, 'avatarunityasset'],
|
||||
] as const) {
|
||||
const form = new FormData()
|
||||
form.set('fileType', String(type))
|
||||
form.set('file', new File(['avatar source'], 'source.bin'))
|
||||
const r = await SELF.fetch(ORIGIN + '/upload', {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'User-Agent': 'RRStudio' },
|
||||
body: form,
|
||||
})
|
||||
expect(r.status).toBe(200)
|
||||
const body = await r.json<{ Filename: string; Hash: string }>()
|
||||
expect(body.Hash).toBeTruthy()
|
||||
const object = await env.CDN_ASSETS.head(prefix + '/' + body.Filename)
|
||||
expect(object?.customMetadata?.ownerId).toBe('42')
|
||||
}
|
||||
})
|
||||
|
||||
function b64url(input: ArrayBuffer | string): string {
|
||||
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
|
||||
let binary = ''
|
||||
|
||||
@@ -83,12 +83,16 @@ build ID with a null completion time. Repeated requests for an in-progress save
|
||||
reuse its job. `GET /cloud-builds?cloudBuildId=...` reads the status;
|
||||
`PUT /cloud-builds/cancel` cancels a queued or running job.
|
||||
|
||||
Apply migration `0003_studio_cloud_build_job.sql`. Configure a private
|
||||
`CLOUD_BUILD_KEY` and run the outbound worker from `recflare/cloud-building` with
|
||||
the matching credential. Without a key, creation returns an explicit unavailable
|
||||
response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and Android), and
|
||||
`CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169. Match installed editor modules to
|
||||
the enabled targets.
|
||||
Apply migration `0003_studio_cloud_build_job.sql`. Create the private
|
||||
`CLOUD_BUILD_KEY` in the shared Secrets Store (the one `JWT_SECRET` lives in — see
|
||||
`.env.example`) and run the outbound worker from `recflare/cloud-building` with the
|
||||
same value as its `STUDIO_WORKER_KEY`. The binding must exist for the worker to
|
||||
deploy; a value of `off` disables cloud building, and creation then returns an
|
||||
explicit unavailable response. `CLOUD_BUILD_TARGETS` defaults to `0,2` (Windows and
|
||||
Android), and `CLOUD_BUILD_TEMPLATE_VERSION` defaults to 169 — the cloud-building
|
||||
worker claims only jobs for its own configured template version (171 by default),
|
||||
so set this to match or jobs queue and are never claimed. Match installed editor
|
||||
modules to the enabled targets.
|
||||
|
||||
The private `/internal/cloud-builds/claim`, `/:id/heartbeat`, and `/:id/finish`
|
||||
routes lease jobs to that worker. Only validated UnityFS artifacts for the
|
||||
@@ -96,5 +100,20 @@ allocated asset IDs and requested platforms are accepted. SDK bundle version 5
|
||||
is retained. Completion links the submitted save; auto-publish does not replace
|
||||
a newer draft. Failed, cancelled, or expired jobs never become successful builds.
|
||||
Claims include a signed, short-lived SDK validation token with no account subject
|
||||
or roles. Deploy the paired API support: only bulk component-text validation
|
||||
accepts this credential. It cannot authenticate as an account or publish a room.
|
||||
or roles. Bulk component-text validation and job-scoped grant reads accept this
|
||||
credential. It cannot authenticate as an account or publish a room.
|
||||
|
||||
## Avatar Studio and consumable validation
|
||||
|
||||
Avatar authoring uses per-account Studio grants, source revisions, and the managed
|
||||
cloud queue. Apply `migrations/0004_avatar_studio.sql` to the shared database before
|
||||
enabling these routes. The API owns creator saves and explicit publication; the
|
||||
Studio service queues prefab builds and returns per-platform main/stripped bundles
|
||||
at version 5 and material bundles at version 6. Image badges publish directly.
|
||||
Changed body/configuration inputs receive distinct output IDs, and automatic
|
||||
publication cannot replace a newer revision. Deploy the paired cloud-building
|
||||
worker for prefab support; restricted validation credentials grant no account role.
|
||||
|
||||
The SDK's access override reads the target account's entitlement. Build-validation
|
||||
credentials can inspect only the current owners of their active job's room; peers
|
||||
can inspect grants only while they currently manage the same room.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id);
|
||||
CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL);
|
||||
@@ -1,11 +1,18 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import {
|
||||
AVATAR_STUDIO_SLOTS,
|
||||
avatarItemAccessBit,
|
||||
avatarStudioFlags,
|
||||
avatarStudioItem,
|
||||
avatarStudioSave,
|
||||
canManageRoom,
|
||||
canManageRoomById,
|
||||
getRoomById,
|
||||
getSubRoom,
|
||||
getSubRoomSaveById,
|
||||
publicStudioBundleFilename,
|
||||
publishAvatarSave,
|
||||
studioBundleR2Key,
|
||||
} from '@repo/domain'
|
||||
import {
|
||||
@@ -16,13 +23,28 @@ import {
|
||||
|
||||
import { parseMultipart } from './multipart'
|
||||
|
||||
import type { App } from './context'
|
||||
import type { Context } from 'hono'
|
||||
import type { App, Env } from './context'
|
||||
|
||||
export const CLOUD_JOB_SCHEMA = `CREATE TABLE IF NOT EXISTS studio_cloud_build_job (
|
||||
cloud_build_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
|
||||
lease_token TEXT, lease_until TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL
|
||||
)`
|
||||
interface Job {
|
||||
kind?: 'avatar'
|
||||
avatarItemId?: string
|
||||
avatar?: {
|
||||
itemId: string
|
||||
outfitType: number
|
||||
bodyType: number
|
||||
configuration: string
|
||||
name: string
|
||||
createdAt: string
|
||||
price: number
|
||||
creatorAccountId: number
|
||||
canCreateRoomieEquipment: boolean
|
||||
permittedItemTypes: number[]
|
||||
}
|
||||
buildId: string
|
||||
roomId: number
|
||||
subRoomId: number
|
||||
@@ -34,7 +56,8 @@ interface Job {
|
||||
targets: number[]
|
||||
manifest: {
|
||||
Version: number
|
||||
MainSceneProjectRelativePath: string
|
||||
MainSceneProjectRelativePath?: string
|
||||
MainPrefabProjectRelativePath?: string
|
||||
ExportPartitionVersion: number
|
||||
Files: Array<{ n: string; p: string; g?: string }>
|
||||
Exports?: Array<{ e: string; d?: string[] }>
|
||||
@@ -51,6 +74,7 @@ const ok = (value: unknown) => ({ success: true, error: null, value })
|
||||
const fail = (error: string) => ({ success: false, error, value: null })
|
||||
const ID = /^[1-9]\d*$/
|
||||
const guid = () => crypto.randomUUID()
|
||||
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
|
||||
interface Artifact {
|
||||
field: string
|
||||
unityAssetId: string
|
||||
@@ -84,7 +108,6 @@ interface CloudBuildRecord {
|
||||
unity_asset_id: string
|
||||
created_by_account_id: number
|
||||
}
|
||||
const textField = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback)
|
||||
function wire(row: CloudBuildRecord) {
|
||||
return {
|
||||
cloudBuildId: row.cloud_build_id,
|
||||
@@ -99,7 +122,21 @@ function wire(row: CloudBuildRecord) {
|
||||
: null,
|
||||
}
|
||||
}
|
||||
async function privateAuth(request: Request, key: string | undefined) {
|
||||
/**
|
||||
* The pull worker's credential, resolved per request from the Secrets Store. Returns ''
|
||||
* when cloud building is off: the secret holds the literal `off` (the binding has to
|
||||
* resolve for the worker to deploy, so that is how an operator opts out) or can't be read.
|
||||
* Every gate on "is cloud building configured" goes through here.
|
||||
*/
|
||||
export async function cloudBuildKey(env: Env): Promise<string> {
|
||||
try {
|
||||
const key = (await env.CLOUD_BUILD_KEY.get()) ?? ''
|
||||
return key === 'off' ? '' : key
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
async function privateAuth(request: Request, key: string) {
|
||||
if (!key) return false
|
||||
const wanted = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`Bearer ${key}`))
|
||||
const got = await crypto.subtle.digest(
|
||||
@@ -116,7 +153,48 @@ async function pendingBuild(db: D1Database, roomId: number, subRoomId: number, s
|
||||
.bind(roomId, subRoomId, saveId)
|
||||
.first<CloudBuildRecord>()
|
||||
}
|
||||
async function pendingAvatarBuild(db: D1Database, itemId: string, saveId: number) {
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) JOIN studio_cloud_build_job j USING(cloud_build_id) WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing') LIMIT 1"
|
||||
)
|
||||
.bind(itemId, saveId)
|
||||
.first<CloudBuildRecord>()
|
||||
}
|
||||
const app = new Hono<App>()
|
||||
.get('/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}', async (c) => {
|
||||
const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (id === null) return c.body(null, 401)
|
||||
const item = await avatarStudioItem(c.env.DB, c.req.param('itemId'))
|
||||
if (!item || item.CreatorAccountId !== id) return c.body(null, 403)
|
||||
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
|
||||
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
|
||||
const { results } = await c.env.DB.prepare(
|
||||
'SELECT b.* FROM studio_cloud_build b JOIN studio_avatar_build a USING(cloud_build_id) WHERE a.item_id=? ORDER BY b.started_at DESC'
|
||||
)
|
||||
.bind(item.CustomAvatarItemId)
|
||||
.all<CloudBuildRecord>()
|
||||
return c.json({
|
||||
results: results.slice(skip, skip + take).map(wire),
|
||||
totalResults: results.length,
|
||||
})
|
||||
})
|
||||
.post(
|
||||
'/cloud-builds/custom-avatar-item/:itemId{[0-9a-fA-F-]{36}}/save/:saveId{[1-9][0-9]*}',
|
||||
(c) => enqueueAvatar(c, c.req.param('itemId'), Number(c.req.param('saveId')))
|
||||
)
|
||||
.post('/cloud-builds/custom-avatar-item/create-for-new-build-version', async (c) => {
|
||||
const form = await c.req.parseBody()
|
||||
const itemId = String(form.customAvatarItemId ?? ''),
|
||||
assetId = String(form.unityAssetId ?? '')
|
||||
const row = await c.env.DB.prepare(
|
||||
'SELECT id FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
|
||||
)
|
||||
.bind(itemId, assetId)
|
||||
.first<{ id: number }>()
|
||||
if (!row) return c.notFound()
|
||||
return enqueueAvatar(c, itemId, row.id, form)
|
||||
})
|
||||
.post('/cloud-builds/create', async (c) => {
|
||||
const secret = await c.env.JWT_SECRET.get()
|
||||
const accountId = await validateAndGetAccountId(c.req.raw, secret)
|
||||
@@ -124,7 +202,7 @@ const app = new Hono<App>()
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
if (!roles?.includes('betastudio'))
|
||||
return c.json(fail('Studio upload authorization is required.'), 403)
|
||||
if (!c.env.CLOUD_BUILD_KEY) return c.json(fail('Cloud building is not configured.'), 503)
|
||||
if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
|
||||
const form = await c.req.parseBody()
|
||||
const ids = ['roomId', 'subRoomId', 'subRoomDataSaveId'].map((key) => textField(form[key]))
|
||||
if (ids.some((id) => !ID.test(id) || !Number.isSafeInteger(Number(id))))
|
||||
@@ -272,11 +350,16 @@ const app = new Hono<App>()
|
||||
const form = await c.req.parseBody()
|
||||
const id = textField(form.cloudBuildId)
|
||||
const row = await c.env.DB.prepare(
|
||||
'SELECT room_id FROM studio_cloud_build WHERE cloud_build_id = ?'
|
||||
'SELECT room_id,created_by_account_id FROM studio_cloud_build WHERE cloud_build_id = ?'
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ room_id: number }>()
|
||||
if (!row || !(await canManageRoomById(c.env.DB, row.room_id, accountId)))
|
||||
.first<{ room_id: number; created_by_account_id: number }>()
|
||||
if (
|
||||
!row ||
|
||||
!(row.room_id === 0
|
||||
? row.created_by_account_id === accountId
|
||||
: await canManageRoomById(c.env.DB, row.room_id, accountId))
|
||||
)
|
||||
return c.json(fail('No accessible build.'), 404)
|
||||
await c.env.DB.batch([
|
||||
c.env.DB.prepare(
|
||||
@@ -289,7 +372,7 @@ const app = new Hono<App>()
|
||||
return c.json(ok(null))
|
||||
})
|
||||
.use('/internal/cloud-builds/*', async (c, next) => {
|
||||
if (!(await privateAuth(c.req.raw, c.env.CLOUD_BUILD_KEY))) return c.body(null, 401)
|
||||
if (!(await privateAuth(c.req.raw, await cloudBuildKey(c.env)))) return c.body(null, 401)
|
||||
return next()
|
||||
})
|
||||
.post('/internal/cloud-builds/claim', async (c) => {
|
||||
@@ -325,17 +408,13 @@ const app = new Hono<App>()
|
||||
})
|
||||
.post('/internal/cloud-builds/:id/heartbeat', async (c) => {
|
||||
const { leaseToken } = await c.req.json<{ leaseToken: string }>()
|
||||
const leaseUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString()
|
||||
const result = await c.env.DB.prepare(
|
||||
"UPDATE studio_cloud_build_job SET lease_until=? WHERE cloud_build_id=? AND lease_token=? AND status IN ('building','publishing') AND lease_until>?"
|
||||
)
|
||||
.bind(
|
||||
new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
||||
c.req.param('id'),
|
||||
leaseToken,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.bind(leaseUntil, c.req.param('id'), leaseToken, new Date().toISOString())
|
||||
.run()
|
||||
return c.json({ active: result.meta.changes > 0 })
|
||||
return c.json(result.meta.changes > 0 ? { active: true, leaseUntil } : { active: false })
|
||||
})
|
||||
.post('/internal/cloud-builds/:id/finish', async (c) => {
|
||||
const form = await parseMultipart(c.req.raw)
|
||||
@@ -359,6 +438,26 @@ const app = new Hono<App>()
|
||||
])
|
||||
return c.json(ok(null))
|
||||
}
|
||||
if (job.kind === 'avatar') {
|
||||
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
|
||||
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
|
||||
if (
|
||||
!item ||
|
||||
!save ||
|
||||
item.CreatorAccountId !== job.accountId ||
|
||||
!((await avatarStudioFlags(c.env.DB, job.accountId)) & avatarItemAccessBit(item.OutfitType))
|
||||
) {
|
||||
await c.env.DB.batch([
|
||||
c.env.DB.prepare(
|
||||
"UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='building'"
|
||||
).bind(id, lease.lease_token),
|
||||
c.env.DB.prepare(
|
||||
"UPDATE studio_cloud_build SET completed_at=?,error='Avatar source or authorization was removed.' WHERE cloud_build_id=? AND completed_at=''"
|
||||
).bind(new Date().toISOString(), id),
|
||||
])
|
||||
return c.json(fail('Avatar source or authorization was removed.'), 409)
|
||||
}
|
||||
}
|
||||
const assets = new Set([job.unityAssetId, ...job.exports.map((item) => item.u)])
|
||||
let entries: unknown
|
||||
try {
|
||||
@@ -368,7 +467,10 @@ const app = new Hono<App>()
|
||||
}
|
||||
if (!Array.isArray(entries) || !entries.every(isArtifact))
|
||||
return c.json(fail('Invalid build artifact metadata.'), 400)
|
||||
if (!entries.length || entries.length > assets.size * job.targets.length * 2)
|
||||
if (
|
||||
!entries.length ||
|
||||
entries.length > assets.size * job.targets.length * (job.kind === 'avatar' ? 3 : 2)
|
||||
)
|
||||
return c.json(fail('Missing build artifacts.'), 400)
|
||||
const seen = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
@@ -377,8 +479,10 @@ const app = new Hono<App>()
|
||||
if (
|
||||
!assets.has(entry.unityAssetId) ||
|
||||
!job.targets.includes(entry.target) ||
|
||||
!['main', 'stripped'].includes(entry.kind) ||
|
||||
entry.version !== 5 ||
|
||||
!(job.kind === 'avatar' ? ['main', 'stripped', 'material'] : ['main', 'stripped']).includes(
|
||||
entry.kind
|
||||
) ||
|
||||
entry.version !== (job.kind === 'avatar' && entry.kind === 'material' ? 6 : 5) ||
|
||||
!(file instanceof File) ||
|
||||
file.size === 0 ||
|
||||
seen.has(key)
|
||||
@@ -390,6 +494,11 @@ const app = new Hono<App>()
|
||||
for (const target of job.targets)
|
||||
if (!seen.has(`${asset}:${target}:main`))
|
||||
return c.json(fail('A required asset bundle is missing.'), 400)
|
||||
if (
|
||||
job.kind === 'avatar' &&
|
||||
job.targets.some((target) => !seen.has(`${job.unityAssetId}:${target}:material`))
|
||||
)
|
||||
return c.json(fail('An avatar material bundle is missing.'), 400)
|
||||
const writes: D1PreparedStatement[] = []
|
||||
for (const entry of entries) {
|
||||
const file = form[entry.field] as File
|
||||
@@ -416,8 +525,11 @@ const app = new Hono<App>()
|
||||
entry.target === 2 ? 'android' : 'windows',
|
||||
entry.kind
|
||||
)
|
||||
const filename = baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
|
||||
const key = studioBundleR2Key(filename)
|
||||
const filename =
|
||||
job.kind === 'avatar'
|
||||
? `${entry.target === 2 ? 'quest/' : ''}studio-avatar/${job.avatarItemId}/${entry.unityAssetId}/${lease.lease_token}.${entry.kind}.assetbundle`
|
||||
: baseFilename.replace('.assetbundle', `.${lease.lease_token}.assetbundle`)
|
||||
const key = job.kind === 'avatar' ? `avatar/${filename}` : studioBundleR2Key(filename)
|
||||
await c.env.CDN_ASSETS.put(key, bytes)
|
||||
keys.push(key)
|
||||
writes.push(
|
||||
@@ -439,48 +551,52 @@ const app = new Hono<App>()
|
||||
await c.env.CDN_ASSETS.put(serverDataKey, JSON.stringify({ Exports: job.exports }))
|
||||
keys.push(serverDataKey)
|
||||
// The exact submitted save is linked; a newer draft must never be replaced.
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
||||
).bind(
|
||||
job.unityAssetId,
|
||||
JSON.stringify({ Exports: job.exports }),
|
||||
job.saveId,
|
||||
job.subRoomId,
|
||||
id,
|
||||
lease.lease_token
|
||||
)
|
||||
)
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
|
||||
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
|
||||
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
||||
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
|
||||
)
|
||||
if (job.autoPublish) {
|
||||
// Test and update the staging slot in the same statement. A draft saved
|
||||
// during compilation must not be replaced by this build's older save.
|
||||
if (job.kind !== 'avatar')
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
`UPDATE subroom SET current_save_id=?,
|
||||
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
|
||||
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
|
||||
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
|
||||
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
||||
"UPDATE subroom_save SET data=json_set(data,'$.UnityAssetId',?,'$.ServerData',json(?)) WHERE sub_room_data_save_id=? AND sub_room_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
||||
).bind(
|
||||
job.saveId,
|
||||
job.saveId,
|
||||
job.roomId,
|
||||
job.subRoomId,
|
||||
job.saveId,
|
||||
job.unityAssetId,
|
||||
JSON.stringify({ Exports: job.exports }),
|
||||
job.saveId,
|
||||
job.subRoomId,
|
||||
id,
|
||||
lease.lease_token
|
||||
)
|
||||
)
|
||||
if (job.kind !== 'avatar') {
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
`UPDATE room SET data=json_set(data,'$.BecameRRStudioRoomAt',?)
|
||||
WHERE room_id=? AND json_type(data,'$.BecameRRStudioRoomAt') IS NOT 'text'
|
||||
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
||||
).bind(new Date().toISOString(), job.roomId, id, lease.lease_token)
|
||||
)
|
||||
if (job.autoPublish) {
|
||||
// Test and update the staging slot in the same statement. A draft saved
|
||||
// during compilation must not be replaced by this build's older save.
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
`UPDATE subroom SET current_save_id=?,
|
||||
staged_save_id=CASE WHEN staged_save_id=? THEN NULL ELSE staged_save_id END
|
||||
WHERE room_id=? AND sub_room_id=? AND COALESCE(staged_save_id,current_save_id)=?
|
||||
AND EXISTS (SELECT 1 FROM subroom_save WHERE sub_room_data_save_id=? AND sub_room_id=?)
|
||||
AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')`
|
||||
).bind(
|
||||
job.saveId,
|
||||
job.saveId,
|
||||
job.roomId,
|
||||
job.subRoomId,
|
||||
job.saveId,
|
||||
job.saveId,
|
||||
job.subRoomId,
|
||||
id,
|
||||
lease.lease_token
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
writes.push(
|
||||
c.env.DB.prepare(
|
||||
"UPDATE studio_cloud_build SET completed_at=?,error=NULL WHERE cloud_build_id=? AND EXISTS (SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND lease_token=? AND status='publishing')"
|
||||
@@ -491,20 +607,42 @@ const app = new Hono<App>()
|
||||
"UPDATE studio_cloud_build_job SET status='completed',lease_token=NULL WHERE cloud_build_id=? AND lease_token=? AND status='publishing'"
|
||||
).bind(id, lease.lease_token)
|
||||
)
|
||||
// Recheck the lease and target inside the publication transaction. R2
|
||||
// uploads can outlast a lease, and a room can be deleted while compiling.
|
||||
// Recheck authority, lease and target inside the final publication transaction.
|
||||
const publicationTime = new Date().toISOString()
|
||||
const roomRow =
|
||||
job.kind !== 'avatar'
|
||||
? await c.env.DB.prepare('SELECT data FROM room WHERE room_id=?')
|
||||
.bind(job.roomId)
|
||||
.first<{ data: string }>()
|
||||
: null
|
||||
const authorizedRoom =
|
||||
roomRow && canManageRoom(JSON.parse(roomRow.data), job.accountId) ? roomRow.data : null
|
||||
const targetExists =
|
||||
job.kind === 'avatar'
|
||||
? `SELECT 1 FROM avatar_studio_save s JOIN custom_avatar_item i ON i.custom_avatar_item_id=s.item_id
|
||||
JOIN avatar_studio_access a ON a.account_id=json_extract(i.data,'$.CreatorAccountId')
|
||||
WHERE s.id=? AND s.item_id=? AND s.unity_asset_id=? AND a.account_id=? AND (a.flags & ?)<>0`
|
||||
: `SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
|
||||
JOIN room ON room.room_id=r.room_id
|
||||
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=? AND room.data=?`
|
||||
const targetBindings =
|
||||
job.kind === 'avatar'
|
||||
? [
|
||||
job.saveId,
|
||||
job.avatarItemId!,
|
||||
job.unityAssetId,
|
||||
job.accountId,
|
||||
avatarItemAccessBit(job.avatar!.outfitType),
|
||||
]
|
||||
: [job.saveId, job.subRoomId, job.roomId, authorizedRoom]
|
||||
const results = await c.env.DB.batch([
|
||||
c.env.DB.prepare(
|
||||
`UPDATE studio_cloud_build_job SET status='failed',lease_token=NULL
|
||||
WHERE cloud_build_id=? AND lease_token=? AND status='publishing'
|
||||
AND (lease_until<=? OR NOT EXISTS (
|
||||
SELECT 1 FROM subroom_save s JOIN subroom r ON r.sub_room_id=s.sub_room_id
|
||||
JOIN room ON room.room_id=r.room_id
|
||||
WHERE s.sub_room_data_save_id=? AND s.sub_room_id=? AND r.room_id=?))`
|
||||
).bind(id, lease.lease_token, publicationTime, job.saveId, job.subRoomId, job.roomId),
|
||||
AND (lease_until<=? OR NOT EXISTS (${targetExists}))`
|
||||
).bind(id, lease.lease_token, publicationTime, ...targetBindings),
|
||||
c.env.DB.prepare(
|
||||
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target save removed.'
|
||||
`UPDATE studio_cloud_build SET completed_at=?,error='Build lease expired or target authorization/save removed.'
|
||||
WHERE cloud_build_id=? AND completed_at='' AND EXISTS (
|
||||
SELECT 1 FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='failed')`
|
||||
).bind(publicationTime, id, id),
|
||||
@@ -512,7 +650,7 @@ const app = new Hono<App>()
|
||||
])
|
||||
if (!results[results.length - 1].meta.changes) {
|
||||
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
||||
return c.json(fail('Build lease expired or target save removed.'), 409)
|
||||
return c.json(fail('Build lease expired or target authorization/save removed.'), 409)
|
||||
}
|
||||
} catch (error) {
|
||||
await Promise.all(keys.map((key) => c.env.CDN_ASSETS.delete(key)))
|
||||
@@ -523,6 +661,156 @@ const app = new Hono<App>()
|
||||
.run()
|
||||
throw error
|
||||
}
|
||||
if (job.kind === 'avatar') {
|
||||
const item = await avatarStudioItem(c.env.DB, job.avatarItemId!)
|
||||
const save = await avatarStudioSave(c.env.DB, job.avatarItemId!, job.saveId)
|
||||
if (
|
||||
item &&
|
||||
save &&
|
||||
(job.autoPublish || (item.Accessibility === 0 && save.update_current === 1))
|
||||
) {
|
||||
await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id, true)
|
||||
}
|
||||
return c.json(ok(null))
|
||||
}
|
||||
|
||||
return c.json(ok(null))
|
||||
})
|
||||
export default app
|
||||
|
||||
async function enqueueAvatar(
|
||||
c: Context<App>,
|
||||
itemId: string,
|
||||
saveId: number,
|
||||
supplied?: Record<string, unknown>
|
||||
) {
|
||||
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
const item = await avatarStudioItem(c.env.DB, itemId),
|
||||
save = await avatarStudioSave(c.env.DB, itemId, saveId)
|
||||
if (!item || !save) return c.notFound()
|
||||
if (item.OutfitType === 1000)
|
||||
return c.json(fail('Badge images publish directly and do not require a Unity build.'), 400)
|
||||
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (
|
||||
item.CreatorAccountId !== accountId ||
|
||||
!roles?.includes('betastudio') ||
|
||||
!((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(item.OutfitType))
|
||||
)
|
||||
return c.body(null, 403)
|
||||
if (!(await cloudBuildKey(c.env))) return c.json(fail('Cloud building is not configured.'), 503)
|
||||
const pending = await pendingAvatarBuild(c.env.DB, itemId, saveId)
|
||||
if (pending) return c.json(ok(wire(pending)))
|
||||
const object = await c.env.CDN_ASSETS.get('avatarunityasset/' + save.source_filename)
|
||||
if (!object || object.size > 8 * 1024 * 1024)
|
||||
return c.json(fail('Missing or oversized avatar source manifest.'), 400)
|
||||
let manifest: Job['manifest']
|
||||
try {
|
||||
manifest = await object.json<Job['manifest']>()
|
||||
} catch {
|
||||
return c.json(fail('Invalid source manifest JSON.'), 400)
|
||||
}
|
||||
const main = manifest?.MainPrefabProjectRelativePath
|
||||
if (
|
||||
!manifest ||
|
||||
manifest.Version !== 3 ||
|
||||
typeof main !== 'string' ||
|
||||
!main.startsWith('Assets/') ||
|
||||
!main.endsWith('.prefab') ||
|
||||
!Array.isArray(manifest.Files) ||
|
||||
!manifest.Files.length ||
|
||||
manifest.Files.length > 100000 ||
|
||||
!manifest.Files.some((f) => f?.p === main) ||
|
||||
manifest.Files.some(
|
||||
(f) =>
|
||||
!f ||
|
||||
typeof f.p !== 'string' ||
|
||||
!/^(Assets|Packages)\//.test(f.p) ||
|
||||
f.p.includes('\\') ||
|
||||
f.p.includes(':') ||
|
||||
f.p
|
||||
.split('/')
|
||||
.some(
|
||||
(p) => p === '..' || p === '.' || p === '' || p.toLowerCase().endsWith('.bundle')
|
||||
) ||
|
||||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
|
||||
!/^[a-z0-9]{40}$/.test(f.n)
|
||||
) ||
|
||||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
|
||||
)
|
||||
return c.json(fail('Invalid avatar source file manifest.'), 400)
|
||||
const form = supplied ?? (await c.req.parseBody())
|
||||
if (form.pipelineVersion !== undefined && String(form.pipelineVersion) !== '2')
|
||||
return c.json(fail('Unsupported avatar build pipeline.'), 400)
|
||||
const targets = (c.env.CLOUD_BUILD_TARGETS ?? '0,2').split(',').map(Number)
|
||||
if (!targets.length || targets.some((t) => t !== 0 && t !== 2))
|
||||
return c.json(fail('Invalid build targets.'), 503)
|
||||
const access = await avatarStudioFlags(c.env.DB, accountId)
|
||||
const permittedItemTypes = AVATAR_STUDIO_SLOTS.filter(
|
||||
(slot) =>
|
||||
(access & avatarItemAccessBit(slot)) !== 0 &&
|
||||
(![104, 203].includes(slot) || roles.includes('developer'))
|
||||
)
|
||||
const job: Job = {
|
||||
kind: 'avatar',
|
||||
avatarItemId: itemId,
|
||||
avatar: {
|
||||
itemId,
|
||||
outfitType: item.OutfitType,
|
||||
bodyType: save.body_type,
|
||||
configuration: save.configuration,
|
||||
name: String(item.Name ?? ''),
|
||||
createdAt: save.created_at,
|
||||
price: Number(item.Price ?? 0),
|
||||
creatorAccountId: accountId,
|
||||
canCreateRoomieEquipment: roles.includes('developer'),
|
||||
permittedItemTypes,
|
||||
},
|
||||
buildId: numericId(),
|
||||
roomId: 0,
|
||||
subRoomId: 0,
|
||||
saveId,
|
||||
accountId,
|
||||
unityAssetId: save.unity_asset_id,
|
||||
autoPublish: textField(form.autoPublish).toLowerCase() === 'true',
|
||||
templateVersion: Number(c.env.CLOUD_BUILD_TEMPLATE_VERSION ?? 169),
|
||||
targets,
|
||||
manifest,
|
||||
room: null,
|
||||
exports: [],
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
// As for rooms, reserve the build and its queue/link rows in one transaction.
|
||||
const created = await c.env.DB.batch([
|
||||
c.env.DB.prepare(
|
||||
`INSERT INTO studio_cloud_build(cloud_build_id,room_id,sub_room_id,sub_room_data_save_id,unity_asset_id,created_by_account_id,started_at,completed_at,error)
|
||||
SELECT ?,0,0,?,?,?,?,'',NULL WHERE NOT EXISTS (
|
||||
SELECT 1 FROM studio_avatar_build a JOIN studio_cloud_build_job j USING(cloud_build_id)
|
||||
WHERE a.item_id=? AND a.save_id=? AND j.status IN ('queued','building','publishing'))`
|
||||
).bind(job.buildId, saveId, job.unityAssetId, accountId, now, itemId, saveId),
|
||||
c.env.DB.prepare(
|
||||
'INSERT INTO studio_avatar_build(cloud_build_id,item_id,save_id) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
|
||||
).bind(job.buildId, itemId, saveId, job.buildId),
|
||||
c.env.DB.prepare(
|
||||
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,created_at) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM studio_cloud_build WHERE cloud_build_id=?)'
|
||||
).bind(job.buildId, JSON.stringify(job), now, job.buildId),
|
||||
])
|
||||
if (!created[0].meta.changes) {
|
||||
const existing = await pendingAvatarBuild(c.env.DB, itemId, saveId)
|
||||
return existing
|
||||
? c.json(ok(wire(existing)))
|
||||
: c.json(fail('Build state changed; retry creation.'), 409)
|
||||
}
|
||||
return c.json(
|
||||
ok(
|
||||
wire({
|
||||
cloud_build_id: job.buildId,
|
||||
started_at: now,
|
||||
completed_at: null,
|
||||
unity_asset_id: job.unityAssetId,
|
||||
created_by_account_id: accountId,
|
||||
error: null,
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,12 +17,23 @@ export type Env = SharedHonoEnv & {
|
||||
// Shared `recflare-cdn` bucket. Local Studio builds land under
|
||||
// `room/studio/<roomId>/`. The same bucket name the rooms and cdn workers bind.
|
||||
CDN_ASSETS: R2Bucket
|
||||
/** Private pull-worker credential; leave unset to disable cloud build dispatch. */
|
||||
CLOUD_BUILD_KEY?: string
|
||||
/**
|
||||
* Private pull-worker credential, out of the shared Secrets Store (same store as
|
||||
* JWT_SECRET). Read it through `cloudBuildKey()` in cloud-builds.ts, never `.get()`
|
||||
* directly: the value `off` (or an unreadable secret) means cloud building is disabled.
|
||||
*/
|
||||
CLOUD_BUILD_KEY: SecretsStoreSecret
|
||||
CLOUD_BUILD_TARGETS?: string
|
||||
CLOUD_BUILD_TEMPLATE_VERSION?: string
|
||||
/** R2 key of an operator-published template catalog; unset disables downloads. */
|
||||
STUDIO_DISTRIBUTION_MANIFEST?: string
|
||||
/**
|
||||
* Discord role ids whose holders have Studio upload access without the account's
|
||||
* `hasStudio` flag (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`; the same var `auth`
|
||||
* stamps `betastudio` from). The access lookups here rebuild the role from the account
|
||||
* rather than a token, so they read it through the same `hasStudioAccess`.
|
||||
*/
|
||||
STUDIO_ROLLOUT?: string
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -109,22 +109,29 @@ export async function getUserBuildCount(
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000)
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) AS failed
|
||||
`SELECT SUM(CASE WHEN room_id!=0 THEN 1 ELSE 0 END) AS total,
|
||||
SUM(CASE WHEN room_id!=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS failed,
|
||||
SUM(CASE WHEN room_id=0 THEN 1 ELSE 0 END) AS avatar_total,
|
||||
SUM(CASE WHEN room_id=0 AND error IS NOT NULL THEN 1 ELSE 0 END) AS avatar_failed
|
||||
FROM studio_cloud_build
|
||||
WHERE created_by_account_id = ?1
|
||||
AND started_at >= ?2 AND started_at < ?3`
|
||||
)
|
||||
.bind(accountId, start.toISOString(), end.toISOString())
|
||||
.first<{ total: number | string; failed: number | string | null }>()
|
||||
.first<{
|
||||
total: number | string
|
||||
failed: number | string | null
|
||||
avatar_total: number | null
|
||||
avatar_failed: number | null
|
||||
}>()
|
||||
return {
|
||||
accountId,
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
subRoomTotalBuildCount: Number(row?.total ?? 0),
|
||||
subRoomFailedBuildCount: Number(row?.failed ?? 0),
|
||||
avatarItemTotalBuildCount: 0,
|
||||
avatarItemFailedBuildCount: 0,
|
||||
avatarItemTotalBuildCount: Number(row?.avatar_total ?? 0),
|
||||
avatarItemFailedBuildCount: Number(row?.avatar_failed ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
canManageRoomById,
|
||||
getAccount,
|
||||
getPlayerIdsInRoomSubRoom,
|
||||
getRoomCoOwnerIds,
|
||||
getSubRoom,
|
||||
} from '@repo/domain'
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
import { studioBuildValidationId, validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
import { hasStudioAccess } from '../../auth/src/platform-db'
|
||||
import cloudBuilds from './cloud-builds'
|
||||
import distribution from './distribution'
|
||||
import { getUserBuildCount, listCloudBuilds, storeLocalCloudBuild } from './local-builds'
|
||||
@@ -30,6 +32,13 @@ function positiveId(raw: string | undefined): number | null {
|
||||
return Number.isSafeInteger(id) ? id : null
|
||||
}
|
||||
|
||||
/** SQLite datetime() is UTC, but its space-separated output is not a .NET JSON DateTime. */
|
||||
function accessTimestamp(value: string | undefined, fallback: string): string {
|
||||
const normalized = value?.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/, '$1T$2Z')
|
||||
const time = normalized ? Date.parse(normalized) : NaN
|
||||
return Number.isFinite(time) ? new Date(time).toISOString() : fallback
|
||||
}
|
||||
|
||||
const BUNDLE_REQUIRED = 'Both a Windows bundle and an Android bundle are required.'
|
||||
const UPLOAD_FIRST =
|
||||
'Upload the room before building locally. The latest save is where this build is recorded.'
|
||||
@@ -168,6 +177,75 @@ const app = new Hono<App>()
|
||||
.get('/', async (c) => {
|
||||
return c.text('hello, world!')
|
||||
})
|
||||
.get('/access/override/:accountId{[1-9][0-9]*}', async (c) => {
|
||||
const secret = await c.env.JWT_SECRET.get()
|
||||
const caller = await validateAndGetAccountId(c.req.raw, secret)
|
||||
const id = Number(c.req.param('accountId'))
|
||||
if (!Number.isSafeInteger(id)) return c.body(null, 400)
|
||||
let roles = await validateAndGetRoles(c.req.raw, secret)
|
||||
if (caller === null) {
|
||||
const buildId = await studioBuildValidationId(c.req.raw, secret)
|
||||
if (!buildId) return c.body(null, 401)
|
||||
const job = await c.env.DB.prepare(
|
||||
"SELECT payload FROM studio_cloud_build_job WHERE cloud_build_id=? AND status='building' AND lease_until>?"
|
||||
)
|
||||
.bind(buildId, new Date().toISOString())
|
||||
.first<{ payload: string }>()
|
||||
if (!job) return c.body(null, 403)
|
||||
const payload = JSON.parse(job.payload) as {
|
||||
roomId: number
|
||||
room?: { Roles?: Array<{ AccountId: number; Role: number }> }
|
||||
}
|
||||
if (
|
||||
!payload.roomId ||
|
||||
!payload.room?.Roles?.some((r) => r.AccountId === id && [30, 255].includes(r.Role))
|
||||
)
|
||||
return c.body(null, 403)
|
||||
if (!(await getRoomCoOwnerIds(c.env.DB, payload.roomId))?.includes(id))
|
||||
return c.body(null, 403)
|
||||
const account = await getAccount(c.env.DB, id)
|
||||
roles = (await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT)) ? ['betastudio'] : []
|
||||
}
|
||||
if (
|
||||
caller !== null &&
|
||||
caller !== id &&
|
||||
!roles?.some((r) => r === 'developer' || r === 'moderator')
|
||||
) {
|
||||
// The SDK validates a registry against every owner of its room. Allow
|
||||
// peers to inspect grants only when both currently manage the same room.
|
||||
const shared = await c.env.DB.prepare(
|
||||
`SELECT 1 FROM room r WHERE
|
||||
(r.creator_account_id=?1 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?1 AND json_extract(role.value,'$.Role') IN (30,255))) AND
|
||||
(r.creator_account_id=?2 OR EXISTS (SELECT 1 FROM json_each(r.data,'$.Roles') role WHERE json_extract(role.value,'$.AccountId')=?2 AND json_extract(role.value,'$.Role') IN (30,255))) LIMIT 1`
|
||||
)
|
||||
.bind(caller, id)
|
||||
.first()
|
||||
if (!shared) return c.body(null, 403)
|
||||
}
|
||||
// Inspection authorization above belongs to the caller; this grant belongs to the target.
|
||||
if (caller !== null && caller !== id)
|
||||
roles = (await hasStudioAccess(
|
||||
c.env.DB,
|
||||
await getAccount(c.env.DB, id),
|
||||
c.env.STUDIO_ROLLOUT
|
||||
))
|
||||
? ['betastudio']
|
||||
: []
|
||||
const grant = await c.env.DB.prepare(
|
||||
'SELECT flags,created_at,modified_at FROM avatar_studio_access WHERE account_id=?'
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ flags: number; created_at: string; modified_at: string }>()
|
||||
const flags = roles?.includes('betastudio') ? (grant?.flags ?? 0) : 0
|
||||
const now = new Date().toISOString()
|
||||
return c.json({
|
||||
AccountId: id,
|
||||
StudioAccess: roles?.includes('betastudio') ? 1 : 2,
|
||||
AvatarStudioAccess: flags,
|
||||
CreatedAt: accessTimestamp(grant?.created_at, now),
|
||||
ModifiedAt: accessTimestamp(grant?.modified_at, now),
|
||||
})
|
||||
})
|
||||
// There is no circuit recommendation corpus on a fresh deployment. An empty
|
||||
// list is the SDK's supported no-recommendations state, not a failed lookup.
|
||||
.get('/commonQuery/rrstudio/chiprecommendations', (c) => c.json([]))
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AVATAR_STUDIO_SCHEMA,
|
||||
avatarSaveDto,
|
||||
avatarStudioSave,
|
||||
publishAvatarSave,
|
||||
ROOM_SCHEMA_DDL,
|
||||
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
|
||||
UNITY_ASSET_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
import { generateToken } from '@repo/jwt'
|
||||
|
||||
import { SCHEMA_DDL, toQuestCustomAvatarItem } from '../../../../api/src/custom-avatar-items-db'
|
||||
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
const origin = 'https://example.com',
|
||||
itemId = '11111111-1111-1111-1111-111111111111',
|
||||
assetId = '22222222-2222-2222-2222-222222222222'
|
||||
const worker = { Authorization: 'Bearer private-avatar-worker-key' }
|
||||
async function auth(id = 42, roles = ['betastudio']) {
|
||||
return {
|
||||
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
|
||||
}
|
||||
}
|
||||
const manifest = {
|
||||
Version: 3,
|
||||
MainPrefabProjectRelativePath: 'Assets/Hat.prefab',
|
||||
Files: [{ n: 'a'.repeat(40), p: 'Assets/Hat.prefab' }],
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||
await adminSecretsStore(env.CLOUD_BUILD_KEY).create('private-avatar-worker-key')
|
||||
env.CLOUD_BUILD_TARGETS = '0,2'
|
||||
for (const sql of [
|
||||
...SCHEMA_DDL,
|
||||
...ROOM_SCHEMA_DDL,
|
||||
...AVATAR_STUDIO_SCHEMA,
|
||||
...STUDIO_CLOUD_BUILD_SCHEMA_DDL,
|
||||
...UNITY_ASSET_SCHEMA_DDL,
|
||||
CLOUD_JOB_SCHEMA,
|
||||
])
|
||||
await env.DB.prepare(sql).run()
|
||||
})
|
||||
beforeEach(async () => {
|
||||
for (const table of [
|
||||
'studio_cloud_build_job',
|
||||
'studio_cloud_build',
|
||||
'studio_avatar_build',
|
||||
'unity_asset',
|
||||
'avatar_studio_save',
|
||||
'avatar_studio_access',
|
||||
'custom_avatar_item',
|
||||
])
|
||||
await env.DB.prepare('DELETE FROM ' + table).run()
|
||||
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
|
||||
await env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
CustomAvatarItemId: itemId,
|
||||
CreatorAccountId: 42,
|
||||
OutfitType: 0,
|
||||
Accessibility: 0,
|
||||
Name: 'Hat',
|
||||
CurrentSaves: [],
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,update_current,created_at) VALUES (1,?,1,?,'source','hash','thumb','{}',1,'2026-10-18')"
|
||||
)
|
||||
.bind(itemId, assetId)
|
||||
.run()
|
||||
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
|
||||
})
|
||||
async function create(id = 42) {
|
||||
return SELF.fetch(origin + '/cloud-builds/custom-avatar-item/' + itemId + '/save/1', {
|
||||
method: 'POST',
|
||||
headers: await auth(id),
|
||||
body: new URLSearchParams({ pipelineVersion: '2', autoPublish: 'true' }),
|
||||
})
|
||||
}
|
||||
async function claim() {
|
||||
const r = await SELF.fetch(origin + '/internal/cloud-builds/claim', {
|
||||
method: 'POST',
|
||||
headers: worker,
|
||||
})
|
||||
return r.json<{ job: { buildId: string; kind: string; manifest: unknown }; leaseToken: string }>()
|
||||
}
|
||||
function artifacts(lease: { leaseToken: string }, materialVersion = 6) {
|
||||
const form = new FormData()
|
||||
form.set('leaseToken', lease.leaseToken)
|
||||
const entries = []
|
||||
for (const target of [0, 2])
|
||||
for (const kind of ['main', 'material', 'stripped']) {
|
||||
const field = `bundle${target}${kind}`
|
||||
form.set(
|
||||
field,
|
||||
new File(['UnityFS\0avatar fixture ' + target + kind], field + '.assetbundle')
|
||||
)
|
||||
entries.push({
|
||||
field,
|
||||
unityAssetId: assetId,
|
||||
target,
|
||||
kind,
|
||||
version: kind === 'material' ? materialVersion : 5,
|
||||
})
|
||||
}
|
||||
form.set('artifacts', JSON.stringify(entries))
|
||||
return form
|
||||
}
|
||||
it('authorizes per-account access and rejects builds from other owners', async () => {
|
||||
expect((await SELF.fetch(origin + '/access/override/42')).status).toBe(401)
|
||||
expect(
|
||||
(await SELF.fetch(origin + '/access/override/42', { headers: await auth(43) })).status
|
||||
).toBe(403)
|
||||
expect(
|
||||
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
|
||||
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 1 })
|
||||
expect(
|
||||
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth(42, []) })).json()
|
||||
).toMatchObject({ AvatarStudioAccess: 0 })
|
||||
expect((await create(43)).status).toBe(403)
|
||||
})
|
||||
it('serializes SQLite and ISO access timestamps as SDK-compatible UTC DateTimes', async () => {
|
||||
await env.DB.prepare(
|
||||
'UPDATE avatar_studio_access SET flags=15,created_at=?,modified_at=? WHERE account_id=42'
|
||||
)
|
||||
.bind('2026-10-19T00:45:00.372Z', '2026-10-19 16:44:56')
|
||||
.run()
|
||||
expect(
|
||||
await (await SELF.fetch(origin + '/access/override/42', { headers: await auth() })).json()
|
||||
).toMatchObject({
|
||||
AvatarStudioAccess: 15,
|
||||
CreatedAt: '2026-10-19T00:45:00.372Z',
|
||||
ModifiedAt: '2026-10-19T16:44:56.000Z',
|
||||
})
|
||||
})
|
||||
it('builds and publishes prefab revisions with real SDK main/material version distinctions', async () => {
|
||||
expect((await create()).status).toBe(200)
|
||||
const lease = await claim()
|
||||
expect(lease.job.kind).toBe('avatar')
|
||||
expect(lease.job.manifest).toMatchObject(manifest)
|
||||
const finish = () => origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish'
|
||||
expect(
|
||||
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease, 5) }))
|
||||
.status
|
||||
).toBe(400)
|
||||
expect(
|
||||
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
|
||||
).toBe(200)
|
||||
const row = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
|
||||
const item = JSON.parse(row!.data)
|
||||
expect(item.CurrentSaves).toHaveLength(1)
|
||||
expect(item.CurrentSaves[0]).toMatchObject({
|
||||
CustomAvatarItemSaveId: 1,
|
||||
BodyType: 1,
|
||||
OutfitType: 0,
|
||||
})
|
||||
expect(item.CurrentSaves[0].UnityAsset).toMatch(/^studio-avatar\//)
|
||||
expect(item.CurrentSaves[0].UnityAsset2).toContain('.material.assetbundle')
|
||||
expect(item.CurrentSaves[0]).not.toHaveProperty('SourceUnityAssetFilename')
|
||||
const saved = await avatarStudioSave(env.DB, itemId, 1),
|
||||
dto = await avatarSaveDto(env.DB, saved!)
|
||||
expect(dto.BakedAssets).toHaveLength(4)
|
||||
expect(dto.BakedAssets.map((a) => a.Version).sort()).toEqual([5, 5, 6, 6])
|
||||
const quest = toQuestCustomAvatarItem(item).CurrentSaves[0]
|
||||
expect(quest.UnityAssetHash).toBe(
|
||||
dto.BakedAssets.find((a) => a.Target === 2 && a.Version === 5)?.Hash
|
||||
)
|
||||
expect(quest.UnityAssetHash).not.toBe(item.CurrentSaves[0].UnityAssetHash)
|
||||
expect((await env.CDN_ASSETS.get('avatar/' + dto.UnityAsset))?.size).toBeGreaterThan(8)
|
||||
expect(
|
||||
(await SELF.fetch(finish(), { method: 'POST', headers: worker, body: artifacts(lease) })).status
|
||||
).toBe(409)
|
||||
const count = await SELF.fetch(origin + '/cloud-builds/get-build-count-for-user', {
|
||||
headers: await auth(),
|
||||
})
|
||||
expect(await count.json()).toMatchObject({
|
||||
value: { subRoomTotalBuildCount: 0, avatarItemTotalBuildCount: 1 },
|
||||
})
|
||||
})
|
||||
it('rejects source code paths and cannot cancel another account’s build', async () => {
|
||||
await env.CDN_ASSETS.put(
|
||||
'avatarunityasset/source',
|
||||
JSON.stringify({ ...manifest, Files: [null] })
|
||||
)
|
||||
expect((await create()).status).toBe(400)
|
||||
await env.CDN_ASSETS.put(
|
||||
'avatarunityasset/source',
|
||||
JSON.stringify({
|
||||
...manifest,
|
||||
Files: [...manifest.Files, { n: 'a'.repeat(40), p: 'Assets/Code.cs' }],
|
||||
})
|
||||
)
|
||||
expect((await create()).status).toBe(400)
|
||||
await env.CDN_ASSETS.put('avatarunityasset/source', JSON.stringify(manifest))
|
||||
await create()
|
||||
const lease = await claim()
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||
method: 'PUT',
|
||||
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||
method: 'PUT',
|
||||
headers: await auth(43),
|
||||
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||
})
|
||||
).status
|
||||
).toBe(404)
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/cloud-builds/cancel', {
|
||||
method: 'PUT',
|
||||
headers: await auth(),
|
||||
body: new URLSearchParams({ cloudBuildId: lease.job.buildId }),
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
|
||||
method: 'POST',
|
||||
headers: worker,
|
||||
body: artifacts(lease),
|
||||
})
|
||||
).status
|
||||
).toBe(409)
|
||||
})
|
||||
it('does not publish a deleted item or an older revision after a newer save', async () => {
|
||||
await create()
|
||||
const lease = await claim()
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1'
|
||||
).run()
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/internal/cloud-builds/' + lease.job.buildId + '/finish', {
|
||||
method: 'POST',
|
||||
headers: worker,
|
||||
body: artifacts(lease),
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
const item = await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>()
|
||||
expect(JSON.parse(item!.data).CurrentSaves).toEqual([])
|
||||
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
|
||||
await env.DB.prepare('DELETE FROM studio_cloud_build').run()
|
||||
await env.DB.prepare('DELETE FROM studio_avatar_build').run()
|
||||
await create()
|
||||
const deleted = await claim()
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(origin + '/internal/cloud-builds/' + deleted.job.buildId + '/finish', {
|
||||
method: 'POST',
|
||||
headers: worker,
|
||||
body: artifacts(deleted),
|
||||
})
|
||||
).status
|
||||
).toBe(409)
|
||||
})
|
||||
|
||||
it('deduplicates concurrent avatar requests and allows a new build after failure', async () => {
|
||||
const responses = await Promise.all([create(), create(), create()])
|
||||
const ids = await Promise.all(
|
||||
responses.map(async (response) => {
|
||||
expect(response.status).toBe(200)
|
||||
return (await response.json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
|
||||
})
|
||||
)
|
||||
expect(new Set(ids).size).toBe(1)
|
||||
for (const table of ['studio_cloud_build', 'studio_avatar_build', 'studio_cloud_build_job'])
|
||||
expect(await env.DB.prepare('SELECT COUNT(*) AS n FROM ' + table).first()).toEqual({ n: 1 })
|
||||
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='failed'").run()
|
||||
expect(
|
||||
(await (await create()).json<{ value: { cloudBuildId: string } }>()).value.cloudBuildId
|
||||
).not.toBe(ids[0])
|
||||
})
|
||||
it.each([false, true])(
|
||||
'checks automatic avatar revision ordering in the write, including CAS retry=%s, preserving explicit restore',
|
||||
async (retry) => {
|
||||
await env.DB.prepare("INSERT INTO unity_asset VALUES (?,0,5,'main','old.bundle','hash')")
|
||||
.bind(assetId)
|
||||
.run()
|
||||
let intercepted = false
|
||||
const DB = new Proxy(env.DB, {
|
||||
get(target, key) {
|
||||
if (key === 'prepare')
|
||||
return (sql: string) => {
|
||||
const wrap = (statement: D1PreparedStatement): D1PreparedStatement =>
|
||||
new Proxy(statement, {
|
||||
get(stmt, method) {
|
||||
if (method === 'bind') return (...values: unknown[]) => wrap(stmt.bind(...values))
|
||||
if (method === 'run' && sql.startsWith('UPDATE custom_avatar_item'))
|
||||
return async () => {
|
||||
if (!intercepted) {
|
||||
intercepted = true
|
||||
await target
|
||||
.prepare(
|
||||
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) SELECT 2,item_id,body_type,'new-identity',source_filename,source_hash,thumbnail,configuration,created_at FROM avatar_studio_save WHERE id=1"
|
||||
)
|
||||
.run()
|
||||
if (retry)
|
||||
await target
|
||||
.prepare(
|
||||
"UPDATE custom_avatar_item SET data=json_set(data,'$.Name','Concurrent metadata')"
|
||||
)
|
||||
.run()
|
||||
}
|
||||
return stmt.run()
|
||||
}
|
||||
const value = Reflect.get(stmt, method)
|
||||
return typeof value === 'function' ? value.bind(stmt) : value
|
||||
},
|
||||
})
|
||||
return wrap(target.prepare(sql))
|
||||
}
|
||||
const value = Reflect.get(target, key)
|
||||
return typeof value === 'function' ? value.bind(target) : value
|
||||
},
|
||||
})
|
||||
expect(await publishAvatarSave(DB, itemId, 1, true)).toBe(false)
|
||||
expect(intercepted).toBe(true)
|
||||
let item = JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
|
||||
)
|
||||
expect(item.CurrentSaves).toEqual([])
|
||||
expect(await publishAvatarSave(env.DB, itemId, 1)).toBe(true)
|
||||
item = JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM custom_avatar_item').first<{ data: string }>())!.data
|
||||
)
|
||||
expect(item.CurrentSaves[0].CustomAvatarItemSaveId).toBe(1)
|
||||
if (retry) expect(item.Name).toBe('Concurrent metadata')
|
||||
}
|
||||
)
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UNITY_ASSET_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||
import cloudBuilds, { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
@@ -49,7 +49,7 @@ async function auth(id = 42, studio = true) {
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||
env.CLOUD_BUILD_KEY = key
|
||||
await adminSecretsStore(env.CLOUD_BUILD_KEY).create(key)
|
||||
env.CLOUD_BUILD_TARGETS = '0'
|
||||
for (const sql of [
|
||||
...ROOM_SCHEMA_DDL,
|
||||
@@ -468,3 +468,138 @@ it('does not complete a build whose submitted save was deleted during compilatio
|
||||
.run()
|
||||
}
|
||||
})
|
||||
|
||||
function builtArtifacts(lease: Awaited<ReturnType<typeof claim>>) {
|
||||
const form = new FormData()
|
||||
form.set('leaseToken', lease.leaseToken)
|
||||
form.set('bundle', new File(['UnityFS\0regression'], 'bundle.assetbundle'))
|
||||
form.set(
|
||||
'artifacts',
|
||||
JSON.stringify([
|
||||
{
|
||||
field: 'bundle',
|
||||
unityAssetId: lease.job.unityAssetId,
|
||||
target: 0,
|
||||
kind: 'main',
|
||||
version: 5,
|
||||
},
|
||||
])
|
||||
)
|
||||
return form
|
||||
}
|
||||
function finishBuild(
|
||||
lease: Awaited<ReturnType<typeof claim>>,
|
||||
form = builtArtifacts(lease),
|
||||
bindings = env
|
||||
) {
|
||||
return cloudBuilds.request(
|
||||
origin + `/internal/cloud-builds/${lease.job.buildId}/finish`,
|
||||
{ method: 'POST', headers: worker, body: form },
|
||||
bindings
|
||||
)
|
||||
}
|
||||
it.each(['before finish', 'before transaction'])(
|
||||
'rejects removed room co-owner authority %s without linking or publishing',
|
||||
async (timing) => {
|
||||
const prior = await env.DB.prepare('SELECT data FROM room WHERE room_id=3000').first<{
|
||||
data: string
|
||||
}>()
|
||||
const originalSave = await env.DB.prepare(
|
||||
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||
).first<{ data: string }>()
|
||||
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
|
||||
.bind(JSON.stringify([{ AccountId: 43, Role: 30 }]))
|
||||
.run()
|
||||
await create(43, true, { autoPublish: 'true' })
|
||||
const lease = await claim()
|
||||
const revoke = () =>
|
||||
env.DB.prepare(
|
||||
"UPDATE room SET data=json_set(data,'$.Roles',json('[]')) WHERE room_id=3000"
|
||||
).run()
|
||||
if (timing === 'before finish') await revoke()
|
||||
const DB = new Proxy(env.DB, {
|
||||
get(target, key) {
|
||||
if (key === 'batch')
|
||||
return async (statements: D1PreparedStatement[]) => {
|
||||
await revoke()
|
||||
return target.batch(statements)
|
||||
}
|
||||
const value = Reflect.get(target, key)
|
||||
return typeof value === 'function' ? value.bind(target) : value
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
|
||||
expect(
|
||||
await env.DB.prepare(
|
||||
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||
).first()
|
||||
).toEqual(originalSave)
|
||||
expect(
|
||||
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
|
||||
.bind(lease.job.unityAssetId)
|
||||
.first()
|
||||
).toBeNull()
|
||||
expect(
|
||||
await env.DB.prepare('SELECT status FROM studio_cloud_build_job WHERE cloud_build_id=?')
|
||||
.bind(lease.job.buildId)
|
||||
.first()
|
||||
).toEqual({ status: 'failed' })
|
||||
expect(
|
||||
await env.CDN_ASSETS.get(
|
||||
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
(await env.CDN_ASSETS.list({ prefix: `room/studio/3000/${lease.job.unityAssetId}` }))
|
||||
.objects
|
||||
).toEqual([])
|
||||
} finally {
|
||||
await env.DB.prepare('UPDATE room SET data=? WHERE room_id=3000').bind(prior!.data).run()
|
||||
}
|
||||
}
|
||||
)
|
||||
it.each(['expired lease', 'deleted save'])(
|
||||
'cleans uploaded artifacts when final publication finds %s',
|
||||
async (condition) => {
|
||||
await create()
|
||||
const lease = await claim()
|
||||
const save = await env.DB.prepare(
|
||||
'SELECT data FROM subroom_save WHERE sub_room_data_save_id=3002'
|
||||
).first<{ data: string }>()
|
||||
const DB = new Proxy(env.DB, {
|
||||
get(target, key) {
|
||||
if (key === 'batch')
|
||||
return async (statements: D1PreparedStatement[]) => {
|
||||
if (condition === 'expired lease')
|
||||
await target
|
||||
.prepare("UPDATE studio_cloud_build_job SET lease_until='2000-01-01'")
|
||||
.run()
|
||||
else
|
||||
await target
|
||||
.prepare('DELETE FROM subroom_save WHERE sub_room_data_save_id=3002')
|
||||
.run()
|
||||
return target.batch(statements)
|
||||
}
|
||||
const value = Reflect.get(target, key)
|
||||
return typeof value === 'function' ? value.bind(target) : value
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect((await finishBuild(lease, builtArtifacts(lease), { ...env, DB })).status).toBe(409)
|
||||
expect(
|
||||
await env.DB.prepare('SELECT unity_asset_id FROM unity_asset WHERE unity_asset_id=?')
|
||||
.bind(lease.job.unityAssetId)
|
||||
.first()
|
||||
).toBeNull()
|
||||
expect(
|
||||
await env.CDN_ASSETS.get(
|
||||
`roomunityasset/studio-builds/${lease.job.buildId}/server-data.json`
|
||||
)
|
||||
).toBeNull()
|
||||
} finally {
|
||||
if (condition === 'deleted save')
|
||||
await env.DB.prepare('INSERT INTO subroom_save VALUES (3002,3001,?)').bind(save!.data).run()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||
|
||||
import { SCHEMA_DDL as ACCOUNT_SCHEMA, AVATAR_STUDIO_SCHEMA, ROOM_SCHEMA_DDL } from '@repo/domain'
|
||||
import { generateStudioBuildValidationToken, generateToken } from '@repo/jwt'
|
||||
|
||||
import { CLOUD_JOB_SCHEMA } from '../../cloud-builds'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
const base = 'https://example.com/access/override/'
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||
for (const sql of [
|
||||
...ACCOUNT_SCHEMA,
|
||||
...ROOM_SCHEMA_DDL,
|
||||
...AVATAR_STUDIO_SCHEMA,
|
||||
CLOUD_JOB_SCHEMA,
|
||||
])
|
||||
await env.DB.prepare(sql).run()
|
||||
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
|
||||
.bind(JSON.stringify({ accountId: 42, username: 'Fixture', hasStudio: true }))
|
||||
.run()
|
||||
await env.DB.prepare('INSERT INTO room(data) VALUES (?)')
|
||||
.bind(
|
||||
JSON.stringify({ RoomId: 3000, CreatorAccountId: 42, Roles: [{ AccountId: 42, Role: 255 }] })
|
||||
)
|
||||
.run()
|
||||
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,8,'now','now')").run()
|
||||
})
|
||||
beforeEach(async () => {
|
||||
await env.DB.prepare('DELETE FROM studio_cloud_build_job').run()
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO studio_cloud_build_job(cloud_build_id,payload,status,lease_until,created_at) VALUES (?,?,?,?,?)'
|
||||
)
|
||||
.bind(
|
||||
'123',
|
||||
JSON.stringify({ roomId: 3000, room: { Roles: [{ AccountId: 42, Role: 255 }] } }),
|
||||
'building',
|
||||
new Date(Date.now() + 60000).toISOString(),
|
||||
'now'
|
||||
)
|
||||
.run()
|
||||
})
|
||||
async function request(id: number, build = '123') {
|
||||
return SELF.fetch(base + id, {
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + (await generateStudioBuildValidationToken(build, 'test-key')),
|
||||
},
|
||||
})
|
||||
}
|
||||
it('allows read-only consumable validation only for an active job’s room owners', async () => {
|
||||
expect(await (await request(42)).json()).toMatchObject({
|
||||
AccountId: 42,
|
||||
StudioAccess: 1,
|
||||
AvatarStudioAccess: 8,
|
||||
})
|
||||
expect((await request(43)).status).toBe(403)
|
||||
expect((await request(42, '124')).status).toBe(403)
|
||||
expect((await SELF.fetch(base + '42')).status).toBe(401)
|
||||
})
|
||||
it('rejects cancelled and expired jobs', async () => {
|
||||
await env.DB.prepare("UPDATE studio_cloud_build_job SET status='cancelled'").run()
|
||||
expect((await request(42)).status).toBe(403)
|
||||
await env.DB.prepare(
|
||||
"UPDATE studio_cloud_build_job SET status='building',lease_until='2000-01-01'"
|
||||
).run()
|
||||
expect((await request(42)).status).toBe(403)
|
||||
})
|
||||
it('lets co-owners validate each other’s grants without exposing unrelated accounts', async () => {
|
||||
await env.DB.prepare("UPDATE room SET data=json_set(data,'$.Roles',json(?)) WHERE room_id=3000")
|
||||
.bind(
|
||||
JSON.stringify([
|
||||
{ AccountId: 42, Role: 255 },
|
||||
{ AccountId: 43, Role: 30 },
|
||||
])
|
||||
)
|
||||
.run()
|
||||
const headers = {
|
||||
Authorization:
|
||||
'Bearer ' + (await generateToken('43', 'fixture', 0, 'test-key', ['betastudio'])),
|
||||
}
|
||||
expect(await (await SELF.fetch(base + '42', { headers })).json()).toMatchObject({
|
||||
AvatarStudioAccess: 8,
|
||||
})
|
||||
expect((await SELF.fetch(base + '44', { headers })).status).toBe(403)
|
||||
})
|
||||
|
||||
it('uses target entitlement for staff inspections in both directions', async () => {
|
||||
await env.DB.prepare('INSERT INTO account(data) VALUES (?)')
|
||||
.bind(JSON.stringify({ accountId: 44, username: 'Disabled', hasStudio: false }))
|
||||
.run()
|
||||
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (44,15,'now','now')").run()
|
||||
const staff = async (roles: string[]) => ({
|
||||
Authorization: 'Bearer ' + (await generateToken('99', 'staff', 0, 'test-key', roles)),
|
||||
})
|
||||
expect(
|
||||
await (
|
||||
await SELF.fetch(base + '44', { headers: await staff(['developer', 'betastudio']) })
|
||||
).json()
|
||||
).toMatchObject({ AccountId: 44, StudioAccess: 2, AvatarStudioAccess: 0 })
|
||||
expect(
|
||||
await (await SELF.fetch(base + '42', { headers: await staff(['moderator']) })).json()
|
||||
).toMatchObject({ AccountId: 42, StudioAccess: 1, AvatarStudioAccess: 8 })
|
||||
})
|
||||
@@ -37,6 +37,20 @@
|
||||
"binding": "JWT_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "JWT_SECRET"
|
||||
},
|
||||
// The credential the cloud-building pull worker (recflare/cloud-building, its
|
||||
// STUDIO_WORKER_KEY) presents as a bearer token on /internal/cloud-builds/*. A real
|
||||
// secret, so it lives in the same store rather than riding along as a `--var` from
|
||||
// .env, which would put it in plaintext on every worker. The binding must resolve
|
||||
// for this worker to deploy: create it with the value `off` to run without cloud
|
||||
// builds (creation then answers 503 and the private routes refuse everyone).
|
||||
//
|
||||
// wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
|
||||
// --scopes workers --remote
|
||||
{
|
||||
"binding": "CLOUD_BUILD_KEY",
|
||||
"store_id": "local",
|
||||
"secret_name": "CLOUD_BUILD_KEY"
|
||||
}
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
|
||||
@@ -154,6 +154,23 @@ box. The grant runs after Plus is granted and the link written, and a failure in
|
||||
logged rather than failing the claim. Unset, the var awards nothing and the claim is as
|
||||
before.
|
||||
|
||||
**Unlinking.** The same panel offers **Unlink Discord** whenever any Discord is on the
|
||||
account — a claim's link or the bare one "Sign in with Discord" leaves — and
|
||||
`POST /api/benefits/unlink` removes every `PlatformType.Discord` link the account holds.
|
||||
It is refused (403) until the account has an **email address**: an account made from
|
||||
Discord has none until the player sets one, the link may be its only way in, and an address
|
||||
is what the account can be recovered through (a password is not required — a passwordless
|
||||
account sets one in game). The row decides, not the page; `/api/benefits/status` serves the
|
||||
same flag (`hasEmail`, beside `discordLinked`) so the panel can say so first. Afterwards that
|
||||
Discord opens no account — signing in with it makes a new one — and the once-only guard lets
|
||||
it claim elsewhere. **The grants go with the link**: `hasPlus` and `hasStudio` are both
|
||||
cleared, whoever set them (an operator's grant too — nothing on the row says which wrote
|
||||
it), and the panel warns before the confirm; both reach the game on the next token. What the
|
||||
account does keep is that it claimed: removing a link with roles recorded stamps
|
||||
`hasClaimedDiscord` on the account, and the claim's "first link" check consults it, or
|
||||
link → unlink → link would pay the one-time supporter gift on every cycle. The route is not gated on the Discord
|
||||
configuration — the link is in the table whether or not the claim is still open.
|
||||
|
||||
The link also records the member's **roles** (`platform_account.role`, a JSON array of
|
||||
role ids). The claim writes them from the member record it read; on their own they'd be
|
||||
a snapshot from claim time, because the claim revokes the player's token the moment it's
|
||||
@@ -220,11 +237,40 @@ won't pick up newly created secrets until it restarts.
|
||||
which one is missing. `wrangler tail www` shows it.
|
||||
|
||||
In the [Discord developer portal](https://discord.com/developers/applications),
|
||||
add `https://<your domain>/claim` to the app's **Redirects**. It has to match byte
|
||||
for byte: `www` derives the redirect URI from the incoming request's own origin
|
||||
(never from the request body, which would turn the client secret into a redemption
|
||||
oracle for someone else's app), so add `http://localhost:5173/claim` too if you
|
||||
want the flow to work under `pnpm turbo dev`.
|
||||
add `https://<your domain>/claim` **and** `https://<your domain>/login/discord` to the
|
||||
app's **Redirects**. Each has to match byte for byte: `www` derives the redirect URI
|
||||
from the incoming request's own origin (never from the request body, which would turn
|
||||
the client secret into a redemption oracle for someone else's app), so add the
|
||||
`http://localhost:5173/…` pair too if you want the flows to work under `pnpm turbo dev`.
|
||||
|
||||
### Sign in with Discord
|
||||
|
||||
The same app also serves the login page's **Sign in with Discord** button, on the same
|
||||
switch (`/api/config` serves `discordLoginUrl` beside `discordAuthorizeUrl`, both null
|
||||
while the claim is closed). It asks Discord for the `identify` scope only and returns to
|
||||
`/login/discord`, where the SPA posts the code to `POST /api/login/discord`. `www`
|
||||
exchanges it, reads `GET /users/@me` for the Discord user id, revokes the token, and
|
||||
mints an **identity assertion** — a one-minute HS256 token signed with the shared
|
||||
`JWT_SECRET` naming platform `101` and that id — which it posts to `auth`'s
|
||||
`identity_assertion` grant over the service binding. `auth` resolves the id through
|
||||
`platform_account`:
|
||||
|
||||
- **One linked account** (a player who claimed benefits on it) — signed in; the ordinary
|
||||
token body comes back and the SPA stores it like a password sign-in's.
|
||||
- **No linked account** — `auth` **creates** one and links the Discord to it, under the
|
||||
ban-evasion check and the per-IP / per-identity signup caps (but not `PASSWORD_SIGNUP`:
|
||||
that switch exists because a password account has no identity but its address). The
|
||||
body carries `created: true`, and the SPA stops to say so: the account has **no
|
||||
password**, and setting one on the account page is what lets it sign in to the game —
|
||||
on a sideloaded Quest without a Meta account, which is the case this is for. Its first
|
||||
benefits claim still pays the welcome gift: the claim keys "first" on roles being
|
||||
recorded on a Discord link, not on the link existing.
|
||||
- **Several linked accounts** — refused; the claim never produces this.
|
||||
|
||||
`www` never chooses the account id: the assertion names an identity, and which account
|
||||
that is (or that there is none) is `auth`'s decision from the same table `cached_login`
|
||||
reads. The Discord snowflake stays off the new account's blob and token, as it does
|
||||
after a claim.
|
||||
|
||||
### Discord role refresh (cron)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RecFlare — play like it's 2025</title>
|
||||
<title>RecFlare</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="RecFlare is a free, open source fan rebuild of the RecNet servers. Download for PC, join the Discord, and play."
|
||||
|
||||
@@ -40,6 +40,14 @@ const AUTH_MESSAGES: Record<string, string> = {
|
||||
'That platform cannot be verified — only Steam and Meta are supported.',
|
||||
'no linked account for this platform identity':
|
||||
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
||||
// The website's Discord sign-in found the Discord user on several accounts — a state the
|
||||
// benefits claim never produces, so this is for an operator to untangle, not the player.
|
||||
'platform identity is linked to more than one account':
|
||||
'That Discord account is linked to more than one RecFlare account. Sign in with your username and password instead.',
|
||||
// www minted the assertion a moment ago, so this is a key mismatch between www and auth
|
||||
// (or a clock a minute out), never something the player did.
|
||||
'invalid or expired identity assertion':
|
||||
'Sign-in is unavailable right now. This is a problem on our end — please try again later.',
|
||||
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
||||
// An account that shares a device or network with a BANNED one. Phrased for BOTH the
|
||||
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
|
||||
|
||||
@@ -16,6 +16,7 @@ import { downgradeRoom, needsDowngrade } from '../room-converter'
|
||||
// The session token, the worker hostnames and the `call` every request goes through —
|
||||
// see api.ts for why they're a module of their own rather than defined here.
|
||||
import {
|
||||
accountByUsername,
|
||||
accountIdForUsername,
|
||||
call,
|
||||
hasToken,
|
||||
@@ -30,11 +31,12 @@ import {
|
||||
import { DeviceLinkPage } from './DeviceLink'
|
||||
import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item'
|
||||
import { ModerationPage, PlayerBanStatus } from './Moderation'
|
||||
import { PumpleBossFight } from './PumpleBossFight'
|
||||
import { StatsPage } from './Stats'
|
||||
import { StudioAccessPage } from './StudioAccess'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Hosts } from './api'
|
||||
import type { Hosts, PublicAccount } from './api'
|
||||
|
||||
/**
|
||||
* Site config from `www`. `signupEnabled` is false when the operator has no Turnstile
|
||||
@@ -56,6 +58,12 @@ interface SiteConfig {
|
||||
* benefits are off. The `state` nonce is appended here — see `startDiscordAuth`.
|
||||
*/
|
||||
discordAuthorizeUrl: string | null
|
||||
/**
|
||||
* The consent URL for "Sign in with Discord" — same app, `identify` scope only, and its own
|
||||
* return leg (`/login/discord`). On the same switch as the claim, so null whenever
|
||||
* `benefitsEnabled` is false. See `DiscordLoginPage`.
|
||||
*/
|
||||
discordLoginUrl: string | null
|
||||
}
|
||||
|
||||
/** The private self DTO from `accounts` (`GET /account/me`). */
|
||||
@@ -66,6 +74,8 @@ interface SelfAccount {
|
||||
email: string | null
|
||||
/** A key on the `img` worker — `DefaultProfileImage.jpg` until the player sets one. */
|
||||
profileImage: string
|
||||
/** The profile banner's key on `img` — `""` until the player sets one. */
|
||||
bannerImage: string
|
||||
/**
|
||||
* Username changes left on the account — each change spends one, and an account
|
||||
* starts with one. Absent on an older self DTO, which reads as "unknown": the form
|
||||
@@ -356,6 +366,41 @@ async function setProfileImage(imageName: string): Promise<void> {
|
||||
if (res.success !== true) throw new Error('The accounts worker refused the image.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a banner picture through the same `api` route as the avatar, but typed
|
||||
* `SavedImageType.None` (0): the game has no banner type (its banner is one of the
|
||||
* player's camera photos), and anything but ShareCamera stays out of the photo feed and
|
||||
* the player's photo list, while ProfileThumbnail would also overwrite the avatar. Filed
|
||||
* under `none/` in the bucket. Returns the key `img` serves it by.
|
||||
*/
|
||||
async function uploadBannerImage(file: File): Promise<string> {
|
||||
const form = new FormData()
|
||||
form.set('imgMeta', JSON.stringify({ savedImageType: 0 }))
|
||||
form.set('image', file)
|
||||
const { ImageName } = await call<{ ImageName?: string }>(
|
||||
`${where().api}/api/images/v4/uploadsaved`,
|
||||
{ method: 'POST', multipart: form, authed: true }
|
||||
)
|
||||
if (!ImageName) throw new Error('The api worker accepted the picture but returned no name.')
|
||||
return ImageName
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the account's banner at an uploaded picture — `PUT /account/me/bannerimage` on
|
||||
* `accounts`, the call the game makes when a player picks a banner. Same `{ success }`
|
||||
* envelope as the avatar's PUT, and it pushes the same `AccountUpdate` frame, so a
|
||||
* running client redraws the banner too.
|
||||
*/
|
||||
async function setBannerImage(imageName: string): Promise<void> {
|
||||
const res = await call<{ success?: boolean }>(`${where().accounts}/account/me/bannerimage`, {
|
||||
method: 'PUT',
|
||||
authed: true,
|
||||
form: { imageName },
|
||||
refusal: 'The accounts worker refused the banner.',
|
||||
})
|
||||
if (res.success !== true) throw new Error('The accounts worker refused the banner.')
|
||||
}
|
||||
|
||||
/**
|
||||
* The blob's SHA-256, base64 — the encoding this API's hash fields use (an invention's
|
||||
* `BlobHash` comes back the same way). `rooms` only echoes it back on the save, but a
|
||||
@@ -539,6 +584,30 @@ const saveEmail = (email: string): Promise<unknown> =>
|
||||
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`. */
|
||||
const changePassword = (oldPassword: string, newPassword: string): Promise<unknown> =>
|
||||
call(`${where().auth}/account/me/changepassword`, {
|
||||
@@ -546,12 +615,40 @@ const changePassword = (oldPassword: string, newPassword: string): Promise<unkno
|
||||
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. */
|
||||
interface BenefitsStatus {
|
||||
/** Whether the account already has Rec Room Plus. */
|
||||
hasPlus: boolean
|
||||
/** Whether a Discord identity is already tied to it. Which one is deliberately not served. */
|
||||
linked: boolean
|
||||
/**
|
||||
* Whether ANY Discord is on the account — a claim's link or the bare one "Sign in with
|
||||
* Discord" leaves. What the unlink button keys on; `linked` is only the claimed case.
|
||||
*/
|
||||
discordLinked: boolean
|
||||
/**
|
||||
* The unlink's one precondition: the account must have an email address, or taking the
|
||||
* Discord off could leave nothing to recover it by. Served so the panel can say so first.
|
||||
*/
|
||||
hasEmail: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -582,6 +679,14 @@ interface ClaimResult {
|
||||
const claimBenefits = (code: string): Promise<ClaimResult> =>
|
||||
call<ClaimResult>('/api/benefits/claim', { json: { code }, authed: true })
|
||||
|
||||
/**
|
||||
* Take the Discord off the account. `www` refuses (403, with a sentence) unless the account
|
||||
* has a password and an email — the panel checks the same two flags first so the button
|
||||
* only shows when it can work. Answers the status as it now stands.
|
||||
*/
|
||||
const unlinkDiscord = (): Promise<BenefitsStatus> =>
|
||||
call<BenefitsStatus>('/api/benefits/unlink', { json: {}, authed: true })
|
||||
|
||||
/**
|
||||
* The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage.
|
||||
*
|
||||
@@ -592,21 +697,52 @@ const claimBenefits = (code: string): Promise<ClaimResult> =>
|
||||
*/
|
||||
const OAUTH_STATE_KEY = 'rf_discord_state'
|
||||
|
||||
/**
|
||||
* Where to land after a Discord SIGN-IN — the `?next=` the login page was opened with.
|
||||
* Stashed beside the nonce because the round trip through Discord loses the query string,
|
||||
* and read back by `DiscordLoginPage` exactly once.
|
||||
*/
|
||||
const OAUTH_NEXT_KEY = 'rf_discord_next'
|
||||
|
||||
/**
|
||||
* Send the browser to Discord's consent screen.
|
||||
*
|
||||
* A real navigation, not a client-side route — Discord is another origin. The `state` is
|
||||
* minted here and stashed for the return leg; `www` built everything else about the URL
|
||||
* (see `/api/config`), so this only ever appends the one parameter it owns.
|
||||
* (see `/api/config`), so this only ever appends the one parameter it owns. `next` is the
|
||||
* sign-in's post-login destination; the claim passes none.
|
||||
*/
|
||||
function startDiscordAuth(authorizeUrl: string) {
|
||||
function startDiscordAuth(authorizeUrl: string, next?: string) {
|
||||
const state = crypto.randomUUID()
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, state)
|
||||
if (next) sessionStorage.setItem(OAUTH_NEXT_KEY, next)
|
||||
else sessionStorage.removeItem(OAUTH_NEXT_KEY)
|
||||
const url = new URL(authorizeUrl)
|
||||
url.searchParams.set('state', state)
|
||||
window.location.assign(url.toString())
|
||||
}
|
||||
|
||||
/** What `/api/login/discord` answers: auth's token body, plus who signed in and whether it made them. */
|
||||
interface DiscordSignIn {
|
||||
access_token?: string
|
||||
/** True when no account was linked to this Discord and `auth` created one just now. */
|
||||
created?: boolean
|
||||
/** The Discord username, for the confirmation line only. */
|
||||
discordUsername?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem the sign-in's authorization code. Goes through `www`, like signup and the claim,
|
||||
* because the exchange needs the Discord client secret; `www` then has `auth` issue the
|
||||
* session and relays the token body, which is stored exactly as a password sign-in's is.
|
||||
*/
|
||||
async function signInWithDiscord(code: string): Promise<DiscordSignIn> {
|
||||
const data = await call<DiscordSignIn>('/api/login/discord', { json: { code } })
|
||||
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login'))
|
||||
setToken(data.access_token)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
|
||||
* — so a session without the role is rejected there (403) even though the UI shows no
|
||||
@@ -739,6 +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
|
||||
// it silently did nothing, which is the single most likely support question here.
|
||||
const [relogin, setRelogin] = useState(false)
|
||||
const [unlinking, setUnlinking] = useState(false)
|
||||
// StrictMode runs effects twice in dev, and a Discord code is single-use: the second
|
||||
// run would redeem a spent code and report a failure over a claim that just worked.
|
||||
const redeemed = useRef(false)
|
||||
@@ -779,7 +916,14 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
|
||||
setPending(true)
|
||||
claimBenefits(code)
|
||||
.then((result) => {
|
||||
setStatus({ hasPlus: result.hasPlus, linked: true })
|
||||
setStatus((prev) => ({
|
||||
hasPlus: result.hasPlus,
|
||||
linked: true,
|
||||
discordLinked: true,
|
||||
// The claim says nothing about this; keep what the status read said, and assume
|
||||
// none until it answers.
|
||||
hasEmail: prev?.hasEmail ?? false,
|
||||
}))
|
||||
const who = result.discordUsername ? ` as ${result.discordUsername}` : ''
|
||||
// Linked either way; whether Plus came with it depends on the roles. Without a
|
||||
// qualifying role the roles are still saved for the game, and that is said
|
||||
@@ -881,6 +1025,60 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
|
||||
Verify with Discord
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status?.discordLinked && !pending && (
|
||||
// Taking the Discord off again. Offered for ANY link — a claim's, or the bare one a
|
||||
// Discord sign-in made — but only once the account can be recovered without it: an
|
||||
// account made from Discord has no email until the player sets one, and `www`
|
||||
// refuses the unlink until there is one. The panel says so rather than showing a
|
||||
// button that would fail.
|
||||
<div className="unlink">
|
||||
<h3>Unlink Discord</h3>
|
||||
{status.hasEmail ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
Removes your Discord from this account. Rec Room Plus and Rec Room Studio access
|
||||
granted to this account are removed with it, your roles stop being read, and signing
|
||||
in with that Discord afterwards will make a new account rather than open this one.
|
||||
You can link again at any time.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="linkish danger"
|
||||
disabled={unlinking}
|
||||
onClick={() => {
|
||||
if (
|
||||
!confirm(
|
||||
'Unlink your Discord from this account? Rec Room Plus and Studio access on this account will be removed.'
|
||||
)
|
||||
)
|
||||
return
|
||||
setUnlinking(true)
|
||||
setError('')
|
||||
setDone('')
|
||||
unlinkDiscord()
|
||||
.then((next) => {
|
||||
setStatus(next)
|
||||
setRelogin(false)
|
||||
setDone('Your Discord has been unlinked from this account.')
|
||||
})
|
||||
.catch((err: unknown) =>
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
)
|
||||
.finally(() => setUnlinking(false))
|
||||
}}
|
||||
>
|
||||
{unlinking ? 'Unlinking…' : 'Unlink Discord'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="hint">
|
||||
Before you can unlink, add an email address to this account (the{' '}
|
||||
<strong>Email</strong> tab), so the account can be recovered without Discord.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -952,16 +1150,6 @@ function roomIdFromPath(path: string): number | null {
|
||||
return match ? Number.parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
/** Public account shape, from `GET /account/:id` or `/account/search`. */
|
||||
interface PublicAccount {
|
||||
accountId: number
|
||||
username: string
|
||||
displayName: string
|
||||
profileImage: string
|
||||
bannerImage: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's photo, from the `ImagesPlayer` projection. Public unless the list was the
|
||||
* viewer's own (see fetchPlayerPhotos), which is the one way a private one gets here —
|
||||
@@ -983,24 +1171,23 @@ interface PublicRoom {
|
||||
Stats: { VisitCount: number; CheerCount: number; FavoriteCount: number }
|
||||
}
|
||||
|
||||
/** Prefix-search accounts by username — backs the header search bar. */
|
||||
async function searchPlayers(query: string): Promise<PublicAccount[]> {
|
||||
/** Prefix-search accounts by username — backs the header search. */
|
||||
async function searchPlayers(query: string, take: number): Promise<PublicAccount[]> {
|
||||
if (query.trim() === '') return []
|
||||
return call<PublicAccount[]>(
|
||||
`${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}`
|
||||
`${where().accounts}/account/search?name=${encodeURIComponent(query.trim())}&take=${take}`
|
||||
)
|
||||
}
|
||||
|
||||
/** The profile page's account: an exact lookup, not the search bar's prefix query. */
|
||||
const fetchPublicAccount = (username: string): Promise<PublicAccount | null> =>
|
||||
searchPlayers(username).then(
|
||||
(matches) => matches.find((m) => m.username.toLowerCase() === username.toLowerCase()) ?? null
|
||||
)
|
||||
accountByUsername(username)
|
||||
|
||||
/**
|
||||
* The photos a player has taken. Sent WITH the session token when there is one: `api`
|
||||
* reads it and, only when the list is the caller's own, includes the photos they keep
|
||||
* private — so a signed-in player sees all of theirs, on `/account` and on their own
|
||||
* `/u/<name>`, while everyone else's profile is the public list either way. Nothing
|
||||
* `/user/<name>`, while everyone else's profile is the public list either way. Nothing
|
||||
* here decides who the viewer is; the worker does, from the token.
|
||||
*/
|
||||
const fetchPlayerPhotos = (accountId: number): Promise<PublicPhoto[]> =>
|
||||
@@ -1073,91 +1260,199 @@ async function setRoomExcluded(roomId: number, excluded: boolean): Promise<Owned
|
||||
return result.value
|
||||
}
|
||||
|
||||
/** The `/u/<username>` path, or null for any other path. */
|
||||
/** The `/user/<username>` path (or the old `/u/<username>`), or null for any other path. */
|
||||
function usernameFromPath(path: string): string | null {
|
||||
const match = /^\/u\/([^/]+)$/.exec(path)
|
||||
const match = /^\/(?:user|u)\/([^/]+)$/.exec(path)
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
// The nav's player search. `/u/:name` profiles stay reachable by URL either way; this only
|
||||
// decides whether the search bar that leads to them is shown.
|
||||
const SHOW_PLAYER_SEARCH: boolean = true
|
||||
// The nav's search. `/user/:name` and `/rooms/:id` stay reachable by URL either way; this
|
||||
// only decides whether the search button that leads to them is shown.
|
||||
const SHOW_SEARCH: boolean = true
|
||||
|
||||
/** How many of each kind the search panel lists. */
|
||||
const SEARCH_TAKE = 5
|
||||
|
||||
/**
|
||||
* The header search bar — a rec.net-style bubble dropdown of matching players as you
|
||||
* type. Debounced so it doesn't fire a search per keystroke; closes on selecting a
|
||||
* result, on Escape, or on clicking outside it.
|
||||
* Rooms whose name starts with `query`. The prefix endpoint, not the game's `/rooms/search`:
|
||||
* that one's substring match reads every public room per call, which a search fired on
|
||||
* every pause in typing can't afford; this one is an index seek billed per row returned.
|
||||
*/
|
||||
function PlayerSearch({ navigate }: { navigate: Navigate }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<PublicAccount[]>([])
|
||||
const searchRooms = (query: string, take: number): Promise<PublicRoom[]> =>
|
||||
query.trim() === ''
|
||||
? Promise.resolve([])
|
||||
: call<PublicRoom[]>(
|
||||
`${where().rooms}/rooms/search/name?query=${encodeURIComponent(query.trim())}&take=${take}`
|
||||
)
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
|
||||
<circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" strokeWidth="2.2" />
|
||||
<path
|
||||
d="M16 16l4.5 4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The header search: a magnifier button that opens a panel searching players and rooms
|
||||
* together as you type. A button rather than an always-on bar so it fits a phone's nav.
|
||||
* Debounced so it doesn't fire per keystroke; closes on picking a result, on Escape, or
|
||||
* on a tap outside it. Enter takes the first player, else the first room.
|
||||
*/
|
||||
function SiteSearch({ navigate }: { navigate: Navigate }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [players, setPlayers] = useState<PublicAccount[]>([])
|
||||
const [rooms, setRooms] = useState<PublicRoom[]>([])
|
||||
// The query the results on screen answer, so "nothing found" waits for the answer.
|
||||
const [answered, setAnswered] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
// Answers by normalized query, for the panel's lifetime on the page: backspacing to a
|
||||
// query already asked redraws it without another request (and another D1 read).
|
||||
const cache = useRef(new Map<string, [PublicAccount[], PublicRoom[]]>())
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim() === '') {
|
||||
setResults([])
|
||||
const key = query.trim().toLowerCase()
|
||||
const show = ([p, r]: [PublicAccount[], PublicRoom[]]) => {
|
||||
setPlayers(p.slice(0, SEARCH_TAKE))
|
||||
setRooms(r.slice(0, SEARCH_TAKE))
|
||||
setAnswered(query)
|
||||
}
|
||||
if (key === '') {
|
||||
setPlayers([])
|
||||
setRooms([])
|
||||
setAnswered('')
|
||||
return
|
||||
}
|
||||
const known = cache.current.get(key)
|
||||
if (known) {
|
||||
show(known)
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
const timeout = setTimeout(() => {
|
||||
void searchPlayers(query)
|
||||
.then((r) => setResults(r.slice(0, 6)))
|
||||
.catch(() => setResults([]))
|
||||
void Promise.all([
|
||||
searchPlayers(key, SEARCH_TAKE).catch(() => null),
|
||||
searchRooms(key, SEARCH_TAKE).catch(() => null),
|
||||
]).then(([p, r]) => {
|
||||
const found: [PublicAccount[], PublicRoom[]] = [p ?? [], r ?? []]
|
||||
// A failed half isn't cached, so the next time this is typed it asks again.
|
||||
if (p !== null && r !== null) cache.current.set(key, found)
|
||||
if (!stale) show(found)
|
||||
})
|
||||
}, 250)
|
||||
return () => clearTimeout(timeout)
|
||||
return () => {
|
||||
stale = true
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (!open) return
|
||||
inputRef.current?.focus()
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [])
|
||||
document.addEventListener('pointerdown', onPointerDown)
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown)
|
||||
}, [open])
|
||||
|
||||
const go = (username: string) => {
|
||||
const go = (to: string) => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
navigate(`/u/${encodeURIComponent(username)}`)
|
||||
navigate(to)
|
||||
}
|
||||
const playerPath = (r: PublicAccount) => `/user/${encodeURIComponent(r.username)}`
|
||||
const roomPath = (r: PublicRoom) => `/rooms/${r.RoomId}`
|
||||
const nothing = query.trim() !== '' && answered === query && players.length + rooms.length === 0
|
||||
|
||||
return (
|
||||
<div className="player-search" ref={containerRef}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search players…"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
if (e.key === 'Enter' && results[0]) go(results[0].username)
|
||||
}}
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<div className="player-search-bubble">
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.accountId}
|
||||
className="player-search-result"
|
||||
onClick={() => go(r.username)}
|
||||
>
|
||||
<img
|
||||
className="player-search-avatar"
|
||||
src={`${where().img}/${r.profileImage}?width=64`}
|
||||
alt=""
|
||||
/>
|
||||
<span>
|
||||
<span className="player-search-name">{r.displayName || r.username}</span>
|
||||
<span className="player-search-handle">@{r.username}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="site-search" ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`site-search-toggle${open ? ' active' : ''}`}
|
||||
aria-label="Search players and rooms"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="site-search-panel">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
placeholder="Search players and rooms…"
|
||||
aria-label="Search players and rooms"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
if (e.key === 'Enter') {
|
||||
if (players[0]) go(playerPath(players[0]))
|
||||
else if (rooms[0]) go(roomPath(rooms[0]))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{players.length > 0 && (
|
||||
<div className="site-search-group">
|
||||
<p className="site-search-heading">Players</p>
|
||||
{players.map((r) => (
|
||||
<button
|
||||
key={r.accountId}
|
||||
type="button"
|
||||
className="site-search-result"
|
||||
onClick={() => go(playerPath(r))}
|
||||
>
|
||||
<img
|
||||
className="site-search-avatar"
|
||||
src={`${where().img}/${r.profileImage}?width=64`}
|
||||
alt=""
|
||||
/>
|
||||
<span>
|
||||
<span className="site-search-name">{r.displayName || r.username}</span>
|
||||
<span className="site-search-sub">@{r.username}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rooms.length > 0 && (
|
||||
<div className="site-search-group">
|
||||
<p className="site-search-heading">Rooms</p>
|
||||
{rooms.map((r) => (
|
||||
<button
|
||||
key={r.RoomId}
|
||||
type="button"
|
||||
className="site-search-result"
|
||||
onClick={() => go(roomPath(r))}
|
||||
>
|
||||
<img
|
||||
className="site-search-thumb"
|
||||
src={`${where().img}/${r.ImageName}?width=128`}
|
||||
alt=""
|
||||
/>
|
||||
<span>
|
||||
<span className="site-search-name">^{r.Name}</span>
|
||||
<span className="site-search-sub">
|
||||
{r.Stats.VisitCount.toLocaleString()} visits
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{nothing && <p className="site-search-empty">No players or rooms match.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1284,7 +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
|
||||
* "rec.net-style profile" other players browse to, not the owner's own dashboard
|
||||
* (that stays on `/account`).
|
||||
@@ -1306,7 +1601,7 @@ function PlayerPage({
|
||||
useEffect(() => {
|
||||
// Waits for the config, not the session: this page is public, but every fetch on it
|
||||
// goes through `where()`, which has no hostnames until `/api/config` lands. On a cold
|
||||
// load of `/u/<name>` the effect would otherwise fire first and the page would be
|
||||
// load of `/user/<name>` the effect would otherwise fire first and the page would be
|
||||
// stuck on "Still starting up".
|
||||
if (config === undefined) return
|
||||
setAccount(undefined)
|
||||
@@ -1777,11 +2072,18 @@ function halloweenActive(): boolean {
|
||||
}
|
||||
|
||||
/** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */
|
||||
const PUMPKIN_SRC =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAZCAYAAADaILXQAAAFD0lEQVR42pWVX2xcxRXGf2fu3Lu7jh0bO3FsnMQ2idP8gZoIGkootZsIGrUlVEVCTVW1qVREUPtSiTckYgQPqEIg1FZU6gtP8EJVAaZpIqE4UtrQOCSIkj+YVgRsp3E2mzisnb27OzOnD+uuE2yJZJ7u6J755jvf+eYc4SbX7qHBbNyjpe5VS5k4Nw3VZj4evcrFiWn+9fZxuTbW3Cw43WcxBMBCNYtYRYwiujDU3ijmwK++rWv7G7BSpLdrGf8Zv8TY+54kCuQ/v4IsckZuBPg7Twzq2ntq1IwXjvzlCz4cPv6VZxcN2LttTfXh9phKLPxtMmVnW7BqAiPnU2aipTzUFnjzonMiFhFn9747vihO9P+PoW29Dz7Yt3LD/sdl3dRl/9NmLRu1GJPzpqfFk2SV6ZIhGwlrVoBP1MQO0xQrQ/1N76/qXL7u4KeXxxbV3Jjwuveu1USGH98XOH44EIopu+7PECJBXGBmVpFKhr71JfqAD0cTVMGY5G1MukAJC/DM9l597IEmVsRTAJz4u2XrG+cxQGnPHuyOP6L797AxOYh1KdGmnYAhHPsrrjrLlq05dNTCtm71Jd82dGTiUh3cKDV7ac2ZAUP60moEB6ffQvfvQeucQt3Bd+3eAZlb+OCV1+n/Zpl3hpsAP8/86QdW6VM/tEjlCmWf8sizGcaiHB9thYQZgg+sfngYMPxuR46VpkS/exMRRQWE5bWL1fD9LsuhcV/Yu707febdz3I29gapBMg0467maG5pI3JlitaSAZaEIk1tHbXqNyxBowTT4IjK0zW72Qoyl7GmJdTPu8SC4dg/Krw6GrhcbeUDrYIY7v+th4uTnHxuaT3NR157j0CVvoN3cvo303PyBPRLjz3MSWiDwMvvrQACEpRg5v6oQVs3sfHFgEpg7NzxufRhbPIE0ZNw+NG1xDbHvnxA/+mR+kVz4Af9p/G31vjq5NkGyppjszpOYFFVQBERfmkNfvVmHIqo4e5cBBiy4RIVyhiRmhnUAG6+cY2M4FzSMNyQc9XWlgoiwi0mkAhkgO4AagUTZVkeWZZloSnyeHzNTdfIMeWgHMmBGbtkX12o5w+ceWj5ra7Y1j4LQK8qwZfRNGVbDgKWINCei+kVT1+Yt6XB4VGEwNEZoZKaXS8cOPWjBV1RCKAWpMLPlrYg1at4CWzf/DkXTt7K16qeFhNxVCI6br/Avrwl+Dw/6Grl5zMrWNbXQFquwpGJhf1cTJYnt16ou8BVpxm8q7bvvCPPW50T/L6rQMcd+TmPQ5rt4RezLdz7aCNrtwi5ZlMYeGKgdF0v2NR/W8FFcatUS/ykI8POzhasVf787/N8rzNi31TKRyu7KSyJicXw30/ykFiCEZbd1k6aFvn6fRmSOEsZ5ZPDbl4Wn2QxCmQa0VCiWrlMKjE+00g5MlQyEaXY4GyEw+EaDZFP8FJG1WGTLGhMpeJAPS5yNeYb7t543ZA6fexUPaP139ioEULcnqGtu5mKKOu3eFDDn84eihiiXt7BXw/o4HdXYnIlxicK12uuaHotMMCZ0VMSVClPpRTOzlL4rMjYUaGnq5nHewb8Y68O1ImpKucu5BmfrNXkpgb0lUK+oz3X1IRa1MiCoSwnTay2dEmI+fio1jRXleFIwYupLgZqggwDJMXkKhtwEEhLji+uKEhdFUZGRtztu3YeMBI1Znzxxgb0tWtw92BWGyitu8dzZlQxCof+cHhRnP8B5aopRAfi2ZcAAAAASUVORK5CYII='
|
||||
const PUMPKIN_SCALE = 3
|
||||
const PUMPKIN_W = 23 * PUMPKIN_SCALE
|
||||
const PUMPKIN_H = 25 * PUMPKIN_SCALE
|
||||
const PUMPKIN_FRAMES = [
|
||||
new URL('./pumple-frames/frame-00.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-01.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-02.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-03.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-04.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-05.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-06.png', import.meta.url).href,
|
||||
new URL('./pumple-frames/frame-07.png', import.meta.url).href,
|
||||
]
|
||||
const PUMPKIN_W = 150
|
||||
const PUMPKIN_H = 150
|
||||
const PUMPKIN_LINES = ['boo!', 'hehe', 'hey!', 'spooky!', 'happy halloween!', 'ow!', '🎃']
|
||||
|
||||
const PUMPKIN_CSS = `
|
||||
@@ -1793,16 +2095,7 @@ const PUMPKIN_CSS = `
|
||||
image-rendering: pixelated;
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
.rf-pumpkin.is-walking img {
|
||||
animation: rf-pumpkin-step 0.24s steps(2, end) infinite;
|
||||
}
|
||||
@keyframes rf-pumpkin-step {
|
||||
0%, 100% { transform: translateY(0) scaleX(1); }
|
||||
50% { transform: translateY(-1px) scaleX(0.96); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rf-pumpkin.is-walking img { animation: none; }
|
||||
}
|
||||
/* The animated GIF provides its own walking frames. */
|
||||
.rf-pumpkin-bubble { position: absolute; left: 50%; transform: translateX(-50%); margin: 6px 0; padding: 3px 8px;
|
||||
border-radius: 10px; background: #fff3dc; color: #2a1608; font-size: 12px; font-weight: 600;
|
||||
white-space: nowrap; opacity: 0; transition: opacity 0.15s; }
|
||||
@@ -1816,13 +2109,15 @@ const PUMPKIN_CSS = `
|
||||
function LilPumpkin() {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const flipRef = useRef<HTMLDivElement>(null)
|
||||
const spriteRef = useRef<HTMLImageElement>(null)
|
||||
const bubbleRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
const flip = flipRef.current
|
||||
const sprite = spriteRef.current
|
||||
const bubble = bubbleRef.current
|
||||
if (!root || !flip || !bubble) return
|
||||
if (!root || !flip || !sprite || !bubble) return
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
root.style.display = 'none'
|
||||
return
|
||||
@@ -1841,12 +2136,73 @@ function LilPumpkin() {
|
||||
let jy = 0
|
||||
let vy = 0
|
||||
let walkTime = 0
|
||||
let walkDistance = 0
|
||||
let walkFrame = 0
|
||||
let mouseX = -1
|
||||
let mouseY = -1
|
||||
let mouseAt = -Infinity
|
||||
let speechTimer = 0
|
||||
let last = performance.now()
|
||||
let raf = 0
|
||||
const effectNodes: HTMLElement[] = []
|
||||
|
||||
const spawnBatSwarm = () => {
|
||||
const flash = document.createElement('div')
|
||||
Object.assign(flash.style, {
|
||||
position: 'fixed',
|
||||
inset: '0',
|
||||
background: 'rgba(210, 0, 28, 0.32)',
|
||||
opacity: '1',
|
||||
transition: 'opacity 480ms ease-out',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '100000',
|
||||
})
|
||||
document.body.appendChild(flash)
|
||||
effectNodes.push(flash)
|
||||
requestAnimationFrame(() => {
|
||||
flash.style.opacity = '0'
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
flash.remove()
|
||||
const i = effectNodes.indexOf(flash)
|
||||
if (i !== -1) effectNodes.splice(i, 1)
|
||||
}, 520)
|
||||
|
||||
const cx = x + PUMPKIN_W / 2
|
||||
const cy = y + jy + PUMPKIN_H / 2
|
||||
for (let i = 0; i < 22; i++) {
|
||||
const bat = document.createElement('span')
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const distance = 100 + Math.random() * 260
|
||||
const dx = Math.cos(angle) * distance
|
||||
const dy = Math.sin(angle) * distance
|
||||
Object.assign(bat.style, {
|
||||
position: 'fixed',
|
||||
left: `${cx}px`,
|
||||
top: `${cy}px`,
|
||||
fontSize: `${20 + Math.random() * 20}px`,
|
||||
lineHeight: '1',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '100001',
|
||||
opacity: '1',
|
||||
transform: 'translate(-50%, -50%) scale(0.4)',
|
||||
transition: 'transform 850ms ease-out, opacity 850ms ease-out',
|
||||
willChange: 'transform, opacity',
|
||||
})
|
||||
bat.textContent = '🦇'
|
||||
document.body.appendChild(bat)
|
||||
effectNodes.push(bat)
|
||||
requestAnimationFrame(() => {
|
||||
bat.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${Math.random() * 100 - 50}deg) scale(1.2)`
|
||||
bat.style.opacity = '0'
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
bat.remove()
|
||||
const i = effectNodes.indexOf(bat)
|
||||
if (i !== -1) effectNodes.splice(i, 1)
|
||||
}, 900)
|
||||
}
|
||||
}
|
||||
|
||||
root.style.transform = `translate(${x}px, ${y}px)`
|
||||
|
||||
@@ -1870,14 +2226,30 @@ function LilPumpkin() {
|
||||
const onLeave = () => {
|
||||
mouseAt = -Infinity
|
||||
}
|
||||
let rapidClickCount = 0
|
||||
let rapidClickStart = 0
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const top = y + jy
|
||||
const bob = 0
|
||||
const hit =
|
||||
e.clientX >= x - 6 &&
|
||||
e.clientX <= x + PUMPKIN_W + 6 &&
|
||||
e.clientY >= top - 6 &&
|
||||
e.clientY <= top + PUMPKIN_H + 6
|
||||
e.clientX >= x - 45 &&
|
||||
e.clientX <= x + PUMPKIN_W + 45 &&
|
||||
e.clientY >= top - bob - 45 &&
|
||||
e.clientY <= top - bob + PUMPKIN_H + 45
|
||||
if (!hit) return
|
||||
|
||||
const now = performance.now()
|
||||
if (now - rapidClickStart > 5000) {
|
||||
rapidClickStart = now
|
||||
rapidClickCount = 0
|
||||
}
|
||||
rapidClickCount++
|
||||
if (rapidClickCount >= 10) {
|
||||
rapidClickCount = 0
|
||||
rapidClickStart = 0
|
||||
window.dispatchEvent(new CustomEvent('rf-pumple-boss'))
|
||||
}
|
||||
spawnBatSwarm()
|
||||
if (jy === 0) vy = -430
|
||||
say(PUMPKIN_LINES[Math.floor(Math.random() * PUMPKIN_LINES.length)])
|
||||
}
|
||||
@@ -1903,11 +2275,11 @@ function LilPumpkin() {
|
||||
const dy = mouseY - (y + PUMPKIN_H / 2)
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if (Math.abs(dx) > 4) facing = dx > 0 ? 1 : -1
|
||||
if (dist > 110) {
|
||||
if (dist > 85) {
|
||||
// Walk toward the cursor but stop short, so it never ends up sitting under the pointer.
|
||||
tx = clamp(mouseX - (dx / dist) * 80 - PUMPKIN_W / 2, 0, maxX())
|
||||
ty = clamp(mouseY - (dy / dist) * 80 - PUMPKIN_H / 2, 0, maxY())
|
||||
speed = 150
|
||||
speed = 310
|
||||
} else {
|
||||
tx = x
|
||||
ty = y
|
||||
@@ -1920,7 +2292,7 @@ function LilPumpkin() {
|
||||
ty = Math.random() * maxY()
|
||||
}
|
||||
} else {
|
||||
speed = 60
|
||||
speed = 145
|
||||
}
|
||||
|
||||
const mx = tx - x
|
||||
@@ -1932,6 +2304,15 @@ function LilPumpkin() {
|
||||
x += (mx / md) * step
|
||||
y += (my / md) * step
|
||||
moving = true
|
||||
|
||||
// Advance the walk cycle according to distance actually traveled.
|
||||
walkDistance += step
|
||||
const nextFrame = Math.floor(walkDistance / 30) % PUMPKIN_FRAMES.length
|
||||
if (nextFrame !== walkFrame) {
|
||||
walkFrame = nextFrame
|
||||
sprite.src = PUMPKIN_FRAMES[walkFrame]
|
||||
}
|
||||
|
||||
if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1
|
||||
} else if (speed > 0 && !following) {
|
||||
pause = 1 + Math.random() * 2.5
|
||||
@@ -1980,7 +2361,7 @@ function LilPumpkin() {
|
||||
<div className="rf-pumpkin" ref={rootRef}>
|
||||
<div className="rf-pumpkin-bubble" ref={bubbleRef} />
|
||||
<div ref={flipRef} style={{ transformOrigin: '50% 100%' }}>
|
||||
<img src={PUMPKIN_SRC} alt="" draggable={false} />
|
||||
<img ref={spriteRef} src={PUMPKIN_FRAMES[0]} alt="" draggable={false} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -2085,6 +2466,7 @@ export function App() {
|
||||
turnstileSiteKey: null,
|
||||
benefitsEnabled: false,
|
||||
discordAuthorizeUrl: null,
|
||||
discordLoginUrl: null,
|
||||
})
|
||||
setAccount(null)
|
||||
})
|
||||
@@ -2102,7 +2484,12 @@ export function App() {
|
||||
return (
|
||||
<>
|
||||
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
|
||||
{path === '/login' || path === '/signup' ? (
|
||||
{path === '/login/discord' ? (
|
||||
// Discord's registered redirect for a SIGN-IN (the claim's is `/claim`). Matched
|
||||
// before `/login`, which it would otherwise never reach; a cold load falls through
|
||||
// to the SPA shell like every other client-side route.
|
||||
<DiscordLoginPage config={config} navigate={navigate} onAuthed={setAccount} />
|
||||
) : path === '/login' || path === '/signup' ? (
|
||||
// One page, two doors. `/signup` exists so the homepage's create-account link
|
||||
// lands on that tab instead of dropping people on sign-in to find it — and so
|
||||
// the URL is linkable. Unknown paths fall back to index.html (see the assets
|
||||
@@ -2115,6 +2502,21 @@ export function App() {
|
||||
navigate={navigate}
|
||||
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' ? (
|
||||
// Studio opens this from verification_uri_complete. The code was minted by
|
||||
// auth; this page only approves it with the website session.
|
||||
@@ -2175,6 +2577,7 @@ export function App() {
|
||||
)}
|
||||
<SiteFooter />
|
||||
<HalloweenOverlay />
|
||||
<PumpleBossFight />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2222,7 +2625,6 @@ function NavBar({
|
||||
RecFlare
|
||||
</Link>
|
||||
<nav className="nav-links">
|
||||
{SHOW_PLAYER_SEARCH && <PlayerSearch navigate={navigate} />}
|
||||
<a href={DISCORD_INVITE} target="_blank" rel="noreferrer">
|
||||
Discord
|
||||
</a>
|
||||
@@ -2267,6 +2669,7 @@ function NavBar({
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
{SHOW_SEARCH && <SiteSearch navigate={navigate} />}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
@@ -2482,7 +2885,7 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
||||
return (
|
||||
<section className="about">
|
||||
<div>
|
||||
<h2 className="about-title">A cloud architected server for the 2023/2025 game clients</h2>
|
||||
<h2 className="about-title">A cloud architected server for the 2023-2026 game clients</h2>
|
||||
<p className="about-lede">
|
||||
A free fan project, made by players who missed it. Aiming to be{' '}
|
||||
<strong>feature-complete</strong> and infinitely scalable —{' '}
|
||||
@@ -2563,6 +2966,7 @@ function LoginPage({
|
||||
}
|
||||
|
||||
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
|
||||
const discordLoginUrl = config?.discordLoginUrl ?? null
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
@@ -2609,6 +3013,30 @@ function LoginPage({
|
||||
your Steam ID — set a password on it and it signs in here too.
|
||||
</p>
|
||||
<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
|
||||
someone who just found out they have no account is actually looking.
|
||||
Gated on the same key, so it can't point at a door that isn't there. */}
|
||||
@@ -2627,6 +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>'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'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. */
|
||||
function AccountPage({
|
||||
account,
|
||||
@@ -2838,7 +3640,7 @@ function PublicRoomView({
|
||||
{creator && (
|
||||
<p className="room-hero-by">
|
||||
by{' '}
|
||||
<Link to={`/u/${encodeURIComponent(creator.username)}`} navigate={navigate}>
|
||||
<Link to={`/user/${encodeURIComponent(creator.username)}`} navigate={navigate}>
|
||||
{creator.displayName || creator.username}
|
||||
</Link>
|
||||
</p>
|
||||
@@ -2889,6 +3691,7 @@ function PublicRoomView({
|
||||
<>
|
||||
{isDeveloper() && <StaffRoomTokens room={room} />}
|
||||
<StaffListing room={room} onRoomChange={onRoomChange} />
|
||||
<StaffClearInstances room={room} />
|
||||
{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 }) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const { pending, error, run } = useAction()
|
||||
@@ -3820,7 +4681,8 @@ function SignupForm({
|
||||
// Saved with the new session's own token: `create_account` takes no email,
|
||||
// `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
|
||||
// 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(() => {})
|
||||
|
||||
// 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.
|
||||
{ id: 'rooms', label: 'My rooms', render: () => <MyRooms navigate={navigate} /> },
|
||||
{ id: 'photos', label: 'My photos', render: () => <MyPhotos accountId={account.accountId} /> },
|
||||
{
|
||||
id: 'banner',
|
||||
label: 'Profile banner',
|
||||
render: () => <BannerUpload account={account} onChange={onChange} />,
|
||||
},
|
||||
{
|
||||
id: 'username',
|
||||
label: 'Username',
|
||||
@@ -4112,6 +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
|
||||
* doing. The whole row links to the room's own page.
|
||||
@@ -4806,18 +5722,43 @@ function EmailForm({
|
||||
onChange: (a: SelfAccount) => void
|
||||
}) {
|
||||
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 (
|
||||
<section className="card">
|
||||
<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
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void run(async () => {
|
||||
await saveEmail(email.trim())
|
||||
onChange({ ...account, email })
|
||||
return 'Email saved.'
|
||||
void save.run(async () => {
|
||||
const wanted = email.trim()
|
||||
await saveEmail(wanted)
|
||||
// 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
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{done && <p className="ok">{done}</p>}
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? 'Saving…' : 'Save email'}
|
||||
{save.error && <p className="error">{save.error}</p>}
|
||||
{save.done && <p className="ok">{save.done}</p>}
|
||||
<button type="submit" disabled={save.pending}>
|
||||
{save.pending ? 'Saving…' : 'Save email'}
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,10 +216,10 @@ export function ItemPage({
|
||||
<p className="room-hero-by">
|
||||
by{' '}
|
||||
<a
|
||||
href={`/u/${encodeURIComponent(creator.username)}`}
|
||||
href={`/user/${encodeURIComponent(creator.username)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(`/u/${encodeURIComponent(creator.username)}`)
|
||||
navigate(`/user/${encodeURIComponent(creator.username)}`)
|
||||
}}
|
||||
>
|
||||
{creator.displayName || creator.username}
|
||||
@@ -379,10 +379,10 @@ export function InventionPage({
|
||||
<p className="room-hero-by">
|
||||
by{' '}
|
||||
<a
|
||||
href={`/u/${encodeURIComponent(creator.username)}`}
|
||||
href={`/user/${encodeURIComponent(creator.username)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
navigate(`/u/${encodeURIComponent(creator.username)}`)
|
||||
navigate(`/user/${encodeURIComponent(creator.username)}`)
|
||||
}}
|
||||
>
|
||||
{creator.displayName || creator.username}
|
||||
|
||||
@@ -369,7 +369,7 @@ function BanSummary({
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for a player's profile page (`/u/<username>`): whether a ban blocks them right
|
||||
* One line for a player's profile page (`/user/<username>`): whether a ban blocks them right
|
||||
* now — their own or a linked account's — and a link to their full history here.
|
||||
*/
|
||||
export function PlayerBanStatus({
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
type Mob = { id: number; x: number; y: number; hp: number }
|
||||
type Shot = { id: number; x: number; y: number; vx: number; vy: number; bomb?: boolean }
|
||||
type Game = {
|
||||
player: number
|
||||
hp: number
|
||||
bossHp: number
|
||||
mobs: Mob[]
|
||||
enemyShots: Shot[]
|
||||
arrows: Shot[]
|
||||
bombs: number
|
||||
kills: number
|
||||
waves: number
|
||||
nextId: number
|
||||
lastSpawn: number
|
||||
lastAttack: number
|
||||
phase: 'fight' | 'victory' | 'defeat'
|
||||
}
|
||||
const freshGame = (): Game => ({
|
||||
player: (typeof window !== 'undefined' ? window.innerWidth : 1024) / 2, hp: 100, bossHp: 100,
|
||||
mobs: [], enemyShots: [], arrows: [], bombs: 0, kills: 0,
|
||||
waves: 1, nextId: 1, lastSpawn: 0, lastAttack: 0, phase: 'fight',
|
||||
})
|
||||
|
||||
const css = `
|
||||
.rf-boss{position:fixed;inset:0;z-index:100010;overflow:hidden;background:radial-gradient(ellipse at 50% 34%,#542619 0%,#26121a 43%,#090810 100%);color:#fff0d5;font-family:system-ui,sans-serif;cursor:crosshair;user-select:none}
|
||||
.rf-boss:before{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,#08060a66,transparent 35%,#05050988);z-index:0}
|
||||
.rf-boss:after{content:"";position:absolute;inset:0;pointer-events:none;opacity:.22;background-image:radial-gradient(#ffb35b 1px,transparent 1px);background-size:37px 37px;mask-image:linear-gradient(transparent,#000 30%,#000)}
|
||||
.rf-boss *{box-sizing:border-box}
|
||||
.rf-boss-hud{position:absolute;z-index:8;top:18px;left:50%;transform:translateX(-50%);width:min(900px,82%);display:grid;grid-template-columns:1.2fr 1fr;gap:22px;padding:16px 20px;border:1px solid #e9843c88;border-radius:15px;background:linear-gradient(135deg,#160d12eF,#2a1117eF);box-shadow:0 10px 45px #0009,inset 0 1px #ffd2a322;backdrop-filter:blur(8px)}
|
||||
.rf-boss-label{font-weight:950;letter-spacing:2px;font-size:11px;text-transform:uppercase;margin-bottom:8px;color:#ffddb2}
|
||||
.rf-boss-track{height:17px;border:1px solid #ffd9aa88;background:#100a10;border-radius:5px;overflow:hidden;box-shadow:inset 0 2px 5px #000a}
|
||||
.rf-boss-fill{height:100%;background:linear-gradient(90deg,#a82420,#ff6036,#ffb05b);box-shadow:0 0 15px #ff552a99;transition:width .12s}
|
||||
.rf-boss-player .rf-boss-fill{background:linear-gradient(90deg,#138a65,#49e7a0);box-shadow:0 0 14px #3df0a088}
|
||||
.rf-boss-monster{position:absolute;z-index:4;top:16%;left:50%;width:min(43vw,390px);height:min(43vw,390px);min-width:230px;min-height:230px;transform:translateX(-50%);display:grid;place-items:center;filter:drop-shadow(0 0 18px #ff5b24aa);animation:rf-boss-hover 3s ease-in-out infinite}
|
||||
.rf-boss-monster:before{content:"";position:absolute;width:90%;height:35%;bottom:0;border-radius:50%;background:#ff5a1726;filter:blur(25px)}
|
||||
.rf-boss-monster img{position:relative;width:100%;height:100%;object-fit:contain;image-rendering:pixelated;filter:drop-shadow(0 12px 0 #150a10) drop-shadow(0 0 16px #ff6b35aa)}
|
||||
.rf-boss-minion{position:absolute;z-index:4;transform:translate(-50%,-50%);width:clamp(44px,7vw,78px);height:clamp(44px,7vw,78px);filter:drop-shadow(0 0 10px #ff4b2588);pointer-events:none}
|
||||
.rf-boss-minion img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated}
|
||||
.rf-boss-shot{position:absolute;z-index:5;transform:translate(-50%,-50%);font-size:25px;pointer-events:none;filter:drop-shadow(0 0 9px #ff712f);animation:rf-shot-glow .5s ease-in-out infinite alternate}
|
||||
.rf-boss-player-char{position:absolute;z-index:5;bottom:7%;font-size:42px;transform:translateX(-50%);width:64px;height:64px;display:grid;place-items:center;border-radius:50%;background:radial-gradient(circle,#77eaff35,transparent 70%);filter:drop-shadow(0 0 10px #7ad9ff);pointer-events:none}
|
||||
.rf-boss-hint{position:absolute;z-index:8;bottom:14px;left:50%;transform:translateX(-50%);width:max-content;max-width:88%;text-align:center;font-size:11px;font-weight:850;letter-spacing:1px;color:#ffe3c1;padding:12px 18px;border:1px solid #b9684777;border-radius:9px;background:#100b13dc;box-shadow:0 4px 25px #0008}
|
||||
.rf-boss-vines{position:absolute;z-index:6;inset:0;pointer-events:none;overflow:hidden;opacity:1}
|
||||
.rf-boss-vines svg{position:absolute;inset:-2%;width:104%;height:104%;overflow:visible}
|
||||
.rf-boss-vines .vine-main{fill:none;stroke:#172f1b;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;filter:drop-shadow(0 0 5px #050807)}
|
||||
.rf-boss-vines .vine-edge{fill:none;stroke:#426a2d;stroke-width:12;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:25 8;animation:rf-vine-crawl 7s linear infinite}
|
||||
.rf-boss-vines .vine-glow{fill:none;stroke:#df722a;stroke-width:3;stroke-linecap:round;stroke-dasharray:3 28;animation:rf-vine-crawl 4s linear infinite}
|
||||
.rf-boss-vines .thorn{fill:#72913b;stroke:#18291a;stroke-width:3}
|
||||
.rf-boss-vines .root{fill:none;stroke:#253d20;stroke-width:39;stroke-linecap:round;opacity:.92}
|
||||
.rf-boss-vines-retreat{animation:rf-vine-retreat 2.8s ease-in forwards}
|
||||
.rf-boss-end{position:absolute;z-index:10;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;background:linear-gradient(180deg,#08090bea,#130d0bea);padding:24px}
|
||||
.rf-boss-panel{max-width:560px;width:100%;padding:clamp(26px,5vw,44px);border:2px solid #9b542d;border-radius:4px;background:#171310;box-shadow:8px 8px 0 #050505, inset 0 0 0 4px #171310, inset 0 0 0 5px #443023;color:#eee2ce}
|
||||
.rf-boss-title{font-family:Impact,'Arial Narrow',system-ui,sans-serif;font-size:clamp(34px,6.5vw,60px);font-weight:900;line-height:1.05;letter-spacing:1px;text-transform:uppercase;color:#f0a04d;text-shadow:3px 3px 0 #522719;margin:0 0 20px}
|
||||
.rf-boss-button{margin:10px 6px 0;border:2px solid #c47b43;border-radius:3px;padding:12px 20px;background:#30251b;color:#ffe5c3;font-family:inherit;font-size:13px;font-weight:800;letter-spacing:1px;text-transform:uppercase;cursor:pointer;box-shadow:0 3px 0 #090807;transition:background .12s,transform .12s}
|
||||
.rf-boss-button:hover{background:#513522;transform:translateY(-2px)}
|
||||
@keyframes rf-boss-hover{0%,100%{translate:0 0}50%{translate:0 -8px}}
|
||||
@keyframes rf-shot-glow{from{scale:.9}to{scale:1.12}}
|
||||
@keyframes rf-vine-crawl{to{stroke-dashoffset:-132}}
|
||||
@keyframes rf-vine-retreat{to{transform:scale(1.4);opacity:0}}
|
||||
@media(max-width:600px){.rf-boss-hud{top:10px;width:86%;padding:12px;gap:12px}.rf-boss-label{font-size:9px;letter-spacing:1px}.rf-boss-track{height:13px}.rf-boss-monster{top:23%;width:65vw;height:65vw;min-width:0;min-height:0}.rf-boss-hint{font-size:9px;letter-spacing:.3px;padding:10px;bottom:8px}}
|
||||
@media(prefers-reduced-motion:reduce){.rf-boss-monster,.rf-boss-shot,.rf-boss-vines .vine-edge,.rf-boss-vines .vine-glow{animation:none}}
|
||||
`
|
||||
|
||||
export function PumpleBossFight() {
|
||||
const [active, setActive] = useState(false)
|
||||
const [view, setView] = useState<Game>(freshGame)
|
||||
const game = useRef<Game>(freshGame())
|
||||
const mouse = useRef({ x: typeof window !== 'undefined' ? window.innerWidth / 2 : 512, y: typeof window !== 'undefined' ? window.innerHeight * .78 : 600 })
|
||||
const aim = useRef({ x: typeof window !== 'undefined' ? window.innerWidth / 2 : 512, y: typeof window !== 'undefined' ? window.innerHeight * .3 : 200 })
|
||||
const started = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const start = () => {
|
||||
if (started.current) return
|
||||
started.current = true
|
||||
game.current = freshGame()
|
||||
mouse.current = { x: innerWidth / 2, y: innerHeight * .78 }
|
||||
setView({ ...game.current })
|
||||
setActive(true)
|
||||
}
|
||||
window.addEventListener('rf-pumple-boss', start)
|
||||
return () => window.removeEventListener('rf-pumple-boss', start)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const g = game.current
|
||||
let last = performance.now()
|
||||
let frame = 0
|
||||
let endTimer = 0
|
||||
|
||||
const tick = () => {
|
||||
const now = performance.now()
|
||||
const dt = Math.min(.05, (now - last) / 1000)
|
||||
last = now
|
||||
if (g.phase === 'fight') {
|
||||
g.player = Math.max(28, Math.min(innerWidth - 28, mouse.current.x))
|
||||
const difficulty = 1 + (g.waves - 1) * .18
|
||||
|
||||
if (now - g.lastSpawn > Math.max(900, 3000 / difficulty) && g.mobs.length < 5 + g.waves) {
|
||||
g.lastSpawn = now
|
||||
g.mobs.push({ id: g.nextId++, x: Math.random() * (innerWidth - 80) + 40, y: innerHeight * (.35 + Math.random() * .28), hp: 2 })
|
||||
}
|
||||
if (now - g.lastAttack > Math.max(450, 1400 / difficulty)) {
|
||||
g.lastAttack = now
|
||||
const sx = innerWidth / 2, sy = innerHeight * .29
|
||||
const dx = g.player - sx, dy = innerHeight * .78 - sy
|
||||
const d = Math.hypot(dx, dy) || 1
|
||||
g.enemyShots.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 280 * difficulty, vy: dy / d * 280 * difficulty })
|
||||
if (g.waves >= 2) {
|
||||
g.enemyShots.push({ id: g.nextId++, x: sx, y: sy, vx: -dx / d * 240 * difficulty, vy: 270 * difficulty })
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of g.mobs) {
|
||||
const dx = g.player - m.x, dy = innerHeight * .78 - m.y, d = Math.hypot(dx, dy) || 1
|
||||
m.x += dx / d * 75 * difficulty * dt
|
||||
m.y += dy / d * 75 * difficulty * dt
|
||||
if (d < 35) { g.hp -= 18 * dt; m.hp = 0 }
|
||||
}
|
||||
|
||||
for (const s of g.enemyShots) { s.x += s.vx * dt; s.y += s.vy * dt }
|
||||
g.enemyShots = g.enemyShots.filter(s => {
|
||||
if (Math.hypot(s.x - g.player, s.y - innerHeight * .78) < 32) { g.hp -= 12; return false }
|
||||
return s.x > -50 && s.x < innerWidth + 50 && s.y < innerHeight + 50
|
||||
})
|
||||
|
||||
for (const s of g.arrows) {
|
||||
s.x += s.vx * dt; s.y += s.vy * dt
|
||||
if (s.bomb) {
|
||||
let detonated = false
|
||||
for (const m of g.mobs) {
|
||||
if (m.hp > 0 && Math.hypot(s.x - m.x, s.y - m.y) < 65) {
|
||||
m.hp = 0
|
||||
detonated = true
|
||||
}
|
||||
}
|
||||
if (Math.hypot(s.x - innerWidth / 2, s.y - innerHeight * .29) < 115) {
|
||||
g.bossHp -= 14
|
||||
detonated = true
|
||||
}
|
||||
if (detonated) s.x = -999
|
||||
} else {
|
||||
let hit = false
|
||||
for (const m of g.mobs) {
|
||||
if (m.hp > 0 && Math.hypot(s.x - m.x, s.y - m.y) < 28) {
|
||||
m.hp = 0
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!hit && Math.hypot(s.x - innerWidth / 2, s.y - innerHeight * .29) < 90) {
|
||||
g.bossHp -= 2
|
||||
hit = true
|
||||
}
|
||||
if (hit) s.x = -999
|
||||
}
|
||||
}
|
||||
g.arrows = g.arrows.filter(s => s.x > -100 && s.x < innerWidth + 100 && s.y > -100 && s.y < innerHeight + 100)
|
||||
// Count defeated minions before removing them; some kills drop bombs.
|
||||
const dead = g.mobs.filter(m => m.hp <= 0)
|
||||
if (dead.length) {
|
||||
g.kills += dead.length
|
||||
for (let i = 0; i < dead.length; i++) {
|
||||
if (Math.random() < .38) g.bombs++
|
||||
}
|
||||
}
|
||||
g.mobs = g.mobs.filter(m => m.hp > 0)
|
||||
|
||||
if (g.bossHp <= 0) g.phase = 'victory'
|
||||
else if (g.hp <= 0) g.phase = 'defeat'
|
||||
if (g.kills >= g.waves * 5) g.waves = Math.min(8, Math.floor(g.kills / 5) + 1)
|
||||
}
|
||||
setView({ ...g, mobs: [...g.mobs], enemyShots: [...g.enemyShots], arrows: [...g.arrows] })
|
||||
if (g.phase !== 'fight') {
|
||||
window.clearInterval(frame)
|
||||
endTimer = window.setTimeout(() => {
|
||||
if (g.phase === 'defeat') window.location.reload()
|
||||
}, 6500)
|
||||
}
|
||||
}
|
||||
frame = window.setInterval(tick, 50) as unknown as number
|
||||
return () => { window.clearInterval(frame); window.clearTimeout(endTimer) }
|
||||
}, [active])
|
||||
|
||||
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
mouse.current = { x: e.clientX, y: e.clientY }
|
||||
aim.current = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
const fire = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (view.phase !== 'fight') return
|
||||
const g = game.current
|
||||
const sx = g.player, sy = innerHeight * .78
|
||||
const dx = e.clientX - sx, dy = e.clientY - sy, d = Math.hypot(dx, dy) || 1
|
||||
g.arrows.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 620, vy: dy / d * 620 })
|
||||
}
|
||||
const bomb = () => {
|
||||
const g = game.current
|
||||
if (!active || g.phase !== 'fight' || g.bombs <= 0) return
|
||||
g.bombs--
|
||||
const sx = g.player, sy = innerHeight * .78
|
||||
const dx = aim.current.x - sx, dy = aim.current.y - sy, d = Math.hypot(dx, dy) || 1
|
||||
g.arrows.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 350, vy: dy / d * 350, bomb: true })
|
||||
setView({ ...g, arrows: [...g.arrows] })
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const key = (e: KeyboardEvent) => { if (e.code === 'Space') { e.preventDefault(); bomb() } }
|
||||
window.addEventListener('keydown', key)
|
||||
return () => window.removeEventListener('keydown', key)
|
||||
}, [active])
|
||||
|
||||
if (!active) return null
|
||||
const ended = view.phase !== 'fight'
|
||||
return <div className="rf-boss" onMouseMove={onMove} onClick={fire} onContextMenu={e => e.preventDefault()}>
|
||||
<style>{css}</style>
|
||||
<div className="rf-boss-hud">
|
||||
<div style={{ flex: 1 }}><div className="rf-boss-label">YOUR HEALTH {Math.max(0, Math.ceil(view.hp))}/100</div><div className="rf-boss-track rf-boss-player"><div className="rf-boss-fill" style={{ width: `${Math.max(0, view.hp)}%` }} /></div></div>
|
||||
<div style={{ flex: 1 }}><div className="rf-boss-label">PUMPLE {Math.max(0, Math.ceil(view.bossHp))}%</div><div className="rf-boss-track"><div className="rf-boss-fill" style={{ width: `${Math.max(0, view.bossHp)}%` }} /></div></div>
|
||||
</div>
|
||||
<div className="rf-boss-monster">
|
||||
<img src={new URL('./pumple-frames/frame-04.png', import.meta.url).href} alt="Pumple, the pumpkin king" draggable={false} />
|
||||
</div>
|
||||
{view.mobs.map(m => <div key={m.id} className="rf-boss-minion" style={{ left: m.x, top: m.y }}>
|
||||
<img src={new URL('./Pumple_monster_walk.gif', import.meta.url).href} alt="" draggable={false} />
|
||||
</div>)}
|
||||
{view.enemyShots.map(s => <div key={s.id} className="rf-boss-shot" style={{ left: s.x, top: s.y }}>🔥</div>)}
|
||||
{view.arrows.map(s => <div key={s.id} className="rf-boss-shot" style={{ left: s.x, top: s.y }}>{s.bomb ? '💣' : '➶'}</div>)}
|
||||
<div className="rf-boss-player-char" style={{ left: view.player }}>🛡️</div>
|
||||
<div className="rf-boss-hint">MOVE YOUR MOUSE TO DODGE · CLICK TO SHOOT ARROWS · SPACE: PUMPKIN BOMB ({view.bombs}) · WAVE {view.waves}</div>
|
||||
<div className={`rf-boss-vines ${view.phase === 'victory' ? 'rf-boss-vines-retreat' : ''}`}>
|
||||
<svg viewBox="0 0 1200 800" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path className="root" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760"/>
|
||||
<path className="vine-main" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760 M-20 300 Q100 250 160 330 T250 390 M1220 450 Q1100 390 1050 460 T960 510"/>
|
||||
<path className="vine-edge" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760 M-20 300 Q100 250 160 330 T250 390 M1220 450 Q1100 390 1050 460 T960 510"/>
|
||||
<path className="vine-glow" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760"/>
|
||||
<g className="thorn">
|
||||
<path d="M54 180 l-35 -18 27 43z M94 305 l-36 -8 30 31z M81 500 l-33 -18 23 39z M1140 200 l34 -24 -24 48z M1100 360 l37 -12 -29 36z M1134 545 l36 -19 -23 43z M190 760 l-5 -40 27 31z M1010 760 l6 -40 -28 31z M180 345 l-25 -28 7 40z M1035 455 l24 -28 -7 40z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
{ended && <><div className="rf-boss-end"><div className="rf-boss-panel">
|
||||
<h1 className="rf-boss-title">{view.phase === 'victory' ? 'PUMPLE DEFEATED!' : 'RECFLARE HAS FALLEN'}</h1>
|
||||
<p>{view.phase === 'victory' ? 'The pumpkin king crumbles into embers. The vines retreat and RecFlare is saved!' : 'The vines have consumed the battlefield. Pumple claims RecFlare...'}</p>
|
||||
{view.phase === 'victory' && <p>Waves reached: {view.waves} · Minions defeated: {view.kills} · Bombs remaining: {view.bombs}</p>}
|
||||
{view.phase === 'victory' && <button className="rf-boss-button" onClick={e => { e.stopPropagation(); setActive(false); started.current = false }}>RETURN TO RECFLARE</button>}
|
||||
{view.phase === 'defeat' && <p>The site will reset after the vines take over...</p>}
|
||||
</div></div></>}
|
||||
</div>
|
||||
}
|
||||
|
After Width: | Height: | Size: 82 KiB |
@@ -14,16 +14,29 @@ import { call, isAdmin, useAction } from './api'
|
||||
|
||||
const PAGE = '/settings/recroomstudio'
|
||||
|
||||
type AccessKind = keyof typeof accessLabels
|
||||
const accessLabels = {
|
||||
studio: 'Studio uploads',
|
||||
'avatar-items': 'Avatar Studio items',
|
||||
'avatar-costumes': 'Avatar Studio costumes',
|
||||
'avatar-badges': 'Avatar Studio badges',
|
||||
'avatar-consumables': 'Avatar Studio consumables',
|
||||
screenshare: 'Screen sharing',
|
||||
}
|
||||
|
||||
interface AccessRow {
|
||||
accountId: number
|
||||
username: string | null
|
||||
displayName: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function StudioAccessPage({
|
||||
function AccessPage({
|
||||
kind,
|
||||
account,
|
||||
navigate,
|
||||
}: {
|
||||
kind: AccessKind
|
||||
/** undefined while the session is still being checked, null when signed out. */
|
||||
account: { accountId: number; username: string } | null | undefined
|
||||
navigate: (to: string) => void
|
||||
@@ -39,15 +52,17 @@ export function StudioAccessPage({
|
||||
const add = useAction()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const me = await call<{ granted: boolean }>('/api/studio-access', { authed: true })
|
||||
setGranted(me.granted === true)
|
||||
const me = await call<Record<AccessKind | 'granted', boolean>>('/api/studio-access', {
|
||||
authed: true,
|
||||
})
|
||||
setGranted((kind === 'studio' ? me.granted : me[kind]) === true)
|
||||
if (isAdmin()) {
|
||||
const page = await call<{ accounts: AccessRow[] }>('/api/staff/studio-access', {
|
||||
const page = await call<{ accounts: AccessRow[] }>(`/api/staff/studio-access?kind=${kind}`, {
|
||||
authed: true,
|
||||
})
|
||||
setRows(page.accounts ?? [])
|
||||
}
|
||||
}, [])
|
||||
}, [kind])
|
||||
|
||||
const accountId = account?.accountId
|
||||
useEffect(() => {
|
||||
@@ -80,13 +95,13 @@ export function StudioAccessPage({
|
||||
void add.run(async () => {
|
||||
const result = await call<{ username: string; alreadyGranted: boolean }>(
|
||||
'/api/staff/studio-access',
|
||||
{ method: 'POST', authed: true, json: { username: username.trim() } }
|
||||
{ method: 'POST', authed: true, json: { username: username.trim(), kind } }
|
||||
)
|
||||
setUsername('')
|
||||
await reload()
|
||||
return result.alreadyGranted
|
||||
? `@${result.username} is already on the list.`
|
||||
: `@${result.username} can upload the next time they sign in to Studio.`
|
||||
? `@${result.username} already has ${accessLabels[kind]} access.`
|
||||
: `@${result.username} now has ${accessLabels[kind]} access.`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,7 +110,14 @@ export function StudioAccessPage({
|
||||
setRemoveError('')
|
||||
void (async () => {
|
||||
try {
|
||||
await call(`/api/staff/studio-access/${row.accountId}`, { method: 'DELETE', authed: true })
|
||||
await call(
|
||||
row.enabled
|
||||
? `/api/staff/studio-access/${row.accountId}?kind=${kind}`
|
||||
: '/api/staff/studio-access',
|
||||
row.enabled
|
||||
? { method: 'DELETE', authed: true }
|
||||
: { method: 'POST', authed: true, json: { accountId: row.accountId, kind } }
|
||||
)
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setRemoveError(err instanceof Error ? err.message : String(err))
|
||||
@@ -108,13 +130,16 @@ export function StudioAccessPage({
|
||||
const signInHref = `/login?next=${encodeURIComponent(PAGE)}`
|
||||
|
||||
return (
|
||||
<main className={staff ? 'shell wide' : 'shell'}>
|
||||
<h1>RecFlare Studio access</h1>
|
||||
<section>
|
||||
<h2>{accessLabels[kind]}</h2>
|
||||
<section className="card">
|
||||
<p className="muted">
|
||||
RecFlare Studio signs in with any account. Uploading is limited to accounts on this list.
|
||||
Access is picked up the next time you sign in to Studio, or when that session refreshes. A
|
||||
session you already have keeps its old access until then, and a session lasts a day.
|
||||
{kind === 'studio'
|
||||
? 'Manage permission to upload from RecFlare Studio.'
|
||||
: kind.startsWith('avatar-')
|
||||
? 'Grant this Avatar Studio feature independently. Studio upload access is also required. Roomie equipment still requires developer access.'
|
||||
: 'Screen sharing is disabled by default. Staff can enable it for individual users.'}{' '}
|
||||
Sign in again or refresh the session to pick up permission changes.
|
||||
</p>
|
||||
{account === undefined ? (
|
||||
<p className="muted">Loading…</p>
|
||||
@@ -130,7 +155,7 @@ export function StudioAccessPage({
|
||||
>
|
||||
Sign in
|
||||
</a>{' '}
|
||||
<span className="muted">to see whether this account can upload.</span>
|
||||
<span className="muted">to see the access for this account.</span>
|
||||
</p>
|
||||
) : loading ? (
|
||||
<p className="muted">Loading…</p>
|
||||
@@ -138,22 +163,23 @@ export function StudioAccessPage({
|
||||
<p className="error">{loadError}</p>
|
||||
) : granted ? (
|
||||
<p>
|
||||
<strong>{account.username}</strong> can upload in RecFlare Studio. If the editor still
|
||||
<strong>{account.username}</strong> has {accessLabels[kind]} access. If the editor still
|
||||
says otherwise, sign out of it and sign in again.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<strong>{account.username}</strong> cannot upload in RecFlare Studio yet. A moderator
|
||||
can add this account to the list.
|
||||
<strong>{account.username}</strong> does not have {accessLabels[kind]} access. A
|
||||
moderator can add this account to the list.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{staff && account && (
|
||||
<section className="card">
|
||||
<h2>Whitelist</h2>
|
||||
<h3>Whitelist</h3>
|
||||
<p className="muted">
|
||||
Adding a player puts the betastudio role on their next Studio sign-in. Developers are
|
||||
not on this list unless someone adds them.
|
||||
{kind === 'screenshare'
|
||||
? 'Only users on this list can share their screen.'
|
||||
: 'Add a username to grant access, or remove a player to revoke it.'}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
@@ -172,7 +198,7 @@ export function StudioAccessPage({
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={add.pending || username.trim() === ''}>
|
||||
Add
|
||||
{kind === 'screenshare' ? 'Enable' : 'Add'}
|
||||
</button>
|
||||
</form>
|
||||
{add.error && <p className="error">{add.error}</p>}
|
||||
@@ -180,7 +206,7 @@ export function StudioAccessPage({
|
||||
{loading ? (
|
||||
<p className="muted">Loading…</p>
|
||||
) : loadError ? null : rows.length === 0 ? (
|
||||
<p className="muted">Nobody is on the list.</p>
|
||||
<p className="muted">No users on this list.</p>
|
||||
) : (
|
||||
<ul className="studio-access-list">
|
||||
{rows.map((row) => (
|
||||
@@ -192,13 +218,12 @@ export function StudioAccessPage({
|
||||
{row.displayName && row.displayName !== row.username ? (
|
||||
<span className="muted"> · {row.displayName}</span>
|
||||
) : null}
|
||||
{kind === 'screenshare' && (
|
||||
<span className="muted"> - {row.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={removing === row.accountId}
|
||||
onClick={() => remove(row)}
|
||||
>
|
||||
Remove
|
||||
<button type="button" disabled={removing !== null} onClick={() => remove(row)}>
|
||||
{kind === 'screenshare' ? (row.enabled ? 'Disable' : 'Enable') : 'Remove'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -207,6 +232,20 @@ export function StudioAccessPage({
|
||||
{removeError && <p className="error">{removeError}</p>}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudioAccessPage(props: {
|
||||
account: { accountId: number; username: string } | null | undefined
|
||||
navigate: (to: string) => void
|
||||
}) {
|
||||
return (
|
||||
<main className="shell wide">
|
||||
<h1>RecFlare Studio access</h1>
|
||||
{(Object.keys(accessLabels) as AccessKind[]).map((kind) => (
|
||||
<AccessPage key={kind} kind={kind} {...props} />
|
||||
))}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,23 +219,32 @@ export function useAction() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `@username` to the account id the workers address a player by.
|
||||
*
|
||||
* `accounts` serves no exact-name lookup, so this goes through the PREFIX search and
|
||||
* keeps only an exact (case-insensitive) hit: a prefix match is a different player, and
|
||||
* acting on whoever happened to sort first would be worse than refusing. The exact name
|
||||
* always sorts first among its own prefixes, so it's inside the search limit whenever it
|
||||
* exists.
|
||||
* The public account behind an exact `@username`, or null when nobody has the name.
|
||||
* `accounts`' `GET /account/username/:name` is an indexed equality lookup — NOT the
|
||||
* prefix search, which this once went through and filtered client-side; one profile view
|
||||
* then cost a 20-row index walk, and the search bar's typeahead already keeps that query
|
||||
* busy enough. A 404 is the one non-ok answer that means "no such player"; anything else
|
||||
* is a real failure and throws.
|
||||
*/
|
||||
export async function accountByUsername(input: string): Promise<PublicAccount | null> {
|
||||
const name = input.trim().replace(/^@/, '')
|
||||
if (name === '') return null
|
||||
const res = await fetch(`${where().accounts}/account/username/${encodeURIComponent(name)}`)
|
||||
if (res.status === 404) return null
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (!res.ok) throw new Error(errorMessage(data, res.status))
|
||||
return data as unknown as PublicAccount
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `@username` to the account id the workers address a player by. An exact
|
||||
* match only: a prefix match is a different player, and acting on whoever happened to
|
||||
* sort first would be worse than refusing.
|
||||
*/
|
||||
export async function accountIdForUsername(input: string): Promise<number> {
|
||||
const name = input.trim().replace(/^@/, '')
|
||||
if (name === '') throw new Error('Enter a username.')
|
||||
const matches = await call<Array<{ accountId?: number; username?: string }>>(
|
||||
`${where().accounts}/account/search?name=${encodeURIComponent(name)}`
|
||||
)
|
||||
const found = Array.isArray(matches)
|
||||
? matches.find((m) => m.username?.toLowerCase() === name.toLowerCase())
|
||||
: undefined
|
||||
const found = await accountByUsername(name)
|
||||
if (typeof found?.accountId !== 'number') throw new Error(`There's no player called @${name}.`)
|
||||
return found.accountId
|
||||
}
|
||||
@@ -245,6 +254,9 @@ export interface PublicAccount {
|
||||
accountId: number
|
||||
username: string
|
||||
displayName: string
|
||||
profileImage: string
|
||||
bannerImage: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -135,8 +135,8 @@ body {
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
/* A link is one or two words; with the search bar in the row it has to give up space
|
||||
by wrapping the ROW (see the nav rule under player search), not by breaking mid-link. */
|
||||
/* A link is one or two words; short of room the ROW wraps (see the nav rule under
|
||||
site search), never a link mid-word. */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -447,6 +447,33 @@ body {
|
||||
border-color: #4752c4;
|
||||
}
|
||||
|
||||
/* The way back out of the Discord link, at the foot of the benefits card: set apart by a
|
||||
rule so it doesn't read as part of the claim, and the button in the error colour so it
|
||||
reads as the undo it is. The hint beside it says what has to be set first. */
|
||||
.unlink {
|
||||
margin-top: 22px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.unlink h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.linkish.danger {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.linkish.danger:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.linkish.danger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Secondary action: same shape, outlined instead of filled. */
|
||||
.cta.ghost {
|
||||
background: transparent;
|
||||
@@ -1770,50 +1797,101 @@ button[type='submit']:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Player search ------------------------------------------------------ */
|
||||
/* ---- Site search ------------------------------------------------------- */
|
||||
|
||||
.player-search {
|
||||
/* The panel hangs off the NAV, not the button: the nav can wrap on a phone, which moves
|
||||
the button, but the nav's right edge stays put — so the panel always opens flush right
|
||||
and never runs off the screen. */
|
||||
.nav {
|
||||
position: relative;
|
||||
width: min(280px, 100%);
|
||||
/* Wraps only when the row doesn't fit (a staff account's extra links on a phone). */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.player-search input {
|
||||
margin: 0;
|
||||
height: 40px;
|
||||
padding: 8px 12px;
|
||||
.site-search-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
border-color: var(--line);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.player-search input:focus {
|
||||
.site-search-toggle:hover,
|
||||
.site-search-toggle.active {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-hi);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.player-search-bubble {
|
||||
.site-search-panel {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 6px;
|
||||
top: calc(100% - 8px);
|
||||
right: 20px;
|
||||
width: min(380px, calc(100% - 40px));
|
||||
max-height: min(70vh, 560px);
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgb(0 0 0 / 28%);
|
||||
}
|
||||
|
||||
.player-search-result {
|
||||
.site-search-panel input {
|
||||
margin: 0;
|
||||
height: 42px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-hi);
|
||||
border-color: var(--line);
|
||||
/* 16px keeps iOS from zooming the page when the box takes focus. */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.site-search-panel input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.site-search-group {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.site-search-heading,
|
||||
.site-search-empty {
|
||||
margin: 6px 8px 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.site-search-empty {
|
||||
margin: 12px 8px 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.site-search-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
@@ -1825,22 +1903,35 @@ button[type='submit']:disabled {
|
||||
font-family: var(--body);
|
||||
}
|
||||
|
||||
.player-search-result:hover,
|
||||
.player-search-result:focus-visible {
|
||||
.site-search-result > span {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.site-search-result:hover,
|
||||
.site-search-result:focus-visible {
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
.player-search-avatar {
|
||||
.site-search-avatar,
|
||||
.site-search-thumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
.player-search-name {
|
||||
.site-search-avatar {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.site-search-thumb {
|
||||
width: 54px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.site-search-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
@@ -1849,7 +1940,7 @@ button[type='submit']:disabled {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-search-handle {
|
||||
.site-search-sub {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
@@ -1860,11 +1951,32 @@ button[type='submit']:disabled {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The search bar is a third thing in a row built for two, so the nav wraps when it runs
|
||||
out of room rather than overflowing. No breakpoint: wrapping only happens when the row
|
||||
doesn't fit, so wide screens are untouched. */
|
||||
.nav {
|
||||
flex-wrap: wrap;
|
||||
/* The account page's banner upload, in the profile banner's shape so what's uploaded
|
||||
looks as it will across the top of the profile. */
|
||||
.banner-upload {
|
||||
display: block;
|
||||
margin: 12px 0 0;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.banner-preview {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
object-fit: cover;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 80% 20%,
|
||||
color-mix(in srgb, var(--accent) 24%, transparent),
|
||||
transparent 45%
|
||||
),
|
||||
linear-gradient(135deg, var(--surface-hi), var(--surface));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.banner-preview {
|
||||
height: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Public player profiles -------------------------------------------- */
|
||||
@@ -2074,3 +2186,19 @@ button[type='submit']:disabled {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* The login card's second door, under the password form: an "or" and the Discord button,
|
||||
separated from the form by a rule so the two aren't read as one. */
|
||||
.alt-signin {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.alt-signin > .muted:first-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -144,6 +144,14 @@ export type Env = SharedHonoEnv & {
|
||||
* all closes the claim, exactly as an unset one does.
|
||||
*/
|
||||
DISCORD_BENEFITS_ROLE_IDS?: string
|
||||
/**
|
||||
* Discord role ids whose holders have Rec Room Studio upload access without the
|
||||
* account's `hasStudio` flag (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`; the var
|
||||
* `auth` stamps `betastudio` from). `/api/studio-access` answers `granted` through the
|
||||
* same `hasStudioAccess`, so the Studio settings page agrees with the token. The staff
|
||||
* list and its add/remove still manage the flag alone.
|
||||
*/
|
||||
STUDIO_ROLLOUT?: string
|
||||
/**
|
||||
* The bot user's token for the scheduled role refresh (discord-roles.ts). Optional in
|
||||
* effect: the binding must exist for the deploy, but a placeholder value just leaves the
|
||||
|
||||
@@ -55,6 +55,15 @@ export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize'
|
||||
*/
|
||||
export const SCOPES = 'identify guilds.members.read'
|
||||
|
||||
/**
|
||||
* What "Sign in with Discord" asks for: the user's id and nothing else. The sign-in has no
|
||||
* question to ask about the guild — it only needs to know WHO, so the account linked to that
|
||||
* Discord user can be signed into (or made). Asking for the member read here would put a
|
||||
* permission on the consent screen that the sign-in never uses; the benefits claim is where
|
||||
* roles are read, with its own consent.
|
||||
*/
|
||||
export const LOGIN_SCOPES = 'identify'
|
||||
|
||||
/** Everything the claim needs configured. Resolved per request; see `discordConfig`. */
|
||||
export interface DiscordConfig {
|
||||
/** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */
|
||||
@@ -170,6 +179,15 @@ export async function readSecret(secret: SecretsStoreSecret, name: string): Prom
|
||||
*/
|
||||
export const redirectUri = (request: Request): string => new URL('/claim', request.url).toString()
|
||||
|
||||
/**
|
||||
* The sign-in's return leg — its own registered redirect, so Discord sends a sign-in back to
|
||||
* a page that knows it is one. `/claim` can't double for it: that page needs a session
|
||||
* before it does anything, and a sign-in is how the session gets made. Same derivation,
|
||||
* same byte-for-byte rule, same reason the SPA doesn't get to supply it. See App.tsx.
|
||||
*/
|
||||
export const loginRedirectUri = (request: Request): string =>
|
||||
new URL('/login/discord', request.url).toString()
|
||||
|
||||
/**
|
||||
* Swap an authorization code for an access token. Returns null on any refusal — a code
|
||||
* that was already spent, expired (they live ~1 minute), issued to another app, or paired
|
||||
@@ -278,6 +296,39 @@ export async function fetchGuildMembership(
|
||||
}
|
||||
}
|
||||
|
||||
/** Who a token belongs to — the `identify` scope's one answer. */
|
||||
export interface DiscordUser {
|
||||
/** The Discord user's id (a snowflake, kept as a string — it exceeds 2^53). */
|
||||
id: string
|
||||
/** Their Discord username, for the confirmation line. Display only, never stored. */
|
||||
username: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The token owner's own Discord user, or null when Discord won't say or can't be reached.
|
||||
* The sign-in's one read: it needs the id, and `identify` grants exactly `GET /users/@me`.
|
||||
*/
|
||||
export async function fetchUser(accessToken: string): Promise<DiscordUser | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/users/@me`, {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
logger.info('discord did not return the token owner', { status: res.status })
|
||||
return null
|
||||
}
|
||||
const user = (await res.json()) as { id?: unknown; username?: unknown }
|
||||
if (typeof user.id !== 'string' || user.id === '') {
|
||||
logger.error('discord returned a user with no id')
|
||||
return null
|
||||
}
|
||||
return { id: user.id, username: typeof user.username === 'string' ? user.username : '' }
|
||||
} catch (err) {
|
||||
logger.error('could not reach discord to read the token owner', { error: String(err) })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the access token back to Discord once the roles have been read.
|
||||
*
|
||||
|
||||
@@ -14,7 +14,7 @@ export const DISCORD_INVITE = 'https://join.recflare.net'
|
||||
export const DOWNLOAD_URL = 'https://github.com/recflare/client/releases'
|
||||
|
||||
/** The stage's "Download for Quest" button: the build's listing on the Meta store. */
|
||||
export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/7rvx0Cv9p'
|
||||
export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/6tSy6tIxY'
|
||||
|
||||
/** The public source repo, linked from the homepage and footer. */
|
||||
export const SOURCE_REPO = 'https://github.com/orgs/recflare/repositories'
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
clearDormSaveBlob,
|
||||
clearPasswordHash,
|
||||
createGift,
|
||||
deleteRoomInstancesByRoom,
|
||||
getAccount,
|
||||
getImageById,
|
||||
getOnlinePlayerIds,
|
||||
@@ -1016,6 +1017,28 @@ export async function giftRoomTokensHandler(c: Context<App>) {
|
||||
return c.json({ roomId, amount, paid, skipped })
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every instance of a room from `room_instance`, from the room's own page. For a
|
||||
* room whose sessions have gone bad: the next player to matchmake in gets a fresh one.
|
||||
* Clearing a room with no instances succeeds and deletes nothing.
|
||||
*/
|
||||
export async function clearRoomInstancesHandler(c: Context<App>) {
|
||||
const roomId = Number(c.req.param('roomId'))
|
||||
if (!Number.isInteger(roomId) || roomId <= 0) {
|
||||
return c.json({ error: 'A numeric room id is required' }, 400)
|
||||
}
|
||||
|
||||
const deleted = await deleteRoomInstancesByRoom(c.env.DB, roomId)
|
||||
|
||||
await recordPlayerAudit(c, 'clear_room_instances', { roomId, deleted })
|
||||
logger.info('staff cleared room instances', {
|
||||
moderatorId: staffId(c),
|
||||
roomId,
|
||||
deletedCount: deleted.length,
|
||||
})
|
||||
return c.json({ roomId, deleted })
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tokens to EVERYONE ONLINE right now — the server-wide drop behind the account page's
|
||||
* "Token drop" tab, beside the coach broadcast and the maintenance notice.
|
||||
@@ -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
|
||||
* 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.
|
||||
*/
|
||||
export async function clearPasswordHandler(c: Context<App>) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
AVATAR_STUDIO_ACCESS,
|
||||
avatarStudioFlags,
|
||||
getAccount,
|
||||
getAccountByUsername,
|
||||
listStudioAccounts,
|
||||
@@ -8,6 +10,8 @@ import {
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import { hasStudioAccess } from '../../auth/src/platform-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
@@ -20,25 +24,55 @@ import type { App } from './context'
|
||||
* the signed-in player. Staff add and remove people through the routes under
|
||||
* `/api/staff/studio-access`, behind the same `requireStaff` gate as moderation.
|
||||
* The grant is the account's `hasStudio` flag; `auth` stamps `betastudio` from it
|
||||
* on the next Studio sign-in or refresh. Who granted what is on `audit_log`.
|
||||
* on the next Studio sign-in or refresh. Who granted what is on `audit_log`. A Discord
|
||||
* role in the `STUDIO_ROLLOUT` var grants the same access without the flag
|
||||
* (`hasStudioAccess`); the status below reports it, the staff list does not — that list
|
||||
* is the accounts staff added by hand.
|
||||
*/
|
||||
|
||||
/** The signed-in player's own flag, and nothing else. 401 with no session. */
|
||||
/** Whether the signed-in player can upload — flag or rollout role. 401 with no session. */
|
||||
export async function studioAccessStatusHandler(c: Context<App>) {
|
||||
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const account = await getAccount(c.env.DB, accountId)
|
||||
return c.json({ granted: account?.hasStudio === true })
|
||||
const flags = await avatarStudioFlags(c.env.DB, accountId)
|
||||
return c.json({
|
||||
...Object.fromEntries(
|
||||
Object.entries(AVATAR_STUDIO_ACCESS).map(([kind, mask]) => [kind, (flags & mask) === mask])
|
||||
),
|
||||
granted: await hasStudioAccess(c.env.DB, account, c.env.STUDIO_ROLLOUT),
|
||||
avatar: (flags & 7) !== 0,
|
||||
screenshare: account?.hasScreenshare === true,
|
||||
})
|
||||
}
|
||||
|
||||
type AccessKind = 'studio' | 'avatar' | 'screenshare' | keyof typeof AVATAR_STUDIO_ACCESS
|
||||
function accessKind(value: unknown): AccessKind | null {
|
||||
if (value === undefined) return 'studio'
|
||||
if (value === 'studio' || value === 'avatar' || value === 'screenshare') return value
|
||||
return typeof value === 'string' && Object.hasOwn(AVATAR_STUDIO_ACCESS, value)
|
||||
? (value as AccessKind)
|
||||
: null
|
||||
}
|
||||
function avatarMask(kind: AccessKind): number {
|
||||
return kind === 'avatar'
|
||||
? 7
|
||||
: Object.hasOwn(AVATAR_STUDIO_ACCESS, kind)
|
||||
? AVATAR_STUDIO_ACCESS[kind as keyof typeof AVATAR_STUDIO_ACCESS]
|
||||
: 0
|
||||
}
|
||||
|
||||
/** Every account with the flag set. */
|
||||
export async function listStudioAccessHandler(c: Context<App>) {
|
||||
const accounts = await listStudioAccounts(c.env.DB)
|
||||
const kind = accessKind(c.req.query('kind'))
|
||||
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
|
||||
const accounts = await listStudioAccounts(c.env.DB, kind)
|
||||
return c.json({
|
||||
accounts: accounts.map((account) => ({
|
||||
accountId: account.accountId,
|
||||
username: account.username,
|
||||
displayName: account.displayName,
|
||||
enabled: kind !== 'screenshare' || account.hasScreenshare === true,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -53,7 +87,9 @@ export async function grantStudioAccessHandler(c: Context<App>) {
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return c.json({ error: 'Expected a JSON body' }, 400)
|
||||
}
|
||||
const posted = body as { username?: unknown; accountId?: unknown }
|
||||
const posted = body as { username?: unknown; accountId?: unknown; kind?: unknown }
|
||||
const kind = accessKind(posted.kind)
|
||||
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
|
||||
const username =
|
||||
typeof posted.username === 'string' ? posted.username.trim().replace(/^@/, '') : ''
|
||||
const accountId = postedAccountId(posted.accountId)
|
||||
@@ -72,9 +108,15 @@ export async function grantStudioAccessHandler(c: Context<App>) {
|
||||
return c.json({ error: 'Name a player by username or account id' }, 400)
|
||||
}
|
||||
|
||||
const alreadyGranted = account.hasStudio === true
|
||||
if (!alreadyGranted) await updateAccount(c.env.DB, account.accountId, { hasStudio: true })
|
||||
await recordAudit(c, 'grant_studio_access', {
|
||||
const alreadyGranted =
|
||||
avatarMask(kind) !== 0
|
||||
? ((await avatarStudioFlags(c.env.DB, account.accountId)) & avatarMask(kind)) ===
|
||||
avatarMask(kind)
|
||||
: kind === 'screenshare'
|
||||
? account.hasScreenshare === true
|
||||
: account.hasStudio === true
|
||||
await setAccess(c, account.accountId, kind, true)
|
||||
await recordAudit(c, `grant_${kind}_access`, {
|
||||
playerId: account.accountId,
|
||||
username: account.username,
|
||||
alreadyGranted,
|
||||
@@ -93,15 +135,22 @@ export async function grantStudioAccessHandler(c: Context<App>) {
|
||||
* account is a 404.
|
||||
*/
|
||||
export async function revokeStudioAccessHandler(c: Context<App>) {
|
||||
const kind = accessKind(c.req.query('kind'))
|
||||
if (!kind) return c.json({ error: 'Unknown access type' }, 400)
|
||||
const accountId = Number(c.req.param('id'))
|
||||
if (!Number.isInteger(accountId) || accountId <= 0) {
|
||||
return c.json({ error: 'A numeric player id is required' }, 400)
|
||||
}
|
||||
const account = await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.json({ error: 'No player has that account id' }, 404)
|
||||
const removed = account.hasStudio === true
|
||||
if (removed) await updateAccount(c.env.DB, accountId, { hasStudio: false })
|
||||
await recordAudit(c, 'revoke_studio_access', {
|
||||
const removed =
|
||||
avatarMask(kind) !== 0
|
||||
? ((await avatarStudioFlags(c.env.DB, accountId)) & avatarMask(kind)) !== 0
|
||||
: kind === 'screenshare'
|
||||
? account.hasScreenshare === true
|
||||
: account.hasStudio === true
|
||||
await setAccess(c, accountId, kind, false)
|
||||
await recordAudit(c, `revoke_${kind}_access`, {
|
||||
playerId: accountId,
|
||||
username: account.username,
|
||||
removed,
|
||||
@@ -109,6 +158,26 @@ export async function revokeStudioAccessHandler(c: Context<App>) {
|
||||
return c.json({ accountId, granted: false, removed })
|
||||
}
|
||||
|
||||
async function setAccess(c: Context<App>, accountId: number, kind: AccessKind, enabled: boolean) {
|
||||
const mask = avatarMask(kind)
|
||||
if (mask !== 0) {
|
||||
const now = new Date().toISOString()
|
||||
await c.env.DB.prepare(
|
||||
`INSERT INTO avatar_studio_access (account_id, flags, created_at, modified_at)
|
||||
VALUES (?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET
|
||||
flags = ${enabled ? 'flags | ?' : 'flags & ~?'}, modified_at = excluded.modified_at`
|
||||
)
|
||||
.bind(accountId, enabled ? mask : 0, now, now, mask)
|
||||
.run()
|
||||
} else {
|
||||
await updateAccount(
|
||||
c.env.DB,
|
||||
accountId,
|
||||
kind === 'screenshare' ? { hasScreenshare: enabled } : { hasStudio: enabled }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A positive integer account id from a JSON field, or null when it isn't one. */
|
||||
function postedAccountId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
updateAccount,
|
||||
} from '@repo/domain/src/accounts-db'
|
||||
import { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-db'
|
||||
import { AVATAR_STUDIO_SCHEMA, avatarStudioFlags } from '@repo/domain/src/avatar-studio'
|
||||
import { PlatformType } from '@repo/domain/src/enums'
|
||||
import { getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain/src/gifts-db'
|
||||
import {
|
||||
@@ -22,7 +23,11 @@ import {
|
||||
setPresence,
|
||||
} from '@repo/domain/src/presence-db'
|
||||
import { getProgression, PROGRESSION_SCHEMA_DDL } from '@repo/domain/src/progression-db'
|
||||
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
|
||||
import {
|
||||
createRoomInstance,
|
||||
getRoomInstancesByRoom,
|
||||
ROOM_INSTANCE_SCHEMA_DDL,
|
||||
} from '@repo/domain/src/room-instance-db'
|
||||
// Banning a player moves them into their own dorm, which is a room (created on demand) with
|
||||
// a subroom — so those tables have to be here, as they are on the shared database.
|
||||
import { getOrCreateDormRoom, ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
|
||||
@@ -44,9 +49,12 @@ import {
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForAccount,
|
||||
getLinksForPlatform,
|
||||
hasRecordedRoles,
|
||||
isPlatformIdentityLinked,
|
||||
linkPlatformIdentity,
|
||||
PLATFORM_SCHEMA_DDL,
|
||||
recordFirstRoles,
|
||||
unlinkPlatform,
|
||||
} from '../../../../auth/src/platform-db'
|
||||
import {
|
||||
BALANCE_SCHEMA_DDL,
|
||||
@@ -68,7 +76,13 @@ import {
|
||||
INVENTORY_CUSTOM_SCHEMA_DDL,
|
||||
ownedCustomAvatarItemIds,
|
||||
} from '../../../../econ/src/inventory-custom-db'
|
||||
import { discordConfig, parseRoleIds, qualifies } from '../../discord'
|
||||
import {
|
||||
discordConfig,
|
||||
loginRedirectUri,
|
||||
parseRoleIds,
|
||||
qualifies,
|
||||
redirectUri,
|
||||
} from '../../discord'
|
||||
import { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles'
|
||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||||
@@ -119,6 +133,7 @@ beforeAll(async () => {
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// `account` likewise: owned by `auth`, read and (for the benefits claim) written here.
|
||||
for (const stmt of ACCOUNT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of AVATAR_STUDIO_SCHEMA) await env.DB.prepare(stmt).run()
|
||||
// And `platform_account`, where a claimed Discord identity is linked.
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// `report` and `warning` are owned (and migrated) by `api`; www serves the staff panel
|
||||
@@ -167,6 +182,8 @@ it('advertises signup and where the other workers live', async () => {
|
||||
// even be started. Note the client id is NOT leaked by a closed config.
|
||||
benefitsEnabled: false,
|
||||
discordAuthorizeUrl: null,
|
||||
// "Sign in with Discord" is on the same switch, and closed with it.
|
||||
discordLoginUrl: null,
|
||||
hosts: {
|
||||
auth: 'https://auth.rec.example.com',
|
||||
accounts: 'https://accounts.rec.example.com',
|
||||
@@ -669,16 +686,220 @@ it('reports where an account stands on benefits', async () => {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ hasPlus: false, linked: false })
|
||||
// No email either: the unlink's one precondition, served alongside so the page can say
|
||||
// so first. Nothing linked, so there is nothing to unlink yet.
|
||||
expect(await res.json()).toEqual({
|
||||
hasPlus: false,
|
||||
linked: false,
|
||||
discordLinked: false,
|
||||
hasEmail: false,
|
||||
})
|
||||
|
||||
// A bare link — what "Sign in with Discord" leaves on an account it just made — is NOT a
|
||||
// claim: no roles were ever recorded, so the page must still offer the claim. It IS a
|
||||
// Discord on the account, though, so `discordLinked` says so: the unlink is offered for
|
||||
// it as much as for a claim's link.
|
||||
await updateAccount(env.DB, 4003, { hasPlus: true })
|
||||
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
|
||||
res = await SELF.fetch('https://example.com/api/benefits/status', {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
// `linked` says THAT a Discord identity is attached, never which one — the id is of no
|
||||
// use to the page, and an account's linked identities have no business on the wire.
|
||||
expect(await res.json()).toEqual({ hasPlus: true, linked: true })
|
||||
expect(await res.json()).toEqual({
|
||||
hasPlus: true,
|
||||
linked: false,
|
||||
discordLinked: true,
|
||||
hasEmail: false,
|
||||
})
|
||||
|
||||
// Claimed (roles recorded, even none). `linked` says THAT a Discord identity has claimed,
|
||||
// never which one — the id is of no use to the page, and an account's linked identities
|
||||
// have no business on the wire.
|
||||
await updateAccount(env.DB, 4003, { email: '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
|
||||
@@ -872,6 +1093,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
|
||||
'/api/staff/players/1/username-changes',
|
||||
'/api/staff/players/1/clear-password',
|
||||
'/api/staff/players/1/clear-dorm',
|
||||
'/api/staff/rooms/1/clear-instances',
|
||||
'/api/staff/studio-access',
|
||||
'/api/staff/players/1/grant-plus',
|
||||
]
|
||||
@@ -2257,6 +2479,31 @@ it('clears a player’s dorm by publishing an empty save over the newest one', a
|
||||
])
|
||||
})
|
||||
|
||||
it('clears every instance of a room, for any staff member', async () => {
|
||||
const make = (roomId: number, photonRoomId: string) =>
|
||||
createRoomInstance(env.DB, { ownerAccountId: 8110, roomId, photonRoomId })
|
||||
const a = await make(7740, 'clear-a')
|
||||
const b = await make(7740, 'clear-b')
|
||||
const other = await make(7741, 'clear-other')
|
||||
|
||||
// A moderator, not only a developer, may clear a room.
|
||||
const res = await staffPost('/api/staff/rooms/7740/clear-instances', 8101, {})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { roomId: number; deleted: number[] }
|
||||
expect(body.roomId).toBe(7740)
|
||||
expect(body.deleted.toSorted()).toEqual([a.roomInstanceId, b.roomInstanceId].toSorted())
|
||||
expect(await getRoomInstancesByRoom(env.DB, 7740)).toEqual([])
|
||||
// Another room's instances are untouched.
|
||||
expect((await getRoomInstancesByRoom(env.DB, 7741)).map((i) => i.roomInstanceId)).toEqual([
|
||||
other.roomInstanceId,
|
||||
])
|
||||
|
||||
// Clearing an empty room succeeds and deletes nothing.
|
||||
const again = await staffPost('/api/staff/rooms/7740/clear-instances', 8101, {})
|
||||
expect(await again.json()).toEqual({ roomId: 7740, deleted: [] })
|
||||
expect((await staffPost('/api/staff/rooms/abc/clear-instances', 8101, {})).status).toBe(400)
|
||||
})
|
||||
|
||||
it('grants a player Plus, developers only', async () => {
|
||||
await updateAccount(env.DB, 8330, { username: 'Plusless' })
|
||||
const hasPlus = async () =>
|
||||
@@ -3022,7 +3269,28 @@ it('tells a signed-in player whether they can upload, and nobody else', async ()
|
||||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ granted: false })
|
||||
expect(await res.json()).toMatchObject({ granted: false, avatar: false, screenshare: false })
|
||||
|
||||
// A Discord role named in STUDIO_ROLLOUT grants the same access without the flag, and the
|
||||
// page reads it from the same helper `auth` stamps `betastudio` from, so the two agree.
|
||||
// The account has to exist: a link alone, with no account behind it, grants nothing.
|
||||
await updateAccount(env.DB, 8601, { username: 'StudioFan' })
|
||||
await linkPlatformIdentity(env.DB, 8601, PlatformType.Discord, '500000000000008601', [
|
||||
'1077000000000000031',
|
||||
])
|
||||
const original = env.STUDIO_ROLLOUT
|
||||
try {
|
||||
env.STUDIO_ROLLOUT = '1077000000000000031'
|
||||
const rolled = await SELF.fetch('https://example.com/api/studio-access', {
|
||||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||||
})
|
||||
expect(await rolled.json()).toMatchObject({ granted: true, avatar: false, screenshare: false })
|
||||
// The flag itself never moved; the staff list is still empty of this account.
|
||||
expect((await getAccount(env.DB, 8601))?.hasStudio).not.toBe(true)
|
||||
} finally {
|
||||
if (original === undefined) delete env.STUDIO_ROLLOUT
|
||||
else env.STUDIO_ROLLOUT = original
|
||||
}
|
||||
})
|
||||
|
||||
it('lets staff add, list, and remove studio upload access', async () => {
|
||||
@@ -3059,13 +3327,13 @@ it('lets staff add, list, and remove studio upload access', async () => {
|
||||
const mine = await SELF.fetch('https://example.com/api/studio-access', {
|
||||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||||
})
|
||||
expect(await mine.json()).toEqual({ granted: true })
|
||||
expect(await mine.json()).toMatchObject({ granted: true, avatar: false, screenshare: false })
|
||||
|
||||
const list = await staffGet('/api/staff/studio-access', 8110)
|
||||
expect(list.status).toBe(200)
|
||||
const body = (await list.json()) as { accounts: Array<{ accountId: number }> }
|
||||
expect(body.accounts).toEqual([
|
||||
{ accountId: 8601, username: 'StudioFan', displayName: expect.any(String) },
|
||||
{ accountId: 8601, username: 'StudioFan', displayName: expect.any(String), enabled: true },
|
||||
])
|
||||
expect((await getAccount(env.DB, 8601))?.hasStudio).toBe(true)
|
||||
|
||||
@@ -3082,7 +3350,7 @@ it('lets staff add, list, and remove studio upload access', async () => {
|
||||
const after = await SELF.fetch('https://example.com/api/studio-access', {
|
||||
headers: { authorization: `Bearer ${await tokenFor(8601, ['gameClient'])}` },
|
||||
})
|
||||
expect(await after.json()).toEqual({ granted: false })
|
||||
expect(await after.json()).toMatchObject({ granted: false, avatar: false, screenshare: false })
|
||||
|
||||
expect(await auditRows('grant_studio_access', 8601)).toEqual([
|
||||
{
|
||||
@@ -3158,3 +3426,136 @@ it('lets staff opt themselves in and out of the sandbox pool, and only themselve
|
||||
[8120, false],
|
||||
])
|
||||
})
|
||||
|
||||
it('manages Avatar Studio flags and screen sharing independently, with staff authorization', async () => {
|
||||
await updateAccount(env.DB, 8611, { username: 'AvatarFan', hasStudio: true })
|
||||
await updateAccount(env.DB, 8612, { username: 'ScreenFan' })
|
||||
const url = '/api/staff/studio-access'
|
||||
for (const kind of ['avatar', 'screenshare']) {
|
||||
const denied = await SELF.fetch(`https://example.com${url}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${await tokenFor(8612)}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ accountId: 8612, kind }),
|
||||
})
|
||||
expect(denied.status).toBe(403)
|
||||
expect((await SELF.fetch(`https://example.com${url}?kind=${kind}`)).status).toBe(401)
|
||||
const deniedDelete = await SELF.fetch(`https://example.com${url}/8611?kind=${kind}`, {
|
||||
method: 'DELETE',
|
||||
headers: { authorization: `Bearer ${await tokenFor(8612)}` },
|
||||
})
|
||||
expect(deniedDelete.status).toBe(403)
|
||||
}
|
||||
expect((await staffPost(url, 8110, { accountId: 8611, kind: 'unknown' })).status).toBe(400)
|
||||
expect((await staffGet(`${url}?kind=unknown`, 8110)).status).toBe(400)
|
||||
expect((await staffDelete(`${url}/8611?kind=unknown`, 8110)).status).toBe(400)
|
||||
expect((await staffPost(url, 8110, { accountId: 99999999, kind: 'avatar' })).status).toBe(404)
|
||||
await env.DB.prepare('INSERT INTO avatar_studio_access VALUES (?, ?, ?, ?)')
|
||||
.bind(8611, 9, 'before', 'before')
|
||||
.run()
|
||||
expect((await staffPost(url, 8110, { username: '@AvatarFan', kind: 'avatar' })).status).toBe(200)
|
||||
expect(await avatarStudioFlags(env.DB, 8611)).toBe(15)
|
||||
const repeated = await staffPost(url, 8110, { accountId: 8611, kind: 'avatar' })
|
||||
expect(await repeated.json()).toMatchObject({ alreadyGranted: true })
|
||||
const avatarList = await staffGet(`${url}?kind=avatar`, 8110)
|
||||
expect(await avatarList.json()).toMatchObject({ accounts: [{ accountId: 8611, enabled: true }] })
|
||||
expect((await getAccount(env.DB, 8611))?.hasScreenshare).toBeUndefined()
|
||||
await staffDelete(`${url}/8611?kind=avatar`, 8110)
|
||||
expect(await avatarStudioFlags(env.DB, 8611)).toBe(8)
|
||||
expect((await getAccount(env.DB, 8611))?.hasStudio).toBe(true)
|
||||
expect((await staffPost(url, 8110, { username: 'ScreenFan', kind: 'screenshare' })).status).toBe(
|
||||
200
|
||||
)
|
||||
expect((await getAccount(env.DB, 8612))?.hasScreenshare).toBe(true)
|
||||
const screenList = await staffGet(`${url}?kind=screenshare`, 8110)
|
||||
expect(await screenList.json()).toMatchObject({ accounts: [{ accountId: 8612, enabled: true }] })
|
||||
const status = await SELF.fetch('https://example.com/api/studio-access', {
|
||||
headers: { authorization: `Bearer ${await tokenFor(8612)}` },
|
||||
})
|
||||
expect(await status.json()).toMatchObject({ granted: false, avatar: false, screenshare: true })
|
||||
await staffDelete(`${url}/8612?kind=screenshare`, 8110)
|
||||
expect((await getAccount(env.DB, 8612))?.hasScreenshare).toBe(false)
|
||||
expect(await (await staffGet(`${url}?kind=screenshare`, 8110)).json()).toEqual({ accounts: [] })
|
||||
expect(await auditRows('grant_avatar_access', 8611)).toHaveLength(2)
|
||||
expect(await auditRows('revoke_screenshare_access', 8612)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('grants and revokes each Avatar Studio feature without changing other permissions', async () => {
|
||||
const features = {
|
||||
'avatar-items': 1,
|
||||
'avatar-costumes': 2,
|
||||
'avatar-badges': 4,
|
||||
'avatar-consumables': 8,
|
||||
}
|
||||
const url = '/api/staff/studio-access'
|
||||
await updateAccount(env.DB, 8621, {
|
||||
username: 'FeatureFan',
|
||||
hasStudio: true,
|
||||
hasScreenshare: false,
|
||||
})
|
||||
await env.DB.prepare('INSERT INTO avatar_studio_access VALUES (?, ?, ?, ?)')
|
||||
.bind(8621, 16, 'original', 'original')
|
||||
.run()
|
||||
let expected = 16
|
||||
const status = async () => {
|
||||
const response = await SELF.fetch('https://example.com/api/studio-access', {
|
||||
headers: { authorization: `Bearer ${await tokenFor(8621)}` },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
return response.json() as Promise<Record<string, boolean>>
|
||||
}
|
||||
for (const [kind, mask] of Object.entries(features)) {
|
||||
expect((await status())[kind]).toBe(false)
|
||||
const denied = await SELF.fetch(`https://example.com${url}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${await tokenFor(8621)}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ accountId: 8621, kind }),
|
||||
})
|
||||
expect(denied.status).toBe(403)
|
||||
const deniedDelete = await SELF.fetch(`https://example.com${url}/8621?kind=${kind}`, {
|
||||
method: 'DELETE',
|
||||
headers: { authorization: `Bearer ${await tokenFor(8621)}` },
|
||||
})
|
||||
expect(deniedDelete.status).toBe(403)
|
||||
expect((await staffPost(url, 8110, { username: '@FeatureFan', kind })).status).toBe(200)
|
||||
expected |= mask
|
||||
expect(await avatarStudioFlags(env.DB, 8621)).toBe(expected)
|
||||
const repeated = await staffPost(url, 8110, { accountId: 8621, kind })
|
||||
expect(await repeated.json()).toMatchObject({ alreadyGranted: true })
|
||||
expect((await status())[kind]).toBe(true)
|
||||
const list = await staffGet(`${url}?kind=${kind}`, 8110)
|
||||
expect(await list.json()).toMatchObject({
|
||||
accounts: expect.arrayContaining([
|
||||
expect.objectContaining({ accountId: 8621, enabled: true }),
|
||||
]),
|
||||
})
|
||||
}
|
||||
for (const [kind, mask] of Object.entries(features)) {
|
||||
expect((await staffDelete(`${url}/8621?kind=${kind}`, 8110)).status).toBe(200)
|
||||
expected &= ~mask
|
||||
expect(await avatarStudioFlags(env.DB, 8621)).toBe(expected)
|
||||
expect((await status())[kind]).toBe(false)
|
||||
for (const [otherKind, otherMask] of Object.entries(features)) {
|
||||
expect((await status())[otherKind]).toBe((expected & otherMask) !== 0)
|
||||
}
|
||||
const list = await staffGet(`${url}?kind=${kind}`, 8110)
|
||||
const remaining = (await list.json()) as { accounts: Array<{ accountId: number }> }
|
||||
expect(remaining.accounts.some((a) => a.accountId === 8621)).toBe(false)
|
||||
const repeat = await staffDelete(`${url}/8621?kind=${kind}`, 8110)
|
||||
expect(await repeat.json()).toMatchObject({ removed: false })
|
||||
expect(await auditRows(`grant_${kind}_access`, 8621)).toHaveLength(2)
|
||||
expect(await auditRows(`revoke_${kind}_access`, 8621)).toHaveLength(2)
|
||||
}
|
||||
expect((await getAccount(env.DB, 8621))?.hasStudio).toBe(true)
|
||||
expect((await getAccount(env.DB, 8621))?.hasScreenshare).toBe(false)
|
||||
expect(
|
||||
await env.DB.prepare('SELECT created_at FROM avatar_studio_access WHERE account_id=8621').first(
|
||||
'created_at'
|
||||
)
|
||||
).toBe('original')
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PlatformType } from '@repo/domain/src/enums'
|
||||
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
||||
import { getStatSeries } from '@repo/domain/src/stats-db'
|
||||
import { intVar, logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import { generateIdentityAssertion, validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
|
||||
// Discord identity is stored there as a PlatformType.Discord link — it is an account ↔
|
||||
@@ -17,8 +17,11 @@ import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import {
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForAccount,
|
||||
hasRecordedRoles,
|
||||
isPlatformIdentityLinked,
|
||||
linkPlatformIdentity,
|
||||
recordFirstRoles,
|
||||
unlinkPlatform,
|
||||
} from '../../auth/src/platform-db'
|
||||
// The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is
|
||||
// econ's cron paid on demand: the first time an account links its Discord, the claim hands
|
||||
@@ -31,6 +34,9 @@ import {
|
||||
discordConfig,
|
||||
exchangeCode,
|
||||
fetchGuildMembership,
|
||||
fetchUser,
|
||||
LOGIN_SCOPES,
|
||||
loginRedirectUri,
|
||||
qualifies,
|
||||
redirectUri,
|
||||
revokeToken,
|
||||
@@ -45,6 +51,7 @@ import {
|
||||
bansInForceHandler,
|
||||
clearDormHandler,
|
||||
clearPasswordHandler,
|
||||
clearRoomInstancesHandler,
|
||||
createReportHandler,
|
||||
getReportHandler,
|
||||
giftItemHandler,
|
||||
@@ -132,14 +139,27 @@ const claimant = async (c: Context<App>): Promise<number | null> =>
|
||||
* (see App.tsx). Everything else about the request is fixed by the server.
|
||||
*/
|
||||
function authorizeUrl(request: Request, clientId: string): string {
|
||||
return consentUrl(clientId, SCOPES, redirectUri(request))
|
||||
}
|
||||
|
||||
/**
|
||||
* The consent URL for "Sign in with Discord": the same app, the narrower `identify` scope,
|
||||
* and the sign-in's own return leg (`/login/discord`). Served beside the claim's URL so the
|
||||
* SPA never assembles either.
|
||||
*/
|
||||
function loginAuthorizeUrl(request: Request, clientId: string): string {
|
||||
return consentUrl(clientId, LOGIN_SCOPES, loginRedirectUri(request))
|
||||
}
|
||||
|
||||
function consentUrl(clientId: string, scope: string, redirect: string): string {
|
||||
const url = new URL(AUTHORIZE_URL)
|
||||
url.search = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: 'code',
|
||||
scope: SCOPES,
|
||||
redirect_uri: redirectUri(request),
|
||||
scope,
|
||||
redirect_uri: redirect,
|
||||
// Skip Discord's "you've already authorized this app, continue?" interstitial on a
|
||||
// repeat claim; the player has already pressed a button that says what this does.
|
||||
// repeat visit; the player has already pressed a button that says what this does.
|
||||
prompt: 'none',
|
||||
}).toString()
|
||||
return url.toString()
|
||||
@@ -195,6 +215,10 @@ const app = new Hono<App>()
|
||||
// ids never leave the worker, since it's the worker that asks Discord the question.
|
||||
benefitsEnabled: discord !== null,
|
||||
discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : null,
|
||||
// "Sign in with Discord", on the SAME switch: the sign-in resolves (or makes) the
|
||||
// account through the Discord link, and only the claim ever writes one with roles, so
|
||||
// an operator who hasn't set the claim up has no reason to offer the door either.
|
||||
discordLoginUrl: discord ? loginAuthorizeUrl(c.req.raw, discord.clientId) : null,
|
||||
hosts: {
|
||||
auth: authBase(c.env),
|
||||
accounts: accountsBase(c.env),
|
||||
@@ -344,6 +368,90 @@ const app = new Hono<App>()
|
||||
return c.json(token)
|
||||
})
|
||||
|
||||
// ---- Sign in with Discord -----------------------------------------------
|
||||
|
||||
/**
|
||||
* Redeem a Discord authorization code from the SIGN-IN consent (`/login/discord`), prove
|
||||
* who the Discord user is, and ask `auth` for a session on the account linked to them —
|
||||
* or, when there is none, for a brand-new account linked to them.
|
||||
*
|
||||
* The browser sends ONLY the code, as it does for the claim, and for the same reason: the
|
||||
* exchange needs the client secret. What is different is what comes after. The claim acts
|
||||
* on a session the caller already has; this is how one GETS a session, so there is no
|
||||
* bearer token and nothing here writes to the account. Instead the proven identity is
|
||||
* signed into an ASSERTION with the shared JWT_SECRET and posted to `auth`'s
|
||||
* `identity_assertion` grant, which resolves it through the link table exactly as
|
||||
* `cached_login` resolves a Steam ticket, and answers the ordinary token body. Resolving
|
||||
* the account — and minting one — stays `auth`'s: www never picks an account id.
|
||||
*
|
||||
* Posted over the service binding with the browser's real IP forwarded, like signup, so
|
||||
* the per-IP cap on the sign-up path counts the browser and not Cloudflare. The token
|
||||
* response is relayed verbatim; `created: true` on it is the SPA's cue to say an account
|
||||
* was just made and that setting a password is what makes it usable in the game.
|
||||
*/
|
||||
.post('/api/login/discord', async (c) => {
|
||||
const config = await discordConfig(c.env)
|
||||
if (!config) return c.json({ error: 'Signing in with Discord is not available here.' }, 403)
|
||||
|
||||
type LoginBody = { code?: string }
|
||||
const { code } = await c.req.json<LoginBody>().catch(() => ({}) as LoginBody)
|
||||
if (!code) return c.json({ error: 'No Discord authorization code was provided.' }, 400)
|
||||
|
||||
const accessToken = await exchangeCode(config, code, loginRedirectUri(c.req.raw))
|
||||
if (accessToken === null) {
|
||||
return c.json(
|
||||
{ error: 'That Discord sign-in could not be completed. Please try again.' },
|
||||
400
|
||||
)
|
||||
}
|
||||
const user = await fetchUser(accessToken)
|
||||
await revokeToken(config, accessToken)
|
||||
if (user === null) {
|
||||
return c.json(
|
||||
{ error: 'That Discord sign-in could not be completed. Please try again.' },
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const jwtSecret = await c.env.JWT_SECRET.get().catch(() => '')
|
||||
if (jwtSecret === '') {
|
||||
logger.error('cannot sign an identity assertion: JWT_SECRET is unreadable')
|
||||
return c.json({ error: authUnreachable('login') }, 502)
|
||||
}
|
||||
const assertion = await generateIdentityAssertion(
|
||||
{ platform: PlatformType.Discord, platformId: user.id },
|
||||
jwtSecret
|
||||
)
|
||||
const res = await postAuthForm(
|
||||
c.env,
|
||||
'/connect/token',
|
||||
{ grant_type: 'identity_assertion', assertion },
|
||||
{ clientIp: c.req.header('cf-connecting-ip') }
|
||||
).catch(() => null)
|
||||
if (res === null) {
|
||||
logger.error('could not reach auth to redeem an identity assertion')
|
||||
return c.json({ error: authUnreachable('login') }, 502)
|
||||
}
|
||||
if (!res.ok) {
|
||||
const failure = await readAuthError(res, 'login')
|
||||
logger.info('auth refused a discord sign-in', {
|
||||
status: res.status,
|
||||
upstream: failure.upstream,
|
||||
})
|
||||
return c.json({ error: failure.message }, failure.status)
|
||||
}
|
||||
const token = (await res.json().catch(() => null)) as {
|
||||
access_token?: string
|
||||
created?: boolean
|
||||
} | null
|
||||
if (!token?.access_token) {
|
||||
logger.error('auth answered a discord sign-in with no access_token')
|
||||
return c.json({ error: authUnreachable('login') }, 502)
|
||||
}
|
||||
logger.info(token.created ? 'created an account from a discord sign-in' : 'discord sign-in')
|
||||
return c.json({ ...token, discordUsername: user.username })
|
||||
})
|
||||
|
||||
// ---- Discord benefits claim ---------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -358,16 +466,106 @@ const app = new Hono<App>()
|
||||
.get('/api/benefits/status', async (c) => {
|
||||
const accountId = await claimant(c)
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
const [account, links] = await Promise.all([
|
||||
const [account, claimed, links] = await Promise.all([
|
||||
getAccount(c.env.DB, accountId),
|
||||
hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord),
|
||||
getLinksForAccount(c.env.DB, accountId),
|
||||
])
|
||||
return c.json({
|
||||
hasPlus: account?.hasPlus ?? false,
|
||||
// Whether this account is already tied to a Discord identity — not WHICH one. The
|
||||
// player knows their own Discord; the id is of no use to the page and every reason
|
||||
// to keep an account's linked identities off the wire.
|
||||
linked: links.some((link) => link.platform === PlatformType.Discord),
|
||||
// Whether this account has CLAIMED through a Discord identity — a link with roles
|
||||
// recorded — not WHICH one. The player knows their own Discord; the id is of no use to
|
||||
// the page and every reason to keep an account's linked identities off the wire. A
|
||||
// bare link, which "Sign in with Discord" leaves on a new account, reads as not yet
|
||||
// claimed: the page then offers the claim, which is what that player still has to do.
|
||||
linked: claimed,
|
||||
// Whether ANY Discord identity is on the account, claimed or bare — what the unlink
|
||||
// button keys on, since a sign-in's bare link is as much a link to take off as a
|
||||
// claim's. Still never WHICH one.
|
||||
discordLinked: links.some((link) => link.platform === PlatformType.Discord),
|
||||
// The unlink's one precondition (see `/api/benefits/unlink`), served beside the link
|
||||
// so the page can say so before the button fails. The self DTO carries the address
|
||||
// too; this is the server's own reading of the same row the unlink will decide from.
|
||||
hasEmail: !!account?.email,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Take the account's Discord off it: every `PlatformType.Discord` link goes, whether a
|
||||
* claim wrote it or "Sign in with Discord" did. Afterwards that Discord opens no account —
|
||||
* signing in with it makes a NEW one — and the benefits claim's once-only guard lets it
|
||||
* claim on another account.
|
||||
*
|
||||
* Refused unless the account has an EMAIL ADDRESS. The link may be the only way into the
|
||||
* account: an account made by "Sign in with Discord" has no password and no email until
|
||||
* the player sets them, and removing its link would leave nothing to recover the account
|
||||
* by. An address is enough — it is what the account can be recovered through — so a
|
||||
* password is NOT required: a player with an email and no password sets one in game, as
|
||||
* any passwordless account does. Checked here, on the row, and not trusted from the page
|
||||
* (which shows the same flag from `/api/benefits/status` so it can say so before anyone
|
||||
* presses anything).
|
||||
*
|
||||
* The grants the link carried go with it. `hasPlus` and `hasStudio` are both cleared:
|
||||
* the Discord roles are what the claim granted Plus for and what the Studio rollout reads,
|
||||
* and a player taking the link off is giving that proof back — unlike a role that merely
|
||||
* lapses, which the claim deliberately never revokes for. This clears an operator's
|
||||
* grant too (`runx admin grant-plus`/`grant-studio` write the same flags, and nothing on
|
||||
* the row says which wrote them); the page warns, and staff can re-grant. Both reach the
|
||||
* game on the NEXT token, like every account flag.
|
||||
*
|
||||
* What it does NOT do is forget that the account has claimed. A link with roles recorded
|
||||
* was a claim, and the first-link supporter gift keys on "never claimed before", read from
|
||||
* the link table — which would read as never-linked once the row is gone. So a claimed
|
||||
* link's removal stamps `hasClaimedDiscord` on the account, and the claim consults both;
|
||||
* otherwise link → unlink → link would pay the one-time box on every cycle.
|
||||
*
|
||||
* Not gated on the Discord configuration: the link is in the table whether or not the
|
||||
* operator still runs the claim, and a player can always take it off. Answers the same
|
||||
* shape `/api/benefits/status` does, as the account now stands.
|
||||
*/
|
||||
.post('/api/benefits/unlink', async (c) => {
|
||||
const accountId = await claimant(c)
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
|
||||
const account = await getAccount(c.env.DB, accountId)
|
||||
if (!account?.email) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Add an email address to your account before unlinking Discord, so the account can be recovered without it.',
|
||||
},
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
// Whether what's being removed includes a CLAIM — a link with `role` recorded, even as
|
||||
// `[]`. Asked of the raw column BEFORE the delete: the parsed `PlatformLink` reads a
|
||||
// NULL role and an empty list both as `[]`, and only the column tells a sign-in's bare
|
||||
// link from a claim that found no roles.
|
||||
const claimed = await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord)
|
||||
const removed = await unlinkPlatform(c.env.DB, accountId, PlatformType.Discord)
|
||||
if (removed.length === 0) {
|
||||
return c.json({ error: 'No Discord account is linked to this account.' }, 404)
|
||||
}
|
||||
// One write: remember the claim (when there was one), and take back what the link
|
||||
// carried. `undefined` drops the key from the blob, which is how "no Plus" is spelled.
|
||||
await updateAccount(c.env.DB, accountId, {
|
||||
hasClaimedDiscord: claimed || account.hasClaimedDiscord === true ? true : undefined,
|
||||
hasPlus: undefined,
|
||||
hasStudio: undefined,
|
||||
})
|
||||
logger.info('unlinked a discord from an account', {
|
||||
accountId,
|
||||
links: removed.length,
|
||||
claimed,
|
||||
hadPlus: account.hasPlus === true,
|
||||
hadStudio: account.hasStudio === true,
|
||||
})
|
||||
return c.json({
|
||||
hasPlus: false,
|
||||
linked: false,
|
||||
discordLinked: false,
|
||||
hasEmail: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -399,7 +597,8 @@ const app = new Hono<App>()
|
||||
* Nothing here ever REVOKES Plus: losing the Discord role later leaves the flag set.
|
||||
* That's deliberate for now — a sweep would need a bot token to enumerate the guild,
|
||||
* which this design specifically avoids — but it does mean the flag records "held the
|
||||
* role once", not "holds it today".
|
||||
* role once", not "holds it today". The player's own unlink (`/api/benefits/unlink`) is
|
||||
* the one thing that takes it back.
|
||||
*
|
||||
* The FIRST time an account links a Discord, the claim also pays the supporter gift
|
||||
* econ's cron would (`DISCORD_ROLE_TOKENS`, the amount for the best of the roles just
|
||||
@@ -456,12 +655,6 @@ const app = new Hono<App>()
|
||||
PlatformType.Discord,
|
||||
membership.userId
|
||||
)
|
||||
// Whether this claim is the account's FIRST Discord link — what the one-time gift below
|
||||
// keys on. Decided before the write, from the table: an account re-claiming with the
|
||||
// same Discord is not first (`alreadyMine`), and neither is one that already carries a
|
||||
// DIFFERENT Discord identity, which the once-only guard doesn't stop. Only asked when
|
||||
// it could be true, to spare the re-claim a query.
|
||||
let firstLink = false
|
||||
if (!alreadyMine) {
|
||||
const claimedElsewhere = await countAccountsForPlatformIdentity(
|
||||
c.env.DB,
|
||||
@@ -477,9 +670,32 @@ const app = new Hono<App>()
|
||||
409
|
||||
)
|
||||
}
|
||||
const links = await getLinksForAccount(c.env.DB, accountId)
|
||||
firstLink = !links.some((link) => link.platform === PlatformType.Discord)
|
||||
}
|
||||
// Whether this is the account's FIRST CLAIM — what the one-time gift below keys on.
|
||||
// Decided before the write, from the table: a claim is recorded as roles on a Discord
|
||||
// link, so an account with any Discord link carrying roles (this identity's, or a
|
||||
// DIFFERENT one the once-only guard doesn't stop) has claimed before. A link with NO
|
||||
// roles has not: that is what "Sign in with Discord" leaves on an account it just made,
|
||||
// and that player's first claim is still their first.
|
||||
//
|
||||
// AND from the account: an unlink removes a claimed link's row, and stamps
|
||||
// `hasClaimedDiscord` on the account so the table reading as never-linked afterwards
|
||||
// doesn't make the next link a "first" one. Both are asked; either says not-first.
|
||||
const firstClaim =
|
||||
(await getAccount(c.env.DB, accountId))?.hasClaimedDiscord !== true &&
|
||||
!(await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord))
|
||||
// On a link that already exists without roles, writing them is the claim's one-shot
|
||||
// write — the single-winner guard a fresh link gets from its INSERT below. Asked only
|
||||
// when the link is the caller's: on anyone else's it would be refused above.
|
||||
const recordedFirst =
|
||||
alreadyMine &&
|
||||
(await recordFirstRoles(
|
||||
c.env.DB,
|
||||
accountId,
|
||||
PlatformType.Discord,
|
||||
membership.userId,
|
||||
membership.roles
|
||||
))
|
||||
|
||||
// The link is written for EVERY guild member, qualifying role or not, and it is
|
||||
// idempotent: INSERT OR IGNORE (so `linkedAt` keeps the FIRST claim's time), with the
|
||||
@@ -519,13 +735,14 @@ const app = new Hono<App>()
|
||||
}
|
||||
|
||||
// The one-time supporter gift: the box econ's cron would hand this member, paid now,
|
||||
// once. Gated on the insert having happened too (`created`), so two claims racing on
|
||||
// once. Gated on a single-winner write having happened too — the insert (`created`), or
|
||||
// the first roles onto a sign-in's bare link (`recordedFirst`) — so two claims racing on
|
||||
// one fresh account can't both pay. Best-effort AFTER Plus is granted and the link is
|
||||
// written: a grant that fails partway is logged with what to check, as econ's cron
|
||||
// logs its own, and the claim still answers — the player got what they came for, and
|
||||
// a retry that also failed partway is how a balance gets credited twice.
|
||||
let tokensAwarded: number | null = null
|
||||
if (created && firstLink) {
|
||||
if ((created || recordedFirst) && firstClaim) {
|
||||
try {
|
||||
const role = await grantDiscordRoleGift(
|
||||
c.env,
|
||||
@@ -597,6 +814,8 @@ const app = new Hono<App>()
|
||||
.post('/api/staff/players/:id/gift-xp', requireDeveloper, giftXpHandler)
|
||||
// Everyone standing in a room, across all of its instances.
|
||||
.post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler)
|
||||
// Delete every live instance of a room — for one whose sessions are stuck. All staff.
|
||||
.post('/api/staff/rooms/:roomId/clear-instances', clearRoomInstancesHandler)
|
||||
// The account page's token drop: the same gift to everyone online at once.
|
||||
.post('/api/staff/online/gift-tokens', requireDeveloper, giftOnlineTokensHandler)
|
||||
// Its sibling: the same gift to every account whose Discord link holds a role, offline
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* it from `@repo/domain` (each uses the subset it needs).
|
||||
*/
|
||||
|
||||
import { AVATAR_STUDIO_ACCESS } from './avatar-studio'
|
||||
import { bindPlaceholders, chunkForBinds } from './d1-binds'
|
||||
|
||||
/**
|
||||
@@ -31,12 +32,17 @@ export const SCHEMA_DDL: string[] = [
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) 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 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_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). */
|
||||
@@ -104,7 +110,12 @@ export interface Account {
|
||||
signupIp?: string
|
||||
/** The client IP of the most recent successful login; refreshed on every login. */
|
||||
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
|
||||
/** Set via POST /account/me/phone; absent until the player provides one. */
|
||||
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
|
||||
* the other is missing: a manually granted account has `hasPlus` and no link at all, and
|
||||
* that is a normal, supported state.
|
||||
* that is a normal, supported state. The one thing that clears it besides an operator is
|
||||
* the player UNLINKING their Discord (`www` `POST /api/benefits/unlink`), which clears
|
||||
* this and `hasStudio` together — the player is handing back the proof — and does so
|
||||
* whoever set them, since nothing on the row says which.
|
||||
*
|
||||
* Nothing reads this per request. `auth` stamps it into every token it mints as the
|
||||
* `rn.plus` claim, and `econ` decides the CampusCard and the subscriber discount from
|
||||
@@ -177,6 +191,21 @@ export interface Account {
|
||||
* real: the website's claim page warns about it, and so does `grant-plus`.
|
||||
*/
|
||||
hasPlus?: boolean
|
||||
/**
|
||||
* Whether this account has EVER claimed its Discord benefits — had roles recorded on a
|
||||
* `PlatformType.Discord` link by `www`'s `POST /api/benefits/claim` — even if that link is
|
||||
* gone. Set only by the website's Discord UNLINK, when the link it removes carries roles:
|
||||
* while the link exists the link table itself says so (`hasRecordedRoles`), and a link
|
||||
* that never claimed has nothing to remember.
|
||||
*
|
||||
* What it protects is the first-link supporter gift. The claim pays that box the FIRST
|
||||
* time an account links a Discord, deciding "first" from the link table — which, after an
|
||||
* unlink, reads as if the account had never linked. Without this flag, link → unlink →
|
||||
* link would pay the one-time gift on every cycle. Nothing else reads it: `hasPlus` stands
|
||||
* on its own (above), and the once-only-per-Discord-user guard is the link table's.
|
||||
* Absent/false means no claim remembered.
|
||||
*/
|
||||
hasClaimedDiscord?: boolean
|
||||
/**
|
||||
* Whether this account may upload from Rec Room Studio. The editor decides Full access
|
||||
* by reading the token for a `role` claim of exactly `betastudio`, which `auth` stamps
|
||||
@@ -188,6 +217,8 @@ export interface Account {
|
||||
* open keeps its old access until it signs in again or refreshes.
|
||||
*/
|
||||
hasStudio?: boolean
|
||||
/** Staff override for screen sharing. Absent means screen sharing is disabled. */
|
||||
hasScreenshare?: boolean
|
||||
}
|
||||
|
||||
interface AccountRow {
|
||||
@@ -287,15 +318,47 @@ export async function getAccountByUsername(
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
|
||||
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
|
||||
export const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
|
||||
|
||||
/**
|
||||
* The smallest string that sorts after every string beginning with `prefix` under
|
||||
* SQLite's default BINARY (byte-wise) collation: the prefix followed by U+10FFFF, whose
|
||||
* UTF-8 encoding (F4 8F BF BF) is the highest valid sequence. The pair
|
||||
* `>= prefix AND < prefixUpperBound(prefix)` is the prefix match as a range.
|
||||
*/
|
||||
export const prefixUpperBound = (prefix: string): string => `${prefix}\u{10FFFF}`
|
||||
|
||||
/**
|
||||
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered
|
||||
* alphabetically. Backed by the indexed `username_lower` generated column, so the
|
||||
* `name%` LIKE stays index-friendly. Returns up to `limit` matches.
|
||||
* alphabetically. Returns up to `limit` matches.
|
||||
*
|
||||
* The prefix is applied as a RANGE on the indexed `username_lower` generated column
|
||||
* (`>= name AND < name+U+10FFFF`), not as `LIKE 'name%'`. SQLite only turns a LIKE into
|
||||
* an index seek when the LIKE is case-sensitive or the index is NOCASE, and never when
|
||||
* it carries an ESCAPE clause, so the LIKE form was a full index scan on every call —
|
||||
* 48k rows read per keystroke in the site's search bar, tens of millions a day on D1's
|
||||
* meter. The range is a seek (`SEARCH … (username_lower>? AND username_lower<?)`) and
|
||||
* walks the index in `ORDER BY` order, so the LIMIT stops it after `limit` rows. The
|
||||
* LIKE stays as a residual filter on those rows so the match is exactly "begins with",
|
||||
* with the user's `%`/`_` taken literally.
|
||||
*/
|
||||
export async function searchAccounts(
|
||||
db: D1Database,
|
||||
@@ -306,9 +369,11 @@ export async function searchAccounts(
|
||||
if (q === '') return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM account WHERE username_lower LIKE ?1 ESCAPE '\\' ORDER BY username_lower LIMIT ?2`
|
||||
`SELECT data FROM account
|
||||
WHERE username_lower >= ?1 AND username_lower < ?2 AND username_lower LIKE ?3 ESCAPE '\\'
|
||||
ORDER BY username_lower LIMIT ?4`
|
||||
)
|
||||
.bind(`${escapeLike(q)}%`, limit)
|
||||
.bind(q, prefixUpperBound(q), `${escapeLike(q)}%`, limit)
|
||||
.all<AccountRow>()
|
||||
return parseAll(results)
|
||||
}
|
||||
@@ -463,10 +528,23 @@ export async function listInfluencerIds(db: D1Database): Promise<number[]> {
|
||||
* Every account flagged `hasStudio`, lowest id first — the website's staff list. A JSON
|
||||
* scan rather than a generated column: the list is short and only staff read it.
|
||||
*/
|
||||
export async function listStudioAccounts(db: D1Database): Promise<Account[]> {
|
||||
export async function listStudioAccounts(
|
||||
db: D1Database,
|
||||
kind: 'studio' | 'avatar' | 'screenshare' | keyof typeof AVATAR_STUDIO_ACCESS = 'studio'
|
||||
): Promise<Account[]> {
|
||||
const mask =
|
||||
kind === 'avatar'
|
||||
? 7
|
||||
: Object.hasOwn(AVATAR_STUDIO_ACCESS, kind)
|
||||
? AVATAR_STUDIO_ACCESS[kind as keyof typeof AVATAR_STUDIO_ACCESS]
|
||||
: 0
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM account WHERE json_extract(data, '$.hasStudio') = 1 ORDER BY account_id`
|
||||
mask !== 0
|
||||
? `SELECT data FROM account WHERE account_id IN (SELECT account_id FROM avatar_studio_access WHERE (flags & ${mask}) != 0) ORDER BY account_id`
|
||||
: kind === 'screenshare'
|
||||
? `SELECT data FROM account WHERE json_extract(data, '$.hasScreenshare') = 1 ORDER BY account_id`
|
||||
: `SELECT data FROM account WHERE json_extract(data, '$.hasStudio') = 1 ORDER BY account_id`
|
||||
)
|
||||
.all<AccountRow>()
|
||||
return parseAll(results)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/** Shared persisted Studio authoring state. Existing storefront items remain unchanged. */
|
||||
export const AVATAR_STUDIO_SCHEMA = [
|
||||
`CREATE TABLE IF NOT EXISTS avatar_studio_access (account_id INTEGER PRIMARY KEY, flags INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, modified_at TEXT NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS avatar_studio_save (id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, body_type INTEGER NOT NULL, unity_asset_id TEXT NOT NULL, source_filename TEXT NOT NULL, source_hash TEXT NOT NULL, thumbnail TEXT NOT NULL, moderation_image TEXT, configuration TEXT NOT NULL, description TEXT, update_current INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_avatar_studio_save_item ON avatar_studio_save(item_id,id)`,
|
||||
`CREATE TABLE IF NOT EXISTS studio_avatar_build (cloud_build_id TEXT PRIMARY KEY, item_id TEXT NOT NULL, save_id INTEGER NOT NULL)`,
|
||||
]
|
||||
|
||||
export interface AvatarStudioItem {
|
||||
CustomAvatarItemId: string
|
||||
CreatorAccountId: number
|
||||
OutfitType: number
|
||||
Accessibility: number
|
||||
CurrentSaves: Array<Record<string, unknown>>
|
||||
[key: string]: unknown
|
||||
}
|
||||
export interface AvatarStudioSave {
|
||||
id: number
|
||||
item_id: string
|
||||
body_type: number
|
||||
unity_asset_id: string
|
||||
source_filename: string
|
||||
source_hash: string
|
||||
thumbnail: string
|
||||
moderation_image: string | null
|
||||
configuration: string
|
||||
description: string | null
|
||||
update_current: number
|
||||
created_at: string
|
||||
}
|
||||
/** Independent authoring permissions reported by Studio's access override. */
|
||||
export const AVATAR_STUDIO_ACCESS = {
|
||||
'avatar-items': 1,
|
||||
'avatar-costumes': 2,
|
||||
'avatar-badges': 4,
|
||||
'avatar-consumables': 8,
|
||||
} as const
|
||||
|
||||
export async function avatarStudioFlags(db: D1Database, accountId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT flags FROM avatar_studio_access WHERE account_id=?')
|
||||
.bind(accountId)
|
||||
.first<{ flags: number }>()
|
||||
return row?.flags ?? 0
|
||||
}
|
||||
export const AVATAR_STUDIO_SLOTS = [
|
||||
0, 2, 3, 10, 20, 100, 101, 102, 103, 104, 200, 203, 300, 301, 500, 501, 502, 503, 504, 505, 506,
|
||||
507, 508, 509, 510, 511, 512, 1000,
|
||||
]
|
||||
export function avatarItemAccessBit(outfitType: number): number {
|
||||
return outfitType === 1000 ? 4 : outfitType >= 500 && outfitType <= 512 ? 2 : 1
|
||||
}
|
||||
export async function avatarStudioItem(
|
||||
db: D1Database,
|
||||
id: string
|
||||
): Promise<AvatarStudioItem | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
|
||||
.bind(id)
|
||||
.first<{ data: string }>()
|
||||
return row ? (JSON.parse(row.data) as AvatarStudioItem) : null
|
||||
}
|
||||
export async function avatarStudioSave(
|
||||
db: D1Database,
|
||||
itemId: string,
|
||||
id: number
|
||||
): Promise<AvatarStudioSave | null> {
|
||||
return db
|
||||
.prepare('SELECT * FROM avatar_studio_save WHERE item_id=? AND id=?')
|
||||
.bind(itemId, id)
|
||||
.first<AvatarStudioSave>()
|
||||
}
|
||||
export async function avatarSaveDto(db: D1Database, row: AvatarStudioSave, target = 0) {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
'SELECT unity_asset_id AS UnityAssetId,target AS Target,version AS Version,filename AS Filename,hash AS Hash,kind FROM unity_asset WHERE unity_asset_id=?'
|
||||
)
|
||||
.bind(row.unity_asset_id)
|
||||
.all<{
|
||||
UnityAssetId: string
|
||||
Target: number
|
||||
Version: number
|
||||
Filename: string
|
||||
Hash: string
|
||||
kind: string
|
||||
}>()
|
||||
const main = results.find((a) => a.Target === target && a.kind === 'main')
|
||||
const material = results.find((a) => a.Target === target && a.kind === 'material')
|
||||
return {
|
||||
CustomAvatarItemSaveId: row.id,
|
||||
CustomAvatarItemId: row.item_id,
|
||||
UnityAssetId: row.unity_asset_id,
|
||||
BodyType: row.body_type,
|
||||
CreatedAt: row.created_at,
|
||||
ModifiedAt: row.created_at,
|
||||
Description: row.description,
|
||||
ThumbnailFileName: row.thumbnail,
|
||||
AdditionalConfiguration: row.configuration,
|
||||
SourceUnityAssetFilename: row.source_filename,
|
||||
SourceUnityAssetHash: row.source_hash,
|
||||
ModerationImageFileName: row.moderation_image,
|
||||
UnityAsset: main?.Filename ?? null,
|
||||
UnityAssetHash: main?.Hash ?? null,
|
||||
UnityAsset2: material?.Filename ?? null,
|
||||
UnityAsset2Hash: material?.Hash ?? null,
|
||||
BakedAssets: results.filter((a) => a.kind !== 'stripped').map(({ kind: _kind, ...a }) => a),
|
||||
}
|
||||
}
|
||||
/** Publish exactly this built revision for its body type, preserving other body types. */
|
||||
export async function publishAvatarSave(
|
||||
db: D1Database,
|
||||
itemId: string,
|
||||
saveId: number,
|
||||
automatic = false,
|
||||
attempt = 0
|
||||
): Promise<boolean> {
|
||||
const item = await avatarStudioItem(db, itemId)
|
||||
const save = await avatarStudioSave(db, itemId, saveId)
|
||||
if (!item || !save) return false
|
||||
const dto = await avatarSaveDto(db, save)
|
||||
// Badge images are uploaded and published directly by the SDK, without a Unity build.
|
||||
if (!dto.UnityAsset && item.OutfitType !== 1000) return false
|
||||
const quest = await avatarSaveDto(db, save, 2)
|
||||
const {
|
||||
SourceUnityAssetFilename: _source,
|
||||
SourceUnityAssetHash: _hash,
|
||||
BakedAssets: _baked,
|
||||
...published
|
||||
} = dto
|
||||
// JSON mutation is conditional on the complete prior snapshot: concurrent body-type
|
||||
// publications cannot overwrite each other or resurrect a deleted item.
|
||||
const prior = JSON.stringify(item)
|
||||
item.CurrentSaves = [
|
||||
...(item.CurrentSaves ?? []).filter((s) => s.BodyType !== save.body_type),
|
||||
{
|
||||
...published,
|
||||
OutfitType: item.OutfitType,
|
||||
QuestUnityAssetHash: quest.UnityAssetHash,
|
||||
QuestUnityAsset2Hash: quest.UnityAsset2Hash,
|
||||
},
|
||||
]
|
||||
item.ModifiedAt = new Date().toISOString()
|
||||
item.ThumbnailImageFilename = save.thumbnail
|
||||
if (item.OutfitType === 1000)
|
||||
item.CustomBadgeMetadata = { RoomId: JSON.parse(save.configuration)?.d?.d?.r ?? 0 }
|
||||
const result = await db
|
||||
.prepare(
|
||||
`UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)
|
||||
AND (?=0 OR ?=(SELECT MAX(id) FROM avatar_studio_save WHERE item_id=? AND body_type=?))`
|
||||
)
|
||||
.bind(JSON.stringify(item), itemId, prior, automatic ? 1 : 0, saveId, itemId, save.body_type)
|
||||
.run()
|
||||
if (!result.meta.changes && attempt < 5 && (await avatarStudioItem(db, itemId)))
|
||||
return publishAvatarSave(db, itemId, saveId, automatic, attempt + 1)
|
||||
return result.meta.changes > 0
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
@@ -13,6 +13,7 @@ export * from './audit-db'
|
||||
export * from './clubs-db'
|
||||
export * from './d1-binds'
|
||||
export * from './images-db'
|
||||
export * from './email-code-db'
|
||||
export * from './password'
|
||||
export * from './player-settings-kv'
|
||||
export * from './rooms-db'
|
||||
@@ -31,3 +32,4 @@ export * from './outfits-db'
|
||||
export * from './progression-db'
|
||||
export * from './relationships-db'
|
||||
export * from './validation'
|
||||
export * from './avatar-studio'
|
||||
|
||||
@@ -476,6 +476,25 @@ export async function deleteEmptyRoomInstances(
|
||||
return results.map((r) => r.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete EVERY instance of a room, occupied or not — the staff "Clear instances" button
|
||||
* for a room whose sessions are stuck. Presence is left alone: a player still in a
|
||||
* deleted instance keeps their row until it expires or they move, and their next
|
||||
* matchmake lands them in a fresh instance.
|
||||
*
|
||||
* Returns the ids deleted.
|
||||
*/
|
||||
export async function deleteRoomInstancesByRoom(db: D1Database, roomId: number): Promise<number[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`DELETE FROM room_instance WHERE room_id = ?1
|
||||
RETURNING json_extract(data, '$.roomInstanceId') AS id`
|
||||
)
|
||||
.bind(roomId)
|
||||
.all<{ id: number }>()
|
||||
return results.map((r) => r.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The oldest joinable public instance of a room (not private, not full, joins
|
||||
* enabled, not already in progress, not set to `Ignore`) that is running `gameVersion`,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* database read-only and uses `getRoomById`. Each imports the subset it needs.
|
||||
*/
|
||||
|
||||
import { escapeLike, prefixUpperBound } from './accounts-db'
|
||||
import { chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
|
||||
import { Accessibility, Role } from './enums'
|
||||
import { countPlayersByRoom } from './presence-db'
|
||||
@@ -72,6 +73,13 @@ export const ROOM_SCHEMA_DDL: string[] = [
|
||||
// feeds all order by it eventually; the WHERE clause is the point, not the key.
|
||||
`CREATE INDEX IF NOT EXISTS idx_room_public ON room (room_id)
|
||||
WHERE is_dorm IS NOT 1 AND accessibility = 1`,
|
||||
// The searchable rooms by lowercased name (migrations/0032_room_search_name.sql) — what
|
||||
// {@link searchRoomsByName} seeks. PARTIAL over exactly {@link SEARCHABLE_WHERE}, so the
|
||||
// walk never steps over a dorm or a private room: the rows it reads are the rows it
|
||||
// serves. `idx_rooms_name_lower` can't do that — it holds every dorm, and a prefix seek
|
||||
// on it checks each private room it passes against the blob.
|
||||
`CREATE INDEX IF NOT EXISTS idx_room_search_name ON room (name_lower)
|
||||
WHERE is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_search IS NOT 1`,
|
||||
// A room's tags, one row per tag (migrations/0013_room_tag.sql). Modelled on the
|
||||
// `api` worker's `event_tag`, and the table is AUTHORITATIVE: `serializeRoom` strips
|
||||
// `Tags` from the blob and the reads re-attach it, the same arrangement `subroom` and
|
||||
@@ -292,6 +300,35 @@ export function roomRoles(room: Room): RoomRole[] {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `Roles` entry records a role the account actually HOLDS, as opposed to one it has
|
||||
* only been offered. An invite is an entry with `Role` {@link Role.None} and the offer on
|
||||
* `InvitedRole`; it stays that way until the player accepts ({@link answerRoomRoleInvite})
|
||||
* or declines (which drops the entry). Tolerates the raw blob shape — older rooms carry
|
||||
* entries without a `Role` key, which read as None, and a malformed entry is not a role.
|
||||
*/
|
||||
function holdsRole(entry: unknown): boolean {
|
||||
if (entry === null || typeof entry !== 'object') return false
|
||||
return Number((entry as Partial<RoomRole>).Role ?? Role.None) !== Role.None
|
||||
}
|
||||
|
||||
/**
|
||||
* The room as SERVED, with pending invites dropped from its `Roles` — a shallow copy; the
|
||||
* room passed in is untouched, so a caller can keep writing from it. Entries are kept in the
|
||||
* blob's own shape (not normalised through {@link roomRoles}), so a room that had no
|
||||
* pending invite serializes exactly as before. A room with no `Roles` array is returned as
|
||||
* is.
|
||||
*
|
||||
* Apply this at the response boundary ONLY, never to a room that is about to be written
|
||||
* back. Rooms are stored as whole blobs and every writer rewrites the blob from the loaded
|
||||
* room, so dropping the pending entries any earlier would silently erase every open invite
|
||||
* on the room's next unrelated edit (a renamed room, a new load screen).
|
||||
*/
|
||||
export function withConfirmedRoles<T extends Room>(room: T): T {
|
||||
if (!Array.isArray(room.Roles)) return room
|
||||
return { ...room, Roles: room.Roles.filter(holdsRole) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The statements that bring `room_role` in line with a room's `Roles`: drop the room's rows,
|
||||
* then insert each entry whole, as {@link roomRoles} reads it. Every entry is stored,
|
||||
@@ -1924,6 +1961,19 @@ export async function saveSubRoomData(
|
||||
createdAt: new Date().toISOString(),
|
||||
unityAssetId: input.unityAssetId,
|
||||
})
|
||||
// A live client save changes the scene data while reusing the baked Studio
|
||||
// asset. Its export manifest belongs to that asset and is needed again on the
|
||||
// save's immediate reload (prefabs/materials live in separate bundles).
|
||||
// A new Studio source upload must wait for its own cloud-build manifest.
|
||||
if (
|
||||
!input.unitySource &&
|
||||
typeof input.unityAssetId === 'string' &&
|
||||
typeof previous?.UnityAssetId === 'string' &&
|
||||
input.unityAssetId.toLowerCase() === previous.UnityAssetId.toLowerCase() &&
|
||||
previous.ServerData != null
|
||||
) {
|
||||
saveData.ServerData = previous.ServerData
|
||||
}
|
||||
if (input.unitySource) {
|
||||
saveData.StudioSourceAsset = input.unitySource
|
||||
saveData.SavedOnPlatform = null
|
||||
@@ -2450,7 +2500,7 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
|
||||
* it.
|
||||
*/
|
||||
const serializeRoom = (room: Room): string => {
|
||||
const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room
|
||||
const { SubRooms: _subRooms, Tags: _tags, Badges: _badges, Stats: stats, ...rest } = room
|
||||
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
|
||||
}
|
||||
|
||||
@@ -2512,13 +2562,21 @@ export async function attachStudioUnityAssets(
|
||||
db: D1Database,
|
||||
saves: SubRoomDataSave[]
|
||||
): Promise<void> {
|
||||
const ids = [
|
||||
...new Set(
|
||||
saves
|
||||
.map((save) => save.UnityAssetId)
|
||||
.filter((id): id is string => typeof id === 'string' && id !== '')
|
||||
),
|
||||
]
|
||||
// The cloud build partitions prefab/material exports into separate bundles.
|
||||
// The main scene keeps external references to them, so the loader must receive
|
||||
// every export, including shared dependencies, before it instantiates the scene.
|
||||
const assetIds = (save: SubRoomDataSave): string[] => {
|
||||
if (typeof save.UnityAssetId !== 'string' || save.UnityAssetId === '') return []
|
||||
const serverData = save.ServerData as { Exports?: unknown } | null | undefined
|
||||
const exports = Array.isArray(serverData?.Exports) ? serverData.Exports : []
|
||||
const ids = new Set([save.UnityAssetId.toLowerCase()])
|
||||
for (const item of exports) {
|
||||
const id = (item as { u?: unknown } | null)?.u
|
||||
if (typeof id === 'string' && id !== '') ids.add(id.toLowerCase())
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
const ids = [...new Set(saves.flatMap(assetIds))]
|
||||
if (ids.length === 0) return
|
||||
const builds = (await listUnityAssetBuilds(db, ids)).filter((build) =>
|
||||
isStudioBundleFilename(build.Filename)
|
||||
@@ -2533,7 +2591,7 @@ export async function attachStudioUnityAssets(
|
||||
for (const save of saves) {
|
||||
const id = save.UnityAssetId
|
||||
if (typeof id !== 'string') continue
|
||||
const baked = bakedUnityAssets(byAsset.get(id.toLowerCase()) ?? [])
|
||||
const baked = bakedUnityAssets(assetIds(save).flatMap((assetId) => byAsset.get(assetId) ?? []))
|
||||
if (baked.length === 0) continue
|
||||
save.UnitySubAssets = baked
|
||||
}
|
||||
@@ -2842,10 +2900,31 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
|
||||
// game's Nullable<DateTime> decoder and discards the entire owned-room list.
|
||||
if (room.PublishedAt === '') room.PublishedAt = null
|
||||
}
|
||||
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms)])
|
||||
await Promise.all([attachSubRooms(db, rooms), attachTags(db, rooms), attachBadges(db, rooms)])
|
||||
return rooms
|
||||
}
|
||||
|
||||
/** Published room-linked image badges; drafts remain on the creator's Studio shelf. */
|
||||
async function attachBadges(db: D1Database, rooms: Room[]): Promise<void> {
|
||||
if (!rooms.length) return
|
||||
// `custom_avatar_item` is owned by api's migrations (0015, badge index 0035), both long
|
||||
// applied to the shared database; this no longer probes `sqlite_master` for it.
|
||||
// Served by `idx_custom_avatar_item_badge_room` (api migrations/0035), a PARTIAL index over
|
||||
// `outfit_type = 1000` keyed by the `json_extract(data,'$.CustomBadgeMetadata.RoomId')`
|
||||
// expression. SQLite matches both only as written: drop or rephrase either term and this
|
||||
// goes back to scanning every custom avatar item on every room page.
|
||||
const rows = await db
|
||||
.prepare(
|
||||
"SELECT data,json_extract(data,'$.CustomBadgeMetadata.RoomId') AS room_id FROM custom_avatar_item WHERE outfit_type=1000 AND accessibility<>0 AND json_array_length(json_extract(data,'$.CurrentSaves'))>0 AND json_extract(data,'$.CustomBadgeMetadata.RoomId') IN (SELECT value FROM json_each(?))"
|
||||
)
|
||||
.bind(JSON.stringify(rooms.map(roomIdOf)))
|
||||
.all<{ data: string; room_id: number }>()
|
||||
for (const room of rooms)
|
||||
room.Badges = rows.results
|
||||
.filter((r) => r.room_id === roomIdOf(room))
|
||||
.map((r) => JSON.parse(r.data))
|
||||
}
|
||||
|
||||
/** A single subroom of a room (columns authoritative), or null if it doesn't exist. */
|
||||
export async function getSubRoom(
|
||||
db: D1Database,
|
||||
@@ -3275,6 +3354,13 @@ export async function countRoomsByCreator(db: D1Database, accountId: number): Pr
|
||||
* room the player made. A room matching BOTH halves appears once — the roles half is an
|
||||
* EXISTS, not a join.
|
||||
*
|
||||
* A PENDING invite is not a job. An invited account holds a `Roles` entry with `Role` 0 and
|
||||
* the offer on `InvitedRole` until it answers, and that entry is indexed like any other —
|
||||
* so without the `role <> 0` guard a room showed up in the player's "rooms you contribute
|
||||
* to" (and, through `ownedby/me`, was considered for "rooms you own") the moment someone
|
||||
* invited them, before they had accepted anything. Only a held role counts here; the offer
|
||||
* reaches them as a `RoomCoOwnerInvited` message, not through this list.
|
||||
*
|
||||
* Roles live inside the room blob, so the roles half reads `room_role`, the trigger-kept
|
||||
* index of them, rather than a `json_each` over every room's `$.Roles` (which scanned and
|
||||
* parsed the whole table on every call). Both halves are index lookups, and SQLite runs
|
||||
@@ -3286,9 +3372,9 @@ export async function getContributedRooms(db: D1Database, accountId: number): Pr
|
||||
.prepare(
|
||||
`SELECT ${ROOM_COLUMNS} FROM room
|
||||
WHERE creator_account_id = ?1
|
||||
OR room_id IN (SELECT room_id FROM room_role WHERE account_id = ?1)`
|
||||
OR room_id IN (SELECT room_id FROM room_role WHERE account_id = ?1 AND role <> ?2)`
|
||||
)
|
||||
.bind(accountId)
|
||||
.bind(accountId, Role.None)
|
||||
.all<RoomRow>()
|
||||
return (await hydrateRooms(db, parseAll(results))).filter((r) => r.IsDorm !== true)
|
||||
}
|
||||
@@ -3613,6 +3699,38 @@ export async function searchRooms(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable rooms whose name BEGINS with `query` (case-insensitive), alphabetically, at
|
||||
* most `take` — the website's header search, which asks on every pause in typing.
|
||||
*
|
||||
* Not {@link searchRooms}: that is the game's search, a SUBSTRING match no index can
|
||||
* serve, so it reads every searchable room per call. This is a range seek on
|
||||
* `idx_room_search_name` (`>= q AND < q+U+10FFFF`, as {@link searchAccounts} does for
|
||||
* usernames), walked in `ORDER BY` order so the LIMIT stops it: D1 bills `take` rows
|
||||
* however many rooms exist. The WHERE repeats {@link SEARCHABLE_WHERE} verbatim because
|
||||
* SQLite only uses a partial index whose condition the query states; the LIKE is a
|
||||
* residual so `%`/`_` match literally. Rooms come back un-hydrated (no subrooms, tags or
|
||||
* badges) — the caller draws a name, a picture and the counters.
|
||||
*/
|
||||
export async function searchRoomsByName(
|
||||
db: D1Database,
|
||||
query: string,
|
||||
take: number
|
||||
): Promise<Room[]> {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q === '' || take <= 0) return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT ${ROOM_COLUMNS} FROM room
|
||||
WHERE ${SEARCHABLE_WHERE}
|
||||
AND name_lower >= ?1 AND name_lower < ?2 AND name_lower LIKE ?3 ESCAPE '\\'
|
||||
ORDER BY name_lower LIMIT ?4`
|
||||
)
|
||||
.bind(q, prefixUpperBound(q), `${escapeLike(q)}%`, take)
|
||||
.all<RoomRow>()
|
||||
return parseAll(results).filter(isSearchable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search suggestions for the box the player is typing in
|
||||
* (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms.
|
||||
|
||||
@@ -7,7 +7,11 @@ export {
|
||||
generateToken,
|
||||
generateStudioBuildValidationToken,
|
||||
validateStudioBuildValidationToken,
|
||||
studioBuildValidationId,
|
||||
generatePhotonAuthToken,
|
||||
generateIdentityAssertion,
|
||||
validateIdentityAssertion,
|
||||
IDENTITY_ASSERTION_TTL_SECONDS,
|
||||
TOKEN_TTL_SECONDS,
|
||||
} from './jwt'
|
||||
export type { PhotonAuthClaims } from './jwt'
|
||||
export type { IdentityAssertion, PhotonAuthClaims } from './jwt'
|
||||
|
||||
@@ -18,7 +18,7 @@ import { GAME_VERSION, gameBuild } from '@repo/domain'
|
||||
// last a day so the client doesn't have to refresh it.
|
||||
export const TOKEN_TTL_SECONDS = 86400
|
||||
|
||||
/** A build host can check component text, but cannot act as an account. */
|
||||
/** A build host can validate content and its job's grants, but cannot act as an account. */
|
||||
export async function generateStudioBuildValidationToken(
|
||||
buildId: string,
|
||||
secret: string
|
||||
@@ -39,19 +39,27 @@ export async function validateStudioBuildValidationToken(
|
||||
request: Request,
|
||||
secret: string
|
||||
): Promise<boolean> {
|
||||
return (await studioBuildValidationId(request, secret)) !== null
|
||||
}
|
||||
|
||||
/** Signed job identity for read-only, job-scoped validation routes. */
|
||||
export async function studioBuildValidationId(
|
||||
request: Request,
|
||||
secret: string
|
||||
): Promise<string | null> {
|
||||
const header = request.headers.get('Authorization') ?? ''
|
||||
if (!header.startsWith('Bearer ')) return false
|
||||
if (!header.startsWith('Bearer ')) return null
|
||||
try {
|
||||
const payload = await verify(header.slice(7), secret, 'HS256')
|
||||
return (
|
||||
payload.sub === undefined &&
|
||||
return payload.sub === undefined &&
|
||||
payload.aud === 'studio-build-validation' &&
|
||||
payload.scope === 'studio.build.validate' &&
|
||||
typeof payload.buildId === 'string' &&
|
||||
/^[1-9]\d*$/.test(payload.buildId)
|
||||
)
|
||||
? (payload.buildId as string)
|
||||
: null
|
||||
} catch {
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,3 +328,82 @@ export async function generateToken(
|
||||
secret
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `aud` of an identity assertion — what tells one apart from every other token this
|
||||
* key signs. An access token's audience is the auth host; a Studio build token's is
|
||||
* `studio-build-validation`. Checked on validation, so neither can be replayed as an
|
||||
* assertion, and an assertion — which carries no `sub` — satisfies none of the
|
||||
* `validateAndGet*` readers either.
|
||||
*/
|
||||
const IDENTITY_ASSERTION_AUDIENCE = 'recflare:identity-assertion'
|
||||
|
||||
/**
|
||||
* How long an assertion is good for. It is minted and redeemed within one request on the
|
||||
* server side (www proves the identity, then posts the assertion to auth), so a minute
|
||||
* covers clock skew between two Workers and nothing else.
|
||||
*/
|
||||
export const IDENTITY_ASSERTION_TTL_SECONDS = 60
|
||||
|
||||
/** A platform identity one worker has verified and is vouching for to `auth`. */
|
||||
export interface IdentityAssertion {
|
||||
/** PlatformType int — Discord (101) today. */
|
||||
platform: number
|
||||
/** The platform's own id for the user — a Discord snowflake, as a string. */
|
||||
platformId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a short-lived, signed statement that the caller has VERIFIED this platform
|
||||
* identity — the credential behind `auth`'s `identity_assertion` grant.
|
||||
*
|
||||
* This exists so a worker other than `auth` can prove an identity `auth` cannot: www
|
||||
* completes a Discord OAuth2 code exchange (which needs the Discord client secret, bound
|
||||
* only to www) and hands `auth` the result, rather than `auth` learning Discord. Signing it
|
||||
* with the shared `JWT_SECRET` adds no trust that didn't exist — a holder of that key can
|
||||
* already mint access tokens outright — and it means the grant works the same over the
|
||||
* service binding and over the public hostname. The assertion names an identity, never an
|
||||
* account: resolving it to one (or minting one) stays `auth`'s decision.
|
||||
*/
|
||||
export async function generateIdentityAssertion(
|
||||
identity: IdentityAssertion,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return sign(
|
||||
{
|
||||
aud: IDENTITY_ASSERTION_AUDIENCE,
|
||||
platform: identity.platform,
|
||||
platform_id: identity.platformId,
|
||||
iat: now,
|
||||
nbf: now,
|
||||
exp: now + IDENTITY_ASSERTION_TTL_SECONDS,
|
||||
jti: crypto.randomUUID(),
|
||||
},
|
||||
secret,
|
||||
'HS256'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an identity assertion and return the identity it vouches for, or null when
|
||||
* the token is malformed, mis-signed, expired, or not an assertion at all. The audience
|
||||
* check is what refuses an access token presented here, and a missing `sub` is required
|
||||
* for the same reason in the other direction: an assertion must never pass as a session.
|
||||
*/
|
||||
export async function validateIdentityAssertion(
|
||||
token: string,
|
||||
secret: string
|
||||
): Promise<IdentityAssertion | null> {
|
||||
try {
|
||||
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
|
||||
if (payload.aud !== IDENTITY_ASSERTION_AUDIENCE || payload.sub !== undefined) return null
|
||||
const platform = payload.platform
|
||||
const platformId = payload.platform_id
|
||||
if (typeof platform !== 'number' || !Number.isInteger(platform) || platform < 0) return null
|
||||
if (typeof platformId !== 'string' || platformId === '') return null
|
||||
return { platform, platformId }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||