mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23bc159c28 | |||
| 70df3cb6cb | |||
| 03b1c59f0d | |||
| d298977790 | |||
| 821bf54b9b | |||
| 108b061019 | |||
| af2a2a0683 | |||
| 339a91735b | |||
| a46f6db9d7 | |||
| b3f1d04823 | |||
| 55cb769de9 |
+16
-16
@@ -1,22 +1,9 @@
|
||||
# Base domain all service hosts are derived from, e.g. accounts.<domain>.
|
||||
RECFLARE_DOMAIN=rec.example.com
|
||||
|
||||
# Optional per-service subdomain overrides, as a compact JSON object keyed by the
|
||||
# service's default subdomain (which, for a service backed by a worker, is that
|
||||
# worker's directory name). Unlisted services keep their default.
|
||||
#
|
||||
# One entry moves both sides: it decides which host `just deploy` puts the worker on
|
||||
# AND which host the `ns` discovery document advertises to the client, so the two can't
|
||||
# drift apart. Redeploy `ns` (`just deploy -F ns`) after changing this.
|
||||
#
|
||||
# {"playersettings":"settings"} the playersettings worker moves to settings.<domain>
|
||||
# {"moderation":"api"} Moderation has no worker of its own, so this is a pure
|
||||
# client-side redirect: it points the client's Moderation
|
||||
# calls at the api worker, which is where the
|
||||
# /api/PlayerReporting/… routes actually live
|
||||
#
|
||||
# Keep it compact — no spaces. Services are listed in SERVICES.md.
|
||||
# RECFLARE_SUBDOMAINS='{"moderation":"api"}'
|
||||
# Optional per-app subdomain overrides, as a compact JSON object keyed by the
|
||||
# worker's directory name. Defaults to the directory name when unset.
|
||||
# RECFLARE_SUBDOMAINS='{"playersettings":"settings"}'
|
||||
|
||||
# Id of the shared `recflare` D1 database (create it manually with
|
||||
# `wrangler d1 create recflare`). All D1-backed workers bind this one database.
|
||||
@@ -77,3 +64,16 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# 0 means players start broke. Applies only to players who haven't been granted yet —
|
||||
# raising it later does NOT top up existing players.
|
||||
# RECFLARE_STARTING_TOKENS=10000
|
||||
|
||||
# Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare
|
||||
# Turnstile widget, and both of that widget's keys live in the shared Secrets Store
|
||||
# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets.
|
||||
#
|
||||
# wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
|
||||
# --scopes workers --remote
|
||||
# wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
|
||||
# --scopes workers --remote
|
||||
#
|
||||
# Setting them both is what opens web signup; with either missing it stays closed. See
|
||||
# DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account
|
||||
# caps above apply regardless.
|
||||
|
||||
+76
-15
@@ -43,10 +43,12 @@ services but would require small code changes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- node (modern)
|
||||
- pnpm
|
||||
- bun
|
||||
- jq/awk/sed
|
||||
**You must have all these requirements or RecFlare deployment will fail!**
|
||||
|
||||
- node 24 (https://nodejs.org)
|
||||
- pnpm (install with `npm install -g pnpm`)
|
||||
- bun (https://bun.sh)
|
||||
- jq/awk/sed (on Windows try `winget jq` etc.)
|
||||
- A Cloudflare account with a zone (domain) you control, for deploying.
|
||||
|
||||
Cloudflare's free plan is good enough for testing (100k worker requests/day) but the
|
||||
@@ -65,6 +67,8 @@ We use [Just](https://github.com/casey/just) for convenience. This will install
|
||||
just install
|
||||
```
|
||||
|
||||
You do not have to use `just` but you will have to run things manually with `pnpm`/`bun`.
|
||||
|
||||
**Configure your custom domain:**
|
||||
|
||||
Create a new .env file from the template:
|
||||
@@ -75,20 +79,13 @@ cp .env.example .env
|
||||
|
||||
Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export RECFLARE_DOMAIN=rec.example.com`)
|
||||
|
||||
(Optional) - per-service subdomain overrides come from `RECFLARE_SUBDOMAINS`, a JSON
|
||||
object keyed by each service's default subdomain (see `SERVICES.md`), e.g.
|
||||
`'{"playersettings":"settings"}'`. A single entry both decides which host `just deploy`
|
||||
puts that worker on and which host the `ns` discovery document advertises to the client,
|
||||
so the two can't drift apart.
|
||||
|
||||
This is also how you merge two services together: `'{"moderation":"api"}'` points the
|
||||
client's Moderation calls at the `api` worker (which is where the `/api/PlayerReporting/…`
|
||||
routes already live) without deploying anything on `moderation.<domain>`. Redeploy `ns`
|
||||
after changing it — `just deploy -F ns`.
|
||||
(Optional) - per-app subdomain overrides come from
|
||||
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
|
||||
if you wanted to merge two services together e.g. send `datacollection` calls to `api`.
|
||||
|
||||
**Create the storage resources:**
|
||||
|
||||
The workers bind Cloudflare storage primitive. Create them once against your
|
||||
The workers bind Cloudflare storage primitives. Create them once against your
|
||||
Cloudflare account, then record the IDs in `.env`. The committed `wrangler.jsonc`
|
||||
files carry `"local"` placeholders; the real IDs are spliced in at deploy time, so
|
||||
nothing in version control needs editing. Authenticate wrangler first
|
||||
@@ -111,6 +108,24 @@ binds it so tokens signed by `auth` verify everywhere. Record its id in `.env` a
|
||||
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
|
||||
```
|
||||
|
||||
The same store also holds `META_APP_SECRET`, the app secret from your app's page in
|
||||
the Meta developer dashboard (developers.meta.com). Only the `auth` worker binds it,
|
||||
and only to authenticate itself to Meta when validating a headset login's nonce —
|
||||
unlike Steam's ticket, which verifies offline, a Meta login cannot be checked without
|
||||
it. Create it too:
|
||||
|
||||
```bash
|
||||
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
|
||||
```
|
||||
|
||||
> ⚠️ Both secrets must **exist** in the store or `just deploy` fails on the `auth`
|
||||
> worker — a binding to a missing secret is a deploy error. If you have no Meta app,
|
||||
> create `META_APP_SECRET` with any placeholder value: Meta sign-ins then fail with a
|
||||
> 500 ("Meta platform verification is not configured") and nothing else is affected.
|
||||
> Steam and password sign-ins are unaffected either way. Put the real value in later
|
||||
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
|
||||
> the secret per request.
|
||||
|
||||
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
|
||||
|
||||
```bash
|
||||
@@ -206,6 +221,52 @@ single address, so raise it (or set it to `0`) if real players report being lock
|
||||
> `just deploy`. `.env` is the durable place. Real secrets don't belong there either — they
|
||||
> go in the Cloudflare Secrets Store, like the shared `JWT_SECRET` above.
|
||||
|
||||
### Signing up on the website (Turnstile)
|
||||
|
||||
Players get an account by launching the game, which needs no setup. The website can create
|
||||
one too — that path has no platform identity behind it, so it runs behind a
|
||||
[Turnstile](https://developers.cloudflare.com/turnstile/) bot check and is **closed until
|
||||
you configure one**. Two steps, both one-time:
|
||||
|
||||
1. Create the widget: Cloudflare dashboard → **Turnstile** → **Add widget**, mode
|
||||
**Managed**, hostnames your domain (add `localhost` if you want it in `just dev` against
|
||||
real keys). It gives you a **site key** and a **secret key**.
|
||||
2. Put both in the same Secrets Store the shared `JWT_SECRET` lives in — they're the switch
|
||||
that opens signup, and store values survive deploys:
|
||||
|
||||
```bash
|
||||
wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
|
||||
--scopes workers --remote
|
||||
wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
|
||||
--scopes workers --remote
|
||||
```
|
||||
|
||||
Then `just deploy -F www`. The site key is public — the browser needs it to render the
|
||||
widget, and gets it from `GET /api/config` — but it lives next to its secret so signup is
|
||||
configured in one place. The secret key never leaves the worker: `/api/signup` verifies the
|
||||
token against Turnstile server-side before it calls `auth`.
|
||||
|
||||
Signup opens only when **both** resolve. With either missing, `/api/config` reports signup
|
||||
closed (the site shows sign-in only) and `POST /api/signup` refuses — a missed step costs
|
||||
you the signup form, never an unprotected one. That is also how you turn signup back off:
|
||||
`wrangler secrets-store secret delete <store-id> --name TURNSTILE_SECRET_KEY --remote`,
|
||||
then redeploy `www` (values are cached per isolate, so a warm worker keeps the old one
|
||||
until fresh isolates start). For local dev, seed the same two names into the local store
|
||||
from `apps/www` — Turnstile's documented always-passes test keypair
|
||||
(`1x00000000000000000000AA` / `1x0000000000000000000000000000000AA`) works there without a
|
||||
widget:
|
||||
|
||||
```bash
|
||||
cd apps/www
|
||||
printf '1x00000000000000000000AA' |
|
||||
wrangler secrets-store secret create local --name TURNSTILE_SITE_KEY --scopes workers
|
||||
printf '1x0000000000000000000000000000000AA' |
|
||||
wrangler secrets-store secret create local --name TURNSTILE_SECRET_KEY --scopes workers
|
||||
```
|
||||
|
||||
Both `auth` account caps above still apply on top of the bot check, and the per-IP one is
|
||||
the only cap that can see a web signup.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
|
||||
|
||||

|
||||
|
||||
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
|
||||
Cloudflare Workers. It implements the network services the Rec Room client talks
|
||||
to — accounts, auth, rooms, matchmaking, economy, chat, notifications, and more —
|
||||
|
||||
+1
-7
@@ -6,12 +6,6 @@ Each is reached at `https://<subdomain>.<your-domain>`. Services with a worker i
|
||||
`apps/` are implemented here; the rest are advertised in the endpoints document
|
||||
but not yet backed by a Worker. Not all services are fully implemented.
|
||||
|
||||
The subdomains below are the defaults. Any of them can be redirected from `.env` via
|
||||
`RECFLARE_SUBDOMAINS`, keyed by the subdomain in this table — which both moves where the
|
||||
worker deploys and what `ns` advertises. Pointing a service with no worker at one that has
|
||||
one merges them, e.g. `'{"moderation":"api"}'` sends the client's Moderation calls to the
|
||||
`api` worker, where the `/api/PlayerReporting/…` routes already live. See `DEPLOYING.md`.
|
||||
|
||||
A small `ns` worker itself serves this discovery document at the
|
||||
apex/`ns` host and isn't listed within it. Each implemented worker has its own
|
||||
`README.md` under `apps/<name>/` documenting its routes.
|
||||
@@ -40,7 +34,7 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own
|
||||
| Link | `link` | — | Not yet implemented |
|
||||
| Lists | `lists` | — | Not yet implemented |
|
||||
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
|
||||
| Moderation | `moderation` | — | No worker; point it at `api` to serve `/api/PlayerReporting/…` |
|
||||
| Moderation | `moderation` | — | Not yet implemented |
|
||||
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
|
||||
| PlatformNotifications | `platformnotifications` | — | Not yet implemented |
|
||||
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
|
||||
|
||||
@@ -94,10 +94,6 @@ function toAccountDto(account: Account) {
|
||||
username: account.username,
|
||||
displayName: account.displayName,
|
||||
profileImage: account.profileImage,
|
||||
// Nothing writes these yet, and rows stored before they existed have neither
|
||||
// key — always emit them as "" rather than letting them go missing.
|
||||
bannerImage: account.bannerImage ?? '',
|
||||
displayEmoji: account.displayEmoji ?? '',
|
||||
isJunior: account.isJunior,
|
||||
platforms: account.platforms,
|
||||
personalPronouns: account.personalPronouns,
|
||||
@@ -116,7 +112,9 @@ function toSelfAccountDto(account: Account) {
|
||||
return {
|
||||
...toAccountDto(account),
|
||||
email: account.email ?? null,
|
||||
birthday: null,
|
||||
// @todo he game client needs this to be set. I forget how birthdays were set, so for now
|
||||
// everyone can be old.
|
||||
birthday: '1904-01-01T00:00:00.000Z',
|
||||
availableUsernameChanges: account.availableUsernameChanges ?? DEFAULT_USERNAME_CHANGES,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ export const AccountDto = z.object({
|
||||
username: z.string(),
|
||||
displayName: z.string(),
|
||||
profileImage: z.string().describe('Avatar object key'),
|
||||
bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'),
|
||||
displayEmoji: z.string().describe('Emoji beside the display name — always "" (nothing sets it yet)'),
|
||||
isJunior: z.boolean(),
|
||||
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
|
||||
personalPronouns: z.int().describe('Pronoun flags bitmask'),
|
||||
|
||||
@@ -161,10 +161,6 @@ describe('auth-gated endpoints', () => {
|
||||
personalPronouns: 0,
|
||||
identityFlags: 0,
|
||||
availableUsernameChanges: 1,
|
||||
// Nothing sets these yet, but the key has to be present — the client reads
|
||||
// both off the account DTO.
|
||||
bannerImage: '',
|
||||
displayEmoji: '',
|
||||
})
|
||||
// juniorState + parentAccountId must be omitted when null, not emitted as
|
||||
// null, or the client's enum parser throws on `juniorState`. `phone` isn't
|
||||
|
||||
@@ -21,6 +21,9 @@ export type Env = SharedHonoEnv & {
|
||||
// Image bucket (shared with the `img` worker, which serves objects back by
|
||||
// key). Uploaded saved images are written here.
|
||||
IMAGES: R2Bucket
|
||||
// Shared CDN bucket (owned by the `cdn` worker, written by `storage`). Read
|
||||
// here only to hash an invention's uploaded data blob under `invention/`.
|
||||
CDN_ASSETS: R2Bucket
|
||||
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||
// push RelationshipChanged notifications when a player's relationship changes.
|
||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||
|
||||
@@ -130,6 +130,37 @@ function inventionBlobName(filename: string): string {
|
||||
return filename.toLowerCase().endsWith('.inv') ? filename : `${filename}.inv`
|
||||
}
|
||||
|
||||
/** Base64 — the encoding the real API's hash fields (`BlobHash`) come back in. */
|
||||
function toBase64(bytes: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The hash of an invention's data blob: its SHA-256, base64-encoded, matching the
|
||||
* real API's `BlobHash`. Read from the checksum the `storage` worker records at
|
||||
* upload time, so this is normally a HEAD with no body transfer; a blob stored
|
||||
* before that (or by anything else) is downloaded and digested instead.
|
||||
*
|
||||
* Null when the blob isn't in the bucket — a metadata-only save names a file that
|
||||
* was never uploaded, and a hash of nothing would be worse than the absent hash the
|
||||
* field already allows for.
|
||||
*/
|
||||
export async function inventionBlobHash(
|
||||
bucket: R2Bucket,
|
||||
blobName: string
|
||||
): Promise<string | null> {
|
||||
const key = `invention/${inventionBlobName(blobName)}`
|
||||
const head = await bucket.head(key)
|
||||
if (head === null) return null
|
||||
const recorded = head.checksums.sha256
|
||||
if (recorded !== undefined) return toBase64(recorded)
|
||||
|
||||
const object = await bucket.get(key)
|
||||
return object === null
|
||||
? null
|
||||
: toBase64(await crypto.subtle.digest('SHA-256', await object.arrayBuffer()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields the client supplies on save (camelCase); everything else is defaulted here.
|
||||
* `inventionDataFilename` is the one the caller must supply — an invention with no
|
||||
@@ -163,6 +194,7 @@ export interface NewInvention {
|
||||
*/
|
||||
export async function createInvention(
|
||||
db: D1Database,
|
||||
bucket: R2Bucket,
|
||||
input: NewInvention
|
||||
): Promise<SavedInvention> {
|
||||
// Sequential id: one past the current max (the table starts empty).
|
||||
@@ -171,6 +203,7 @@ export async function createInvention(
|
||||
.first<{ next: number }>()
|
||||
const inventionId = row?.next ?? 1
|
||||
const now = new Date().toISOString()
|
||||
const blobName = inventionBlobName(input.inventionDataFilename)
|
||||
const invention: SavedInvention = {
|
||||
InventionId: inventionId,
|
||||
ReplicationId: crypto.randomUUID(),
|
||||
@@ -183,8 +216,8 @@ export async function createInvention(
|
||||
InventionId: inventionId,
|
||||
ReplicationId: crypto.randomUUID(),
|
||||
VersionNumber: 1,
|
||||
BlobName: inventionBlobName(input.inventionDataFilename),
|
||||
BlobHash: null,
|
||||
BlobName: blobName,
|
||||
BlobHash: await inventionBlobHash(bucket, blobName),
|
||||
InstantiationCost: input.instantiationCost ?? 0,
|
||||
LightsCost: input.lightsCost ?? 0,
|
||||
ChipsCost: input.chipsCost ?? 0,
|
||||
@@ -568,20 +601,38 @@ export async function getInventionsByRoom(
|
||||
*/
|
||||
export async function getInventionVersion(
|
||||
db: D1Database,
|
||||
bucket: R2Bucket,
|
||||
inventionId: number,
|
||||
versionNumber: number
|
||||
): Promise<InventionVersion | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
return invention.CurrentVersionNumber === versionNumber ? invention.CurrentVersion : null
|
||||
if (invention.CurrentVersionNumber !== versionNumber) return null
|
||||
|
||||
// A version saved before its blob finished uploading (or before we hashed on
|
||||
// save at all) carries no hash. Hash it now and keep the result, so the other
|
||||
// invention endpoints serve it too and this stays a one-time cost per blob.
|
||||
// ModifiedAt is deliberately left alone: reading a version is not an edit.
|
||||
if (invention.CurrentVersion.BlobHash === null) {
|
||||
const hash = await inventionBlobHash(bucket, invention.CurrentVersion.BlobName)
|
||||
if (hash !== null) {
|
||||
invention.CurrentVersion = { ...invention.CurrentVersion, BlobHash: hash }
|
||||
await storeInvention(db, invention)
|
||||
}
|
||||
}
|
||||
return invention.CurrentVersion
|
||||
}
|
||||
|
||||
/** Persist an edited invention record, bumping ModifiedAt. */
|
||||
async function writeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
|
||||
const updated: SavedInvention = { ...invention, ModifiedAt: new Date().toISOString() }
|
||||
await storeInvention(db, { ...invention, ModifiedAt: new Date().toISOString() })
|
||||
}
|
||||
|
||||
/** Write a record back as it stands — for changes that aren't edits (see above). */
|
||||
async function storeInvention(db: D1Database, invention: SavedInvention): Promise<void> {
|
||||
await db
|
||||
.prepare('UPDATE invention SET data = ?1 WHERE id = ?2')
|
||||
.bind(JSON.stringify(updated), invention.InventionId)
|
||||
.bind(JSON.stringify(invention), invention.InventionId)
|
||||
.run()
|
||||
}
|
||||
|
||||
|
||||
+10
-90
@@ -135,7 +135,7 @@ export const ApiConfigV2 = JsonObject.describe(
|
||||
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
|
||||
)
|
||||
|
||||
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build is one we accept. */
|
||||
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build matches GAME_VERSION. */
|
||||
export const VersionCheck = z.object({
|
||||
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
|
||||
UpdateNotificationStage: z.int(),
|
||||
@@ -206,7 +206,10 @@ export const InventionVersionDto = z.object({
|
||||
ReplicationId: z.string(),
|
||||
VersionNumber: z.int(),
|
||||
BlobName: z.string().describe('The `.inv` key in the storage worker‘s bucket'),
|
||||
BlobHash: z.string().nullable(),
|
||||
BlobHash: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Base64 SHA-256 of the blob; null when it was never uploaded'),
|
||||
InstantiationCost: z.int(),
|
||||
LightsCost: z.int(),
|
||||
ChipsCost: z.int(),
|
||||
@@ -352,86 +355,6 @@ export const CustomAvatarItemsPage = z.object({
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One custom-item save — the rebuilt version of a legacy avatar item. This is the
|
||||
* official shape, recorded for documentation: nothing stores custom items yet, so we
|
||||
* never actually emit one of these.
|
||||
*/
|
||||
export const CustomAvatarItemSave = z.object({
|
||||
customAvatarItemSaveId: z.int().describe('The save’s id'),
|
||||
customAvatarItemId: z.string().describe('Guid of the custom item this save belongs to'),
|
||||
unityAssetId: z.string().describe('Guid of the built Unity asset'),
|
||||
createdAt: z.string().describe('ISO 8601 timestamp'),
|
||||
thumbnailFileName: z.string(),
|
||||
additionalConfiguration: z.string(),
|
||||
unityAsset: z.string(),
|
||||
unityAssetHash: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The custom-item saves that replace a set of legacy avatar items, keyed by the legacy
|
||||
* item's `AvatarItemDesc`. Nothing stores custom items yet, so the map is always empty —
|
||||
* the value shape is documented rather than served.
|
||||
*/
|
||||
export const LegacyAvatarItemSaves = z.object({
|
||||
customAvatarItemSavesByAvatarItemDesc: z.record(z.string(), CustomAvatarItemSave),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /outfits/me` — the outfit envelope. Either the outfit stored in slot 0, served
|
||||
* back exactly as it was saved, or (for a player who has never saved) the brand-new-
|
||||
* account form, where every field that would carry an outfit is null/empty and
|
||||
* `DataVersion` is 9.
|
||||
*/
|
||||
export const OutfitsMeResponse = z.object({
|
||||
LegacyData: z.object({
|
||||
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
|
||||
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
|
||||
FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
|
||||
SkinColor: z.string().nullable(),
|
||||
HairColor: z.string().nullable(),
|
||||
}),
|
||||
Selections: JsonArray,
|
||||
DataVersion: z.int().describe('9 in the new-account envelope; whatever was saved otherwise'),
|
||||
CustomizationSettings: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
|
||||
ThumbnailFileName: z.string().nullable(),
|
||||
Name: z.string().nullable(),
|
||||
Accessibility: z.int(),
|
||||
Slot: z.int().describe('0 — the outfit being worn'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /outfits/me` JSON body — the outfit the client is saving, in the newer envelope.
|
||||
* The heavy fields are JSON-in-a-string, exactly as the client serialises them:
|
||||
* `SelectionsV2` and `CustomizationSettings` are whole documents encoded as strings, and
|
||||
* `FaceFeatures` likewise. Note the two formats overlap: `LegacyData` carries the old
|
||||
* flat descriptors while `CustomizationSettings` carries the same outfit in the new
|
||||
* structured form, and the client sends both. `Selections` arrives empty — the actual
|
||||
* selections are inside those strings.
|
||||
*/
|
||||
export const OutfitsMeRequest = z.object({
|
||||
DataVersion: z.int().describe('The client’s outfit format version (2 in observed saves)'),
|
||||
LegacyData: z.object({
|
||||
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
|
||||
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
|
||||
FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
|
||||
SkinColor: z.string().nullable(),
|
||||
HairColor: z.string().nullable(),
|
||||
}),
|
||||
CustomizationSettings: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
|
||||
Selections: JsonArray.describe('Empty in observed saves'),
|
||||
Slot: z.int(),
|
||||
Name: z.string().nullable(),
|
||||
Accessibility: z.int(),
|
||||
ThumbnailFileName: z.string().nullable(),
|
||||
})
|
||||
|
||||
/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
|
||||
export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() })
|
||||
|
||||
@@ -471,16 +394,13 @@ export const SubscriptionResponse = z.object({
|
||||
// ---- Moderation ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
||||
* answer (no ban storage yet), mirroring the reference server's stub
|
||||
* `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0,
|
||||
* which is a real category, and `Message` is null — the client distinguishes "no
|
||||
* message" from a blank one, so we send null where the reference sends an empty string.
|
||||
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
|
||||
* they carry their C# defaults (false / null).
|
||||
* `GET /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
||||
* answer (no ban storage yet). `ReportCategory` is -1 (no category) rather than 0,
|
||||
* which is a real category; `Message` is null, not an empty string — the client
|
||||
* distinguishes "no message" from a blank one.
|
||||
*/
|
||||
export const ModerationBlockDetails = z.object({
|
||||
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
|
||||
ReportCategory: z.int().describe('-1 = no category (0 is a real one)'),
|
||||
Duration: z.int(),
|
||||
GameSessionId: z.int(),
|
||||
IsBan: z.boolean(),
|
||||
|
||||
+16
-148
@@ -1,8 +1,6 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { CURRENT_OUTFIT_SLOT, getOutfit, setOutfit } from '@repo/domain'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
createInvention,
|
||||
@@ -41,9 +39,6 @@ import {
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
LegacyAvatarItemSaves,
|
||||
OutfitsMeRequest,
|
||||
OutfitsMeResponse,
|
||||
pageParams,
|
||||
SaveInventionRequest,
|
||||
SetTagsRequest,
|
||||
@@ -218,141 +213,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||
)
|
||||
|
||||
// The client asks which legacy avatar items have been rebuilt as custom items, so it
|
||||
// can render the custom version instead. Nothing stores custom items yet, so nothing
|
||||
// has a save — an empty list means "use the legacy items as-is".
|
||||
.post(
|
||||
'/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Custom-item saves for legacy avatar items',
|
||||
description:
|
||||
'Given a set of legacy avatar items, the custom-item saves that replace them, keyed ' +
|
||||
'by the legacy item’s `AvatarItemDesc`. Nothing stores custom items yet, so the map ' +
|
||||
'is always empty — which the client reads as “render the legacy items as-is”. The ' +
|
||||
'request body is ignored.\n\n' +
|
||||
'The value shape is the official one, recorded here for documentation; we never ' +
|
||||
'emit one until custom items are stored.',
|
||||
responses: { 200: json(LegacyAvatarItemSaves, 'An empty map') },
|
||||
}),
|
||||
(c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} })
|
||||
)
|
||||
|
||||
// The newer outfit read, on a bare (un-prefixed) path. Auth-gated. The outfit the
|
||||
// player is wearing is slot 0 of the shared `outfit` table (the same table the `econ`
|
||||
// worker's saved-outfit slots live in); a player who has never saved gets the
|
||||
// brand-new-account envelope instead.
|
||||
.get(
|
||||
'/outfits/me',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'The caller’s outfit',
|
||||
description:
|
||||
'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' +
|
||||
'`outfit` table — the newer client treats slot 0 as the outfit currently worn — and ' +
|
||||
'handed back exactly as it was saved, since the payload’s heavy fields are the ' +
|
||||
'client’s own JSON-in-a-string documents.\n\n' +
|
||||
'A player who has never saved gets the brand-new-account envelope: all-null ' +
|
||||
'`LegacyData`, no `Selections`, `DataVersion` 9.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(OutfitsMeResponse, 'The stored outfit, or the empty envelope'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const outfit = await getOutfit(c.env.DB, id, CURRENT_OUTFIT_SLOT)
|
||||
if (outfit !== null) return c.json(outfit)
|
||||
|
||||
return c.json({
|
||||
LegacyData: {
|
||||
SelectionsV1: null,
|
||||
SelectionsV2: null,
|
||||
FaceFeatures: null,
|
||||
SkinColor: null,
|
||||
HairColor: null,
|
||||
},
|
||||
Selections: [],
|
||||
DataVersion: 9,
|
||||
CustomizationSettings: null,
|
||||
ThumbnailFileName: null,
|
||||
Name: null,
|
||||
Accessibility: 0,
|
||||
Slot: 0,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Saving an outfit through the same bare path — into the slot the body names, which
|
||||
// is slot 0 for the outfit being worn. Stored verbatim: the heavy fields are the
|
||||
// client's own JSON-in-a-string documents, and re-encoding risks changing a payload
|
||||
// it has to parse back. Answers the saved outfit, which is what the client re-renders
|
||||
// from.
|
||||
.put(
|
||||
'/outfits/me',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Save the caller’s outfit',
|
||||
description:
|
||||
'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' +
|
||||
'outfit worn, which is what the GET reads. Re-saving a slot overwrites it.\n\n' +
|
||||
'The payload is stored verbatim and answered back: its heavy fields (`SelectionsV2`, ' +
|
||||
'`FaceFeatures`, `CustomizationSettings`) are whole JSON documents encoded as ' +
|
||||
'strings by the client’s own serializer, so nothing here parses or re-encodes them.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'),
|
||||
responses: {
|
||||
200: json(OutfitsMeRequest, 'The outfit as stored'),
|
||||
400: json(ErrorResponse, 'Unparseable body'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||
|
||||
// The client sends `Slot`; a body without one saves the worn outfit.
|
||||
const outfit = {
|
||||
...body,
|
||||
Slot: typeof body.Slot === 'number' ? body.Slot : CURRENT_OUTFIT_SLOT,
|
||||
}
|
||||
await setOutfit(c.env.DB, id, outfit)
|
||||
return c.json(outfit)
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's outfit wardrobe. An empty list for now — the outfits saved through
|
||||
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them
|
||||
// belong in this list (and in what shape) has not been pinned down, so it answers []
|
||||
// rather than guessing.
|
||||
.get(
|
||||
'/outfits/me/saved',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'The caller’s saved outfits',
|
||||
description:
|
||||
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
|
||||
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' +
|
||||
'list should carry, and in what shape, is not pinned down yet.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonArray, 'An empty list'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
|
||||
// or 404 when there's no such invention.
|
||||
.get(
|
||||
@@ -470,18 +330,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
)
|
||||
|
||||
// A single version of an invention (`?inventionId=…&version=…`) — the bare
|
||||
// RRInventionVersion, which carries the blob name the client downloads. Public.
|
||||
// Only the current version exists (nothing writes version history yet), so any
|
||||
// other version number 404s rather than naming a blob that isn't there.
|
||||
// RRInventionVersion, which carries the blob name the client downloads and the
|
||||
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
|
||||
// version history yet), so any other version number 404s rather than naming a
|
||||
// blob that isn't there.
|
||||
.get(
|
||||
'/api/inventions/v1/version',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'One version of an invention',
|
||||
description:
|
||||
'The bare `RRInventionVersion`, which carries the blob name the client downloads. ' +
|
||||
'Only the current version exists — nothing writes version history yet — so any ' +
|
||||
'other version number 404s rather than naming a blob that is not there.',
|
||||
'The bare `RRInventionVersion`, which carries the blob name the client downloads ' +
|
||||
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
|
||||
'never uploaded). Only the current version exists — nothing writes version ' +
|
||||
'history yet — so any other version number 404s rather than naming a blob that ' +
|
||||
'is not there.',
|
||||
parameters: [
|
||||
intQuery('inventionId', 'Invention id; required'),
|
||||
intQuery('version', 'Version number; required'),
|
||||
@@ -498,7 +361,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
const versionNumber = Number.parseInt(c.req.query('version') ?? '', 10)
|
||||
if (Number.isNaN(versionNumber)) return c.json({ error: 'version is required' }, 400)
|
||||
|
||||
const version = await getInventionVersion(c.env.DB, inventionId, versionNumber)
|
||||
const version = await getInventionVersion(
|
||||
c.env.DB,
|
||||
c.env.CDN_ASSETS,
|
||||
inventionId,
|
||||
versionNumber
|
||||
)
|
||||
return version === null ? c.notFound() : c.json(version)
|
||||
}
|
||||
)
|
||||
@@ -844,7 +712,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
||||
}
|
||||
|
||||
const invention = await createInvention(c.env.DB, {
|
||||
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
|
||||
creatorPlayerId: id,
|
||||
inventionDataFilename,
|
||||
name: str(body.name),
|
||||
|
||||
@@ -17,19 +17,6 @@ import {
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
/**
|
||||
* Client builds the version check answers as current. `GAME_VERSION` is the build the
|
||||
* rest of the stack targets; `20230616` and `20231207` are later clients that talk the
|
||||
* same protocol, so we let them through rather than telling them to update.
|
||||
*
|
||||
* DEBUGGING ONLY: the extra builds are here so we can point other clients at this
|
||||
* server while working on it — they are not a supported-version list. Nothing else in
|
||||
* the stack targets them, so a client waved through here can still hit protocol
|
||||
* differences the version check would otherwise have caught. Trim this back to
|
||||
* `GAME_VERSION` alone before anyone but us is playing.
|
||||
*/
|
||||
const ACCEPTED_GAME_VERSIONS = new Set([GAME_VERSION, '20230616', '20231207'])
|
||||
|
||||
// ---- Config / version ------------------------------------------------------
|
||||
export const configRoutes = new Hono<App>({ strict: false })
|
||||
.get(
|
||||
@@ -113,13 +100,13 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Client version check',
|
||||
description:
|
||||
'Whether the client build is current. Compares the client’s `?v=` build against ' +
|
||||
'the builds we accept — our target `GAME_VERSION` plus `20230616`: ' +
|
||||
'`VersionStatus` is 0 for either, 1 for any other build.',
|
||||
'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
|
||||
'client is on a different build.',
|
||||
responses: { 200: json(VersionCheck, 'Version status') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
VersionStatus: ACCEPTED_GAME_VERSIONS.has(c.req.query('v') ?? '') ? 0 : 1,
|
||||
VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
|
||||
@@ -14,29 +14,21 @@ import type { App } from '../context'
|
||||
|
||||
// ---- Player reporting ------------------------------------------------------
|
||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). No ban
|
||||
// storage yet, so this is always the "not blocked" answer — the reference server's
|
||||
// stub `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1), not 0,
|
||||
// which is a real category. `Message` is null rather than the empty string that stub
|
||||
// sends: the client distinguishes "no message" from a blank one.
|
||||
// `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but left unset there, so they
|
||||
// go out with their C# defaults.
|
||||
// POST with no body is the client's actual call, despite this being a pure read; it
|
||||
// answers GET too, so the path is reachable either way.
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
|
||||
// ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is
|
||||
// -1 (no category) rather than 0, which is a real category; `Message` is null, not
|
||||
// an empty string — the client distinguishes "no message" from a blank one.
|
||||
.get(
|
||||
'/api/PlayerReporting/v1/moderationBlockDetails',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Whether the caller is blocked',
|
||||
description:
|
||||
'Ban / timeout / host-kick state for the caller. There is no ban storage yet, so ' +
|
||||
'this is always the “not blocked” answer, following the reference server’s stub: ' +
|
||||
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category. ' +
|
||||
'`Message` is null rather than the empty string that stub sends — the client ' +
|
||||
'distinguishes “no message” from a blank one. `IsVoiceModAutoban` and ' +
|
||||
'`TimeoutStartedAt` are on the DTO but unset by that stub, so they carry their ' +
|
||||
'defaults.',
|
||||
'this is always the “not blocked” answer. Two details matter to the client: ' +
|
||||
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
|
||||
'`Message` is null rather than an empty string — the client distinguishes “no ' +
|
||||
'message” from a blank one.',
|
||||
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
|
||||
}),
|
||||
(c) =>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
GAME_VERSION,
|
||||
OUTFIT_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
@@ -49,14 +49,9 @@ const TEST_ROOMS = [
|
||||
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')
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS room (
|
||||
data TEXT NOT NULL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
||||
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Subrooms live in their own table now; getRoomById hydrates from it, so create it and
|
||||
// split each seeded room's subrooms into it (mirrors the rooms worker's 0007 migration).
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
@@ -84,9 +79,6 @@ beforeAll(async () => {
|
||||
// Relationships table (owned by the api worker) — friendship endpoints use it.
|
||||
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0.
|
||||
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Inventions table (owned by the api worker) — invention save/mine use it.
|
||||
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
@@ -118,6 +110,12 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
|
||||
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
|
||||
}
|
||||
|
||||
/** Base64 SHA-256 — the form an invention version's `BlobHash` takes. */
|
||||
async function base64Sha256(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
||||
}
|
||||
|
||||
describe('public endpoints', () => {
|
||||
test('GET /api/config/v1/amplitude', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
|
||||
@@ -152,11 +150,6 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 reports current for the 20230616 build', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=20230616`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||
@@ -241,31 +234,24 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
// The client POSTs this with no body, despite it being a pure read; the route answers
|
||||
// GET as well, and both methods serve the same body.
|
||||
test.each(['GET', 'POST'])(
|
||||
'%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"',
|
||||
async (method) => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
|
||||
{ method }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
|
||||
// reference stub's empty string — the client tells "no message" from a blank one.
|
||||
expect(await res.json()).toEqual({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// ReportCategory -1 = no category (0 is a real one), and Message is null.
|
||||
expect(await res.json()).toEqual({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
})
|
||||
|
||||
// Unauthenticated by design — the client posts this before it has an account, so
|
||||
// there's no bearer token to check and nothing to attribute the id to.
|
||||
@@ -358,128 +344,6 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
test('POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems returns an empty map', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ AvatarItemIds: [1, 2, 3] }),
|
||||
}
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ customAvatarItemSavesByAvatarItemDesc: {} })
|
||||
})
|
||||
|
||||
test('GET /outfits/me 401s without a token, serves the empty envelope for a new player', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`)
|
||||
expect(anon.status).toBe(401)
|
||||
// Account 77 never saves an outfit, so it keeps getting the new-account envelope.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer('77') })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
LegacyData: {
|
||||
SelectionsV1: null,
|
||||
SelectionsV2: null,
|
||||
FaceFeatures: null,
|
||||
SkinColor: null,
|
||||
HairColor: null,
|
||||
},
|
||||
Selections: [],
|
||||
DataVersion: 9,
|
||||
CustomizationSettings: null,
|
||||
ThumbnailFileName: null,
|
||||
Name: null,
|
||||
Accessibility: 0,
|
||||
Slot: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test('PUT /outfits/me saves into slot 0; GET reads it back verbatim', async () => {
|
||||
// The client's own payload, trimmed to one selection: the point is that the heavy
|
||||
// JSON-in-a-string fields survive the round trip as strings, unparsed.
|
||||
const outfit = {
|
||||
DataVersion: 2,
|
||||
LegacyData: {
|
||||
SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0',
|
||||
SelectionsV2:
|
||||
'{"selections":[{"PrefabGuid":"193a3bf9-abc0-4d78-8d63-92046908b1c5","CombinationGuid":"","BodyPart":0}]}',
|
||||
FaceFeatures: '{"ver":7,"eyeId":"Aeu0yxJXG0qCOLZW5Tcu7A","hideEars":false}',
|
||||
SkinColor: 'Dc6StLFk60u5iUTrb3_C3w',
|
||||
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
|
||||
},
|
||||
CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}',
|
||||
Selections: [],
|
||||
Slot: 0,
|
||||
Name: null,
|
||||
Accessibility: 1,
|
||||
ThumbnailFileName: null,
|
||||
}
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(outfit),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(outfit),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual(outfit)
|
||||
|
||||
// The read serves it back byte-for-byte — the JSON-in-a-string fields are still
|
||||
// strings, not re-encoded objects.
|
||||
const read = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
|
||||
expect(await read.json()).toEqual(outfit)
|
||||
|
||||
// Re-saving overwrites slot 0 rather than adding a second row.
|
||||
const changed = { ...outfit, LegacyData: { ...outfit.LegacyData, SkinColor: 'changed' } }
|
||||
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(changed),
|
||||
})
|
||||
const reread = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
|
||||
expect(await reread.json()).toEqual(changed)
|
||||
const rows = await env.DB.prepare(
|
||||
'SELECT COUNT(*) AS n FROM outfit WHERE account_id = 42'
|
||||
).first<{ n: number }>()
|
||||
expect(rows?.n).toBe(1)
|
||||
|
||||
// A save naming another slot does not touch what the caller is wearing.
|
||||
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...changed, Slot: 3, Name: 'slot three' }),
|
||||
})
|
||||
const worn = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
|
||||
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
|
||||
})
|
||||
|
||||
test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
|
||||
expect(anon.status).toBe(401)
|
||||
// Empty even for account 42, which saved an outfit through PUT /outfits/me above.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('PUT /outfits/me 400s on an unparseable body', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer()), 'content-type': 'application/json' },
|
||||
body: 'not json',
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -908,6 +772,12 @@ describe('public endpoints', () => {
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
|
||||
// The data file is uploaded (via the storage worker) before the metadata save,
|
||||
// so the version carries its hash from the start. No sha256 recorded on this
|
||||
// object — the api worker digests the blob itself in that case.
|
||||
const data = new Uint8Array([1, 2, 3, 4])
|
||||
await env.CDN_ASSETS.put('invention/2026-07-12/lamp.inv', data)
|
||||
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('7373')), 'Content-Type': 'application/json' },
|
||||
@@ -919,7 +789,8 @@ describe('public endpoints', () => {
|
||||
})
|
||||
const { Invention } = (await save.json()) as InventionSaveResult
|
||||
|
||||
// The bare RRInventionVersion — the blob name is what the client downloads.
|
||||
// The bare RRInventionVersion — the blob name is what the client downloads,
|
||||
// BlobHash the base64 SHA-256 of what it will download.
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
|
||||
)
|
||||
@@ -928,6 +799,7 @@ describe('public endpoints', () => {
|
||||
InventionId: Invention.InventionId,
|
||||
VersionNumber: 1,
|
||||
BlobName: '2026-07-12/lamp.inv',
|
||||
BlobHash: await base64Sha256(data),
|
||||
InstantiationCost: 42,
|
||||
})
|
||||
|
||||
@@ -950,6 +822,44 @@ describe('public endpoints', () => {
|
||||
expect(noId.status).toBe(400)
|
||||
})
|
||||
|
||||
test('BlobHash is null until the blob exists, then backfilled onto the invention', async () => {
|
||||
// Saved before the upload landed: nothing to hash, so the field stays null
|
||||
// rather than carrying a hash of something the client can't download.
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('7474')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Late Lamp', inventionDataFilename: '2026-07-12/late.inv' }),
|
||||
})
|
||||
const { Invention, InventionVersion } = (await save.json()) as InventionSaveResult
|
||||
expect(InventionVersion.BlobHash).toBeNull()
|
||||
|
||||
const version = async (): Promise<Record<string, unknown>> => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=1`
|
||||
)
|
||||
return (await res.json()) as Record<string, unknown>
|
||||
}
|
||||
expect((await version()).BlobHash).toBeNull()
|
||||
|
||||
// Once the blob is there the hash resolves — here from the checksum recorded at
|
||||
// upload time (what the storage worker puts), not by digesting the body.
|
||||
const data = new Uint8Array([9, 8, 7])
|
||||
await env.CDN_ASSETS.put('invention/2026-07-12/late.inv', data, {
|
||||
sha256: await crypto.subtle.digest('SHA-256', data),
|
||||
})
|
||||
const hash = await base64Sha256(data)
|
||||
expect((await version()).BlobHash).toBe(hash)
|
||||
|
||||
// And it's kept, so the other invention endpoints serve it too — without the
|
||||
// read counting as an edit (ModifiedAt is untouched).
|
||||
const details = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${Invention.InventionId}`
|
||||
)
|
||||
const stored = (await details.json()) as SavedInvention
|
||||
expect(stored.CurrentVersion.BlobHash).toBe(hash)
|
||||
expect(stored.ModifiedAt).toBe(Invention.ModifiedAt)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/update edits metadata + permission, creator only', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
@@ -2102,15 +2012,11 @@ describe('openapi', () => {
|
||||
'GET /api/roomkeys/v1/room',
|
||||
'GET /api/rooms/v1/filters',
|
||||
'GET /api/versioncheck/v4',
|
||||
'GET /outfits/me',
|
||||
'GET /outfits/me/saved',
|
||||
'GET /voice/config',
|
||||
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
'POST /api/PlayerReporting/v1/deviceId',
|
||||
'POST /api/PlayerReporting/v1/hile',
|
||||
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
'POST /api/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
'POST /api/images/v4/uploadsaved',
|
||||
@@ -2135,7 +2041,6 @@ describe('openapi', () => {
|
||||
'POST /api/sanitize/v1',
|
||||
'POST /api/sanitize/v1/isPure',
|
||||
'POST /api/v1/progression/bulk',
|
||||
'PUT /outfits/me',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — an undescribed one renders as a bare path.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"CurrentAnnouncement": {
|
||||
"Message": "Server powered by RecFlare",
|
||||
"MoreInfoUrl": "https://github.com/djdevin/recflare"
|
||||
"MoreInfoUrl": "https://recflare.net"
|
||||
},
|
||||
"InstagramImages": [
|
||||
{
|
||||
|
||||
@@ -19,11 +19,17 @@
|
||||
}
|
||||
],
|
||||
// Image bucket shared with the `img` worker (which serves objects back by key).
|
||||
// Saved-image uploads are written here.
|
||||
// Saved-image uploads are written here. The `recflare-cdn` bucket (owned by the
|
||||
// `cdn` worker, written by `storage`) is bound read-only alongside it, to hash an
|
||||
// invention's uploaded data blob for its `BlobHash`.
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "IMAGES",
|
||||
"bucket_name": "recflare-img"
|
||||
},
|
||||
{
|
||||
"binding": "CDN_ASSETS",
|
||||
"bucket_name": "recflare-cdn"
|
||||
}
|
||||
],
|
||||
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
||||
|
||||
+72
-20
@@ -42,35 +42,79 @@ route without documenting it fails rather than silently shipping an incomplete s
|
||||
without matchmaking. A posted `password` becomes the login credential.
|
||||
- **`cached_login`** — logs into an already-linked account using platform ownership as
|
||||
the credential; no password. The posted `account_id` must be linked to exactly the
|
||||
identity the Steam ticket proves.
|
||||
identity `platform_auth` proves.
|
||||
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
|
||||
30-day TTL; platform and platform id come from what was stored at issue time.
|
||||
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
|
||||
the account by `username` or numeric `account_id` and requires the matching password
|
||||
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
|
||||
all, which is what closes id/username-only takeover.
|
||||
all, which is what closes id/username-only takeover. When it also carries a verifying
|
||||
`platform_auth`, that identity is **linked** to the account (see below).
|
||||
|
||||
Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role`
|
||||
claim, so developer/moderator powers refresh on every login and every refresh grant.
|
||||
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
|
||||
|
||||
### Steam is the only verifiable platform
|
||||
### Verifiable platforms: Steam and Meta
|
||||
|
||||
`platform_auth` tickets are verified **offline** — `src/steam-ticket.ts` parses the
|
||||
ticket and checks Steam's signature against Steam's system public key. No publisher
|
||||
Web API key, no network call. Steam (platform `0`) is therefore the only platform
|
||||
whose identity can be proven, so any grant that authenticates _by platform identity_
|
||||
(`cached_login`, and `create_account` when it asserts a platform) must be Steam. The
|
||||
verified SteamID64 replaces the client-supplied `platform_id` and is the only value
|
||||
ever written to an account's `platformId`.
|
||||
Only an identity we can _prove_ is ever bound to an account, so any grant that
|
||||
authenticates _by platform identity_ (`cached_login`, and `create_account` when it
|
||||
asserts a platform) must be a platform we can verify. Two are:
|
||||
|
||||
- **Steam (`0`)** — `src/steam-ticket.ts` parses the `platform_auth` ticket and checks
|
||||
Steam's signature against Steam's system public key. Verified **offline**: no
|
||||
publisher Web API key, no network call. The SteamID64 the ticket carries replaces the
|
||||
client-supplied `platform_id`.
|
||||
- **Meta / Oculus (`1`)** — `src/meta-nonce.ts` posts the nonce in `platform_auth` to
|
||||
`graph.oculus.com/user_nonce_validate`, authenticated as the app with
|
||||
`META_APP_SECRET`. Meta's nonce proves nothing by itself; validation is what binds it
|
||||
to a user id, so here the posted `platform_id` is an _input_ to the check and a
|
||||
spoofed one fails. This means an outbound request on every Meta login, and no Meta
|
||||
login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather
|
||||
than falling back to trusting the client.
|
||||
|
||||
Everything else is refused. Whichever platform, the identity that gets bound or linked
|
||||
is the verified one, never the raw `platform_id` field.
|
||||
|
||||
### One account, many platform identities
|
||||
|
||||
An account can be reached from several platform identities — a player's PC and their
|
||||
headset both open the same account, with no password after the first time. The links
|
||||
live in the `platform_account` table (`src/platform-db.ts`, migration 0007), one row per
|
||||
(platform, platform id, account).
|
||||
|
||||
That table is the **one source of truth** for both halves of a cached login: the picker
|
||||
(`/cachedlogin/forplatformid`) lists the accounts an identity links to, and the
|
||||
`cached_login` grant asks it whether the account it was handed is linked to the identity
|
||||
just proven. They used to be two separate checks over the account blob's single
|
||||
`platformId`, which could disagree — the client would be offered an account that then
|
||||
answered "no linked account" forever.
|
||||
|
||||
A second device is linked by **logging in with a password there**: the client posts its
|
||||
`platform_auth` alongside the password, and a proof that verifies becomes a link. Only a
|
||||
verified identity is ever linked, since a link is a password-free way into the account.
|
||||
A proof that doesn't verify never fails the login — it just leaves that device without a
|
||||
cached login.
|
||||
|
||||
The account blob keeps `platform`/`platformId` as the account's **primary** identity
|
||||
(the first one linked). It feeds the account DTO and a refreshed token's claims, and
|
||||
nothing authorizes off it. It is no longer indexed: migration 0008 drops the
|
||||
`account.platform_id` generated column that 0004 added, since leaving a queryable copy
|
||||
of one identity per account invites exactly the picker/grant disagreement above. Look
|
||||
identities up in `platform_account`.
|
||||
|
||||
## Signup caps
|
||||
|
||||
`create_account` is capped on two independent arms, per verified platform id and per
|
||||
signup IP. The platform arm can't be spoofed or reset by changing networks; the IP arm
|
||||
is coarse and will produce false positives behind NAT, shared campus and mobile
|
||||
`create_account` is capped on two independent arms, per verified platform identity and
|
||||
per signup IP. The platform arm can't be spoofed or reset by changing networks; the IP
|
||||
arm is coarse and will produce false positives behind NAT, shared campus and mobile
|
||||
networks. Both default to 3.
|
||||
|
||||
The platform arm also caps **linking**, or it wouldn't be a cap: an identity at the
|
||||
limit could otherwise have accounts created for it with a password and link its way into
|
||||
all of them. Hitting it never fails a password login — the account just doesn't get a
|
||||
cached login on that device.
|
||||
|
||||
Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`,
|
||||
`RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means
|
||||
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
|
||||
@@ -78,11 +122,12 @@ small private server, or when a shared network is being locked out.
|
||||
|
||||
## Bindings
|
||||
|
||||
| Binding | Type | Notes |
|
||||
| -------------------- | ------------- | ------------------------------------------------------ |
|
||||
| `DB` | D1 | Shared `recflare` database; this worker owns `account` |
|
||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
||||
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
|
||||
| Binding | Type | Notes |
|
||||
| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` |
|
||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
||||
| `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce |
|
||||
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
|
||||
|
||||
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
|
||||
table, so they stay independent of the `rooms` worker's migrations on the same
|
||||
@@ -109,12 +154,19 @@ wrangler secrets-store store create recflare --scopes workers
|
||||
|
||||
# Set the shared signing key (prompted for the value)
|
||||
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
|
||||
|
||||
# Set the Meta app secret. Required for the deploy to succeed even with no Meta app —
|
||||
# a binding to a missing secret is a deploy error. Any placeholder will do; Meta
|
||||
# sign-ins then answer 500 until it holds the real value.
|
||||
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
|
||||
```
|
||||
|
||||
For local `wrangler dev`, seed a local value (omit `--remote`) so `.get()` resolves:
|
||||
For local `wrangler dev`, seed local values (omit `--remote`) so `.get()` resolves:
|
||||
|
||||
```sh
|
||||
wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> --scopes workers
|
||||
wrangler secrets-store secret create local --name META_APP_SECRET --value <app-secret> --scopes workers
|
||||
```
|
||||
|
||||
Rotating the store value invalidates all existing tokens (clients re-authenticate).
|
||||
Rotating the signing key invalidates all existing tokens (clients re-authenticate).
|
||||
The Meta secret is read per request, so updating it takes effect without a redeploy.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Let one account be linked to MORE THAN ONE platform identity, so a player with a
|
||||
-- PC and a headset gets a cached login on both. The account blob's single
|
||||
-- `platformId`/`platform` pair could only hold one, so logging in on the second
|
||||
-- device meant a password every time.
|
||||
--
|
||||
-- Links move into their own table, which becomes the one source of truth for both
|
||||
-- halves of a cached login (the picker and the `cached_login` grant). The blob fields
|
||||
-- stay as the account's *primary* identity — the first one linked — for the account
|
||||
-- DTO and the refresh grant's claims; nothing authorizes off them any more. Kept in
|
||||
-- sync with PLATFORM_SCHEMA_DDL in src/platform-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform_account (
|
||||
account_id INTEGER NOT NULL,
|
||||
platform INTEGER NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
linked_at TEXT NOT NULL,
|
||||
PRIMARY KEY (platform, platform_id, account_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id);
|
||||
|
||||
-- Backfill every identity already bound to an account. `platform` is COALESCEd to 0
|
||||
-- because nothing ever defaulted that field: an account can carry a platformId with no
|
||||
-- platform recorded, and back when Steam was the only verifiable platform an unset one
|
||||
-- *was* Steam. Without the COALESCE those accounts would lose their cached login at
|
||||
-- deploy. Mirrored as PLATFORM_BACKFILL_SQL in src/platform-db.ts, which is what the
|
||||
-- tests run.
|
||||
INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
SELECT
|
||||
account_id,
|
||||
COALESCE(json_extract(data, '$.platform'), 0),
|
||||
platform_id,
|
||||
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
|
||||
FROM account
|
||||
WHERE platform_id IS NOT NULL AND platform_id <> '';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Drop the `platform_id` generated column added by 0004. Nothing reads it any more:
|
||||
-- 0007 moved every account ↔ identity link into `platform_account`, which is now the
|
||||
-- one source of truth for the login picker and the `cached_login` grant. The column's
|
||||
-- last reader was 0007's own backfill, which has already run.
|
||||
--
|
||||
-- Leaving it would leave a SECOND, stale answer to "which account does this identity
|
||||
-- open?" — it only ever holds the account's primary identity, so an account reachable
|
||||
-- from a PC and a headset appears here under one of them. That is exactly the split
|
||||
-- that used to have the picker offer an account the grant then refused.
|
||||
--
|
||||
-- The underlying `platformId` in the JSON blob STAYS: it is the account's primary
|
||||
-- identity, and feeds the account DTO and a refreshed token's claims. This drops the
|
||||
-- generated column and its index only — a virtual column stores nothing, so no account
|
||||
-- data is rewritten or lost. The index has to go first; SQLite refuses to drop an
|
||||
-- indexed column. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts.
|
||||
--
|
||||
-- Safe to run before or after the deploy that ships it: no worker queries this column,
|
||||
-- so the currently-deployed code doesn't notice it go. (`PLATFORM_BACKFILL_SQL` in
|
||||
-- src/platform-db.ts still names it in 0007's text — that statement has run and won't
|
||||
-- run again; the exported copy selects the blob instead so tests keep working.)
|
||||
|
||||
DROP INDEX IF EXISTS idx_accounts_platform_id;
|
||||
ALTER TABLE account DROP COLUMN platform_id;
|
||||
+322
-130
@@ -3,13 +3,12 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
countAccountsByPlatformId,
|
||||
countAccountsBySignupIp,
|
||||
createAccount,
|
||||
GAME_VERSION,
|
||||
getAccount,
|
||||
getAccountByUsername,
|
||||
getAccountsByPlatformId,
|
||||
getAccountsByIds,
|
||||
getPasswordHash,
|
||||
getRoomById,
|
||||
hashPassword,
|
||||
@@ -19,16 +18,17 @@ import {
|
||||
setPasswordHash,
|
||||
setPresence,
|
||||
subRoomDataBlob,
|
||||
updateAccount,
|
||||
verifyPassword,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import { verifyMetaNonce } from './meta-nonce'
|
||||
import {
|
||||
CachedLogin,
|
||||
ChangePasswordRequest,
|
||||
ChangePasswordResponse,
|
||||
FakeCachedLogin,
|
||||
form,
|
||||
json,
|
||||
OAuthError,
|
||||
@@ -38,26 +38,25 @@ import {
|
||||
TokenRequest,
|
||||
TokenResponse,
|
||||
} from './openapi'
|
||||
import {
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForPlatformId,
|
||||
getLinksForPlatformIdentity,
|
||||
isPlatformIdentityLinked,
|
||||
linkPlatformIdentity,
|
||||
} from './platform-db'
|
||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||
import { verifySteamTicket } from './steam-ticket'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
import type { PlatformLink } from './platform-db'
|
||||
|
||||
/** OAuth scopes granted by `/connect/token`. */
|
||||
const TOKEN_SCOPE =
|
||||
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
||||
|
||||
/** The canned entry served for any Oculus cached-login lookup. See the route below. */
|
||||
const FAKE_OCULUS_CACHED_LOGIN = {
|
||||
platform: PlatformType.Oculus,
|
||||
platformId: '1',
|
||||
accountId: 1,
|
||||
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
||||
requirePassword: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Signup caps, enforced on create_account only (never on login — an existing account
|
||||
* always stays reachable, however many accounts its owner has since accumulated).
|
||||
@@ -166,48 +165,155 @@ function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | nu
|
||||
/**
|
||||
* The platform an account's `platformId` belongs to. Nothing defaults the `platform`
|
||||
* field (see defaultAccount), so an account can carry a platform identity with no
|
||||
* platform recorded — and Steam is the only platform whose identity we can prove, so
|
||||
* an unset one *is* Steam.
|
||||
* platform recorded — and until Meta verification landed Steam was the only identity
|
||||
* we could prove, so an unset one *is* Steam. Every account bound since records its
|
||||
* platform explicitly; this default only covers those older rows.
|
||||
*/
|
||||
function accountPlatform(account: Pick<Account, 'platform'>): number {
|
||||
return account.platform ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account is the one linked to a given platform identity — the single
|
||||
* check behind both the cached-login picker and the `cached_login` grant. It lives in
|
||||
* one place on purpose: if the picker offers an account the grant then rejects, the
|
||||
* client is handed an `account_id` it can never log into ("no linked account for this
|
||||
* platform identity" on every attempt).
|
||||
*
|
||||
* `platformId` must be the *proven* identity (the SteamID64 from a verified
|
||||
* platform_auth ticket), never the client-supplied `platform_id` field.
|
||||
*/
|
||||
export function isLinkedToPlatformIdentity(
|
||||
account: Pick<Account, 'platform' | 'platformId'>,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): boolean {
|
||||
if (!account.platformId || platformId === '') return false
|
||||
return account.platformId === platformId && accountPlatform(account) === platform
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a linked account into the client's CachedLogin DTO — the account-picker
|
||||
* entry on the login screen. The client posts the chosen `accountId` back as a
|
||||
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
|
||||
* (the platform_auth ticket) is the credential for a cached login — no prompt.
|
||||
* (the verified `platform_auth`) is the credential for a cached login — no prompt.
|
||||
*
|
||||
* The platform and id come from the LINK, not from the account: an account linked to
|
||||
* both a Steam and a Meta identity appears in both pickers, and each has to report the
|
||||
* identity that picker was asked about — that's what the client posts back, and what
|
||||
* the grant then checks the link against.
|
||||
*/
|
||||
function toCachedLogin(account: Account) {
|
||||
function toCachedLogin(account: Account, link: PlatformLink) {
|
||||
return {
|
||||
platform: accountPlatform(account),
|
||||
platformId: account.platformId ?? '',
|
||||
platform: link.platform,
|
||||
platformId: link.platformId,
|
||||
accountId: account.accountId,
|
||||
lastLoginTime: account.lastLoginTime ?? account.createdAt,
|
||||
requirePassword: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a set of links into picker entries, dropping any whose account no longer
|
||||
* exists. One batched account read rather than one per link.
|
||||
*
|
||||
* Order follows the links (oldest first), so the picker is stable between launches.
|
||||
*/
|
||||
async function toCachedLogins(db: D1Database, links: PlatformLink[]) {
|
||||
if (links.length === 0) return []
|
||||
const accounts = await getAccountsByIds(db, [...new Set(links.map((l) => l.accountId))])
|
||||
const byId = new Map(accounts.map((a) => [a.accountId, a]))
|
||||
return links.flatMap((link) => {
|
||||
const account = byId.get(link.accountId)
|
||||
return account ? [toCachedLogin(account, link)] : []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the platform identity a password login proved to the account it logged into,
|
||||
* so the next launch on that device is a cached login. Called only with a VERIFIED
|
||||
* identity — a link is a password-free way into the account.
|
||||
*
|
||||
* Already linked is the common case (every subsequent login on that device) and costs
|
||||
* one read and nothing else.
|
||||
*
|
||||
* The per-identity cap applies here as well as at signup, or it wouldn't be a cap:
|
||||
* an identity could otherwise sit at the limit, have accounts created for it with a
|
||||
* password, and link its way into all of them. Reaching it does NOT fail the login —
|
||||
* the password was valid — it just leaves the account without a cached login, so the
|
||||
* player types their password each time rather than being locked out.
|
||||
*
|
||||
* The first identity linked also becomes the account's primary (the blob's
|
||||
* `platform`/`platformId`), which is what the account DTO and the refresh grant's
|
||||
* claims report. Later platforms link without disturbing it.
|
||||
*/
|
||||
async function linkLoginIdentity(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string,
|
||||
maxAccountsPerIdentity: number
|
||||
): Promise<void> {
|
||||
if (await isPlatformIdentityLinked(db, accountId, platform, platformId)) return
|
||||
|
||||
if (
|
||||
maxAccountsPerIdentity > 0 &&
|
||||
(await countAccountsForPlatformIdentity(db, platform, platformId)) >= maxAccountsPerIdentity
|
||||
) {
|
||||
logger.info('platform link refused: account limit reached for this platform identity', {
|
||||
accountId,
|
||||
platform,
|
||||
platformId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await linkPlatformIdentity(db, accountId, platform, platformId))) return
|
||||
logger.info('linked platform identity to account', { accountId, platform, platformId })
|
||||
|
||||
const account = await getAccount(db, accountId)
|
||||
if (account && !account.platformId) {
|
||||
await updateAccount(db, accountId, { platform, platformId })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a login's `platform_auth` proved, if anything. Failures are split because the
|
||||
* callers act on them differently: a grant that authenticates BY platform identity has
|
||||
* to refuse, while a password grant — which has already proven who it is — carries on
|
||||
* and just doesn't link.
|
||||
*
|
||||
* `unconfigured` is an operator problem (no META_APP_SECRET), not a bad credential,
|
||||
* and is the one case that warrants a 5xx.
|
||||
*/
|
||||
type PlatformProof =
|
||||
/** Nothing was checked — the login offered no proof, so there is nothing to report. */
|
||||
| { status: 'none' }
|
||||
| { status: 'verified'; platform: number; platformId: string }
|
||||
| { status: 'unsupported' }
|
||||
| { status: 'unconfigured' }
|
||||
| { status: 'rejected'; reason: string }
|
||||
|
||||
/**
|
||||
* Verify a login's `platform_auth` and return the identity it proves.
|
||||
*
|
||||
* The two verifiable platforms prove the id in opposite directions, which is why they
|
||||
* can't share a code path: Steam's ticket *carries* a SteamID64 we read out and trust,
|
||||
* so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is
|
||||
* validated *against* the posted `platform_id`, so that field is an input, and a
|
||||
* spoofed one fails validation rather than being ignored. Either way the id that comes
|
||||
* back is proven, never the raw client-supplied field, and only a proven id is ever
|
||||
* written to an account or linked to one.
|
||||
*/
|
||||
async function verifyPlatformProof(
|
||||
env: App['Bindings'],
|
||||
platform: number,
|
||||
platformAuth: string,
|
||||
postedPlatformId: string
|
||||
): Promise<PlatformProof> {
|
||||
if (platform === PlatformType.Steam) {
|
||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||
if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' }
|
||||
return { status: 'verified', platform: PlatformType.Steam, platformId: verified.steamId }
|
||||
}
|
||||
if (platform === PlatformType.Oculus) {
|
||||
// `.get()` throws when the secret doesn't exist in the store at all (as opposed to
|
||||
// holding an empty/placeholder value) — the same misconfiguration from the player's
|
||||
// side, so it takes the same branch.
|
||||
const appSecret = await env.META_APP_SECRET.get().catch(() => '')
|
||||
if (appSecret === '') return { status: 'unconfigured' }
|
||||
const verified = await verifyMetaNonce(platformAuth, postedPlatformId, appSecret)
|
||||
if (!verified.ok) return { status: 'rejected', reason: verified.reason }
|
||||
return {
|
||||
status: 'verified',
|
||||
platform: PlatformType.Oculus,
|
||||
platformId: verified.identity.userId,
|
||||
}
|
||||
}
|
||||
return { status: 'unsupported' }
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -250,55 +356,43 @@ const app = new Hono<App>()
|
||||
tags: ['Cached login'],
|
||||
summary: 'Accounts linked to a platform id',
|
||||
description: [
|
||||
'Accounts the client may offer on its login screen for this platform identity.',
|
||||
'Filtered to those a `cached_login` grant would actually accept, so an entry here',
|
||||
'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls',
|
||||
'back to a fresh login or create_account.',
|
||||
'EXCEPT platform 1 (Oculus), which is stubbed: it ignores the id and returns one',
|
||||
'canned, non-redeemable entry with `requirePassword: true`.',
|
||||
'Accounts the client may offer on its login screen for this platform identity —',
|
||||
'the links this identity has, so an entry here is always redeemable by a',
|
||||
'`cached_login` grant (both read the same table). An account linked to several',
|
||||
'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
|
||||
'and the client falls back to a fresh login or create_account.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'platform',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'PlatformType integer. A non-numeric value disables the link filter.',
|
||||
description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Platform-native id — a SteamID64 for Steam.',
|
||||
description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(
|
||||
CachedLogin.or(FakeCachedLogin).array(),
|
||||
'Matching accounts; `[]` if none. The canned entry for platform 1 (Oculus).'
|
||||
),
|
||||
200: json(CachedLogin.array(), 'Matching accounts; `[]` if none'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { platform, id } = c.req.param()
|
||||
logger.info('cached login lookup', { platform, id })
|
||||
const platformInt = Number.parseInt(platform, 10)
|
||||
// Oculus has no identity flow yet, so there is nothing in the DB to look up and
|
||||
// the real path would always yield []. Hand back one canned entry instead, so the
|
||||
// Oculus client gets past its login screen. `requirePassword` is true — unlike a
|
||||
// genuine cached login there is no platform ticket behind this, so the client must
|
||||
// prompt. Delete this branch once Oculus platform auth lands.
|
||||
if (platformInt === PlatformType.Oculus) return c.json([FAKE_OCULUS_CACHED_LOGIN])
|
||||
const accounts = await getAccountsByPlatformId(c.env.DB, id)
|
||||
// Offer only accounts the `cached_login` grant will actually accept — same check.
|
||||
return c.json(
|
||||
accounts
|
||||
.filter(
|
||||
(a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id)
|
||||
)
|
||||
.map(toCachedLogin)
|
||||
)
|
||||
// Listed straight from the link table, which is also what the `cached_login`
|
||||
// grant authorizes against — so the picker can't offer an account the grant
|
||||
// then refuses.
|
||||
const links = Number.isNaN(platformInt)
|
||||
? await getLinksForPlatformId(c.env.DB, id)
|
||||
: await getLinksForPlatformIdentity(c.env.DB, platformInt, id)
|
||||
return c.json(await toCachedLogins(c.env.DB, links))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -312,8 +406,8 @@ const app = new Hono<App>()
|
||||
description: [
|
||||
'Resolves many platform ids at once. Results are flattened across all ids, so the',
|
||||
'response cannot be mapped back to a specific input id — the client uses each',
|
||||
'entry’s own `platformId`. Unlike the single-id route, results are NOT filtered to',
|
||||
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
||||
'entry’s own `platformId`. No platform accompanies these ids, so each matches on',
|
||||
'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
||||
].join(' '),
|
||||
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
|
||||
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
|
||||
@@ -324,7 +418,8 @@ const app = new Hono<App>()
|
||||
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
|
||||
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
||||
for (const pid of ids) {
|
||||
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
|
||||
// No platform accompanies these ids, so they match on any platform.
|
||||
out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid))))
|
||||
}
|
||||
return c.json(out)
|
||||
}
|
||||
@@ -345,12 +440,13 @@ const app = new Hono<App>()
|
||||
'`password` becomes the login credential. Subject to two independent signup caps,',
|
||||
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
|
||||
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
|
||||
'`platform`, that platform must be Steam and `platform_auth` must verify.',
|
||||
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
|
||||
'must verify.',
|
||||
'',
|
||||
'**`cached_login`** — logs into an already-linked account using platform ownership as',
|
||||
'the credential; no password. Requires a Steam `platform_auth` ticket, and the posted',
|
||||
'`account_id` must be linked to exactly the identity that ticket proves. An account',
|
||||
'with no stored platform identity cannot be cached-logged-into.',
|
||||
'the credential; no password. Requires a verifying `platform_auth`, and the posted',
|
||||
'`account_id` must be LINKED to exactly the identity it proves. An account with no',
|
||||
'link for that identity cannot be cached-logged-into.',
|
||||
'',
|
||||
'**`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.',
|
||||
@@ -358,13 +454,22 @@ const app = new Hono<App>()
|
||||
'**`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,',
|
||||
'which is what closes id/username-only takeover.',
|
||||
'which is what closes id/username-only takeover. When it also posts a `platform_auth`',
|
||||
'that verifies, that identity is LINKED to the account — this is how a player who',
|
||||
'signed up on one platform gets a cached login on a second device. The login is',
|
||||
'never failed over the link: an unverifiable proof (or one over the per-identity',
|
||||
'cap) just leaves the account without a cached login there.',
|
||||
'',
|
||||
'**Platform verification.** Steam (platform `0`) is the only platform that can be',
|
||||
'verified, via its signed `platform_auth` ticket, so any grant authenticating by',
|
||||
'platform identity must be Steam. The verified SteamID64 replaces the client-supplied',
|
||||
'`platform_id` and is the only value ever written to an account. Password and refresh',
|
||||
'grants carry their own credential and are not gated this way.',
|
||||
'**Platform identity.** An account can be reached from several platform identities;',
|
||||
'the links are the one thing both the picker and `cached_login` consult, and only a',
|
||||
'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
|
||||
'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
|
||||
'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`',
|
||||
'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted',
|
||||
'`platform_id` — validation is what binds the nonce to that user id, so a spoofed id',
|
||||
'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500',
|
||||
'when it is unset. The first identity linked also becomes the account’s primary',
|
||||
'(what the account DTO and a refreshed token report); later ones only link.',
|
||||
'',
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
'powers refresh on every login and every refresh grant.',
|
||||
@@ -378,13 +483,17 @@ const app = new Hono<App>()
|
||||
400: json(
|
||||
OAuthError,
|
||||
[
|
||||
'Unusable grant: bad credentials, an unverifiable or non-Steam platform, an',
|
||||
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
|
||||
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
|
||||
].join(' ')
|
||||
),
|
||||
500: json(
|
||||
OAuthError,
|
||||
'JWT_SECRET is unset — a token is refused rather than signed with an empty key'
|
||||
[
|
||||
'The server is missing a secret it cannot proceed without: JWT_SECRET (a token is',
|
||||
'refused rather than signed with an empty key) or, on a Meta login, META_APP_SECRET',
|
||||
'(no nonce can be validated without it).',
|
||||
].join(' ')
|
||||
),
|
||||
},
|
||||
}),
|
||||
@@ -419,43 +528,91 @@ const app = new Hono<App>()
|
||||
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
|
||||
// A platform-authenticated login proves who you are with the platform itself,
|
||||
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
|
||||
// ticket. So those logins must be Steam:
|
||||
// - cached_login authenticates purely by platform identity → always Steam-only.
|
||||
// - create_account that asserts a platform is rejected unless it's Steam, since
|
||||
// we won't bind an identity we can't prove. (create_account with NO platform
|
||||
// is the password-account path — allowed, but it binds no platformId.)
|
||||
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
|
||||
// the ONLY value ever written to an account's `platformId`. Credential (password)
|
||||
// and refresh_token grants carry their own credential and aren't gated here.
|
||||
let verifiedSteamId: string | null = null
|
||||
// A platform-authenticated login proves who you are with the platform itself, and
|
||||
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
|
||||
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
|
||||
// verifyPlatformProof). Only a verified identity is ever bound or linked.
|
||||
//
|
||||
// Two grants are GATED on it — they have no other credential, so an unverifiable
|
||||
// platform is fatal:
|
||||
// - cached_login authenticates purely by platform identity.
|
||||
// - create_account that asserts a platform: we won't bind an identity we can't
|
||||
// prove. (create_account with NO platform is the password-account path —
|
||||
// allowed, but binds no platformId.)
|
||||
//
|
||||
// A password grant is NOT gated: the password already proved who it is. It posts
|
||||
// its platform proof too, and if that verifies we LINK the identity to the account
|
||||
// (see below), which is how a player who created an account on Steam gets a cached
|
||||
// login on their headset. If it doesn't verify, the login still succeeds — it just
|
||||
// links nothing, because a link is a password-free way into the account and must
|
||||
// never rest on an unproven id.
|
||||
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
||||
const platformAsserted = !Number.isNaN(platformInt)
|
||||
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
|
||||
if (platformInt !== PlatformType.Steam) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'unsupported platform; only Steam can be verified',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||
if (!verified) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'invalid or missing platform_auth ticket',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
verifiedSteamId = verified.steamId
|
||||
platformId = verified.steamId
|
||||
const gatedOnPlatform =
|
||||
grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)
|
||||
// The password grant only spends a verification when the client actually offered
|
||||
// one; the rest of the time there is nothing to link.
|
||||
const proof: PlatformProof =
|
||||
gatedOnPlatform || (platformAsserted && platformAuth !== '')
|
||||
? await verifyPlatformProof(c.env, platformInt, platformAuth, platformId)
|
||||
: { status: 'none' }
|
||||
|
||||
let verifiedPlatformId: string | null = null
|
||||
let verifiedPlatform: number | null = null
|
||||
if (proof.status === 'verified') {
|
||||
verifiedPlatform = proof.platform
|
||||
verifiedPlatformId = proof.platformId
|
||||
} else if (proof.status !== 'none') {
|
||||
// Log every failure, including the ones a password grant shrugs off: a player
|
||||
// who silently never gets a cached login on their headset has no other symptom,
|
||||
// and this line is where "Meta rejected the nonce" becomes visible.
|
||||
logger.info('platform_auth not verified', {
|
||||
platform: platformInt,
|
||||
platformId,
|
||||
grantType,
|
||||
status: proof.status,
|
||||
reason: proof.status === 'rejected' ? proof.reason : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (gatedOnPlatform && proof.status !== 'verified') {
|
||||
if (proof.status === 'unsupported') {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'unsupported platform; only Steam and Meta can be verified',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
if (proof.status === 'unconfigured') {
|
||||
// An operator misconfiguration, not the client's fault: without the app secret
|
||||
// every Meta player is locked out, so it answers 500 the way an unset
|
||||
// JWT_SECRET does below rather than blaming the credential. (We never fall
|
||||
// back to trusting the posted id — that would let anyone log into any
|
||||
// Meta-linked account by naming its user id.)
|
||||
logger.error('refusing a Meta login: META_APP_SECRET is empty')
|
||||
return c.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Meta platform verification is not configured',
|
||||
},
|
||||
500
|
||||
)
|
||||
}
|
||||
// The reason is for the operator; the client is told only that it was rejected.
|
||||
// A wrong app secret and a stale nonce look identical from the client side.
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth' },
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// From here on `platformId` is the PROVEN identity wherever there is one — the
|
||||
// SteamID64 out of the ticket or the Meta user id the nonce validated against,
|
||||
// never the raw client-supplied field.
|
||||
if (verifiedPlatformId !== null) platformId = verifiedPlatformId
|
||||
|
||||
// Resolve the account this token is for:
|
||||
// - create_account: mint + persist a brand-new account (auto-assigned random
|
||||
// username — players don't pick one initially); the token's `sub` is its id.
|
||||
@@ -482,10 +639,16 @@ const app = new Hono<App>()
|
||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||
if (
|
||||
maxPerPlatformId > 0 &&
|
||||
verifiedSteamId !== null &&
|
||||
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
|
||||
verifiedPlatformId !== null &&
|
||||
(await countAccountsForPlatformIdentity(
|
||||
c.env.DB,
|
||||
verifiedPlatform ?? 0,
|
||||
verifiedPlatformId
|
||||
)) >= maxPerPlatformId
|
||||
) {
|
||||
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
|
||||
logger.info('signup rejected: platform account limit', {
|
||||
platformId: verifiedPlatformId,
|
||||
})
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
@@ -509,14 +672,15 @@ const app = new Hono<App>()
|
||||
)
|
||||
}
|
||||
|
||||
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
|
||||
// `platformId` (the SteamID64) is what a later cached login is checked against,
|
||||
// so only this Steam user can log back into the account. A password/anonymous
|
||||
// create_account (no platform) binds no platformId.
|
||||
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
|
||||
// a Meta-validated nonce). A password/anonymous create_account (no platform)
|
||||
// binds nothing. The account blob keeps this first identity as its PRIMARY one
|
||||
// (for the account DTO and the refresh grant's claims); the link written just
|
||||
// below is what a later cached login is actually authorized against.
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: platformInt || 0,
|
||||
platform: verifiedSteamId !== null ? 0 : undefined,
|
||||
platformId: verifiedSteamId ?? undefined,
|
||||
platform: verifiedPlatform ?? undefined,
|
||||
platformId: verifiedPlatformId ?? undefined,
|
||||
lastLoginTime: new Date().toISOString(),
|
||||
deviceId: deviceId || undefined,
|
||||
deviceClass: deviceId ? deviceClass : undefined,
|
||||
@@ -524,6 +688,14 @@ const app = new Hono<App>()
|
||||
lastLoginIp: clientIp || undefined,
|
||||
})
|
||||
accountId = String(account.accountId)
|
||||
if (verifiedPlatformId !== null) {
|
||||
await linkPlatformIdentity(
|
||||
c.env.DB,
|
||||
account.accountId,
|
||||
verifiedPlatform ?? 0,
|
||||
verifiedPlatformId
|
||||
)
|
||||
}
|
||||
// Establish the login password when one is posted (raw password never stored).
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (password !== '') {
|
||||
@@ -547,18 +719,25 @@ const app = new Hono<App>()
|
||||
} else if (grantType === 'cached_login') {
|
||||
// Platform-authenticated login into an already-linked account. The client posts
|
||||
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
||||
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
|
||||
// account is linked to exactly this platform identity — this is the check that
|
||||
// keeps anyone but platform user `platform_id` out of the account (platform
|
||||
// ownership is the credential; no password needed). An account with no stored
|
||||
// platform identity can't be cached-logged-into and must use a fresh login.
|
||||
// `platform_id` its platform_auth vouches for. Authorize ONLY when the link
|
||||
// table says that account is linked to exactly this platform identity — this is
|
||||
// the check that keeps anyone but that platform user out of the account
|
||||
// (platform ownership is the credential; no password needed). An account with no
|
||||
// link for the presented identity must use a password.
|
||||
//
|
||||
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
|
||||
// above), never the client-supplied field. See steam-ticket.ts.
|
||||
// The picker lists straight from the same table, so it can only offer accounts
|
||||
// this check accepts.
|
||||
//
|
||||
// NB: `platform_id` here is the verified identity set above — the SteamID64 from
|
||||
// the ticket, or the Meta user id the nonce validated against — never the raw
|
||||
// client-supplied field. See steam-ticket.ts and meta-nonce.ts.
|
||||
//
|
||||
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
|
||||
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
|
||||
const linked =
|
||||
account !== null &&
|
||||
(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
|
||||
if (!account || !linked) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
@@ -600,6 +779,19 @@ const app = new Hono<App>()
|
||||
)
|
||||
}
|
||||
accountId = String(resolvedId)
|
||||
// The password proved the account; the platform proof (when the client sent one
|
||||
// and it verified) proves the device's platform identity. Linking the two is
|
||||
// what gives a player who signed up on Steam a cached login on their headset —
|
||||
// they type their password once there, and never again.
|
||||
if (verifiedPlatformId !== null) {
|
||||
await linkLoginIdentity(
|
||||
c.env.DB,
|
||||
resolvedId,
|
||||
verifiedPlatform ?? 0,
|
||||
verifiedPlatformId,
|
||||
intVar(c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID)
|
||||
)
|
||||
}
|
||||
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ export type Env = SharedHonoEnv & {
|
||||
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
|
||||
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
// The Meta (Oculus) app secret, from the app's page in the Meta developer dashboard.
|
||||
// Bound from the same Secrets Store as JWT_SECRET; resolve it with `.get()`. Used
|
||||
// only to authenticate US to Meta's graph API when validating a login nonce (see
|
||||
// meta-nonce.ts) — it never leaves the worker. Unlike Steam, whose ticket verifies
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Verification of a Meta (Oculus) `platform_auth` nonce, against Meta's graph API.
|
||||
*
|
||||
* Steam's ticket is signed by Steam, so we verify it offline with no network and no
|
||||
* credential (see steam-ticket.ts). Meta's user proof is the opposite: an opaque
|
||||
* nonce that means nothing on its own. The only way to know it is genuine is to ask
|
||||
* Meta — which is why this path makes an outbound request on every Meta login and
|
||||
* cannot work at all without the app secret.
|
||||
*
|
||||
* A Meta login posts
|
||||
*
|
||||
* platform_auth = {"Nonce":"<64 chars>","AppId":"1232175103309633","Source":"logged in user"}
|
||||
* platform_id = <the Meta user id>
|
||||
*
|
||||
* and validation is what BINDS those two together: `user_nonce_validate` answers
|
||||
* "was this nonce issued to this user, for this app?". So the posted `platform_id` is
|
||||
* an *input* here rather than something read out of a ticket, and a spoofed one fails
|
||||
* — a nonce Meta issued to user A does not validate as user B. The id is therefore
|
||||
* proven exactly as much as a Steam ticket's SteamID64 is, and is safe to bind to an
|
||||
* account. (It's an app-scoped id: it identifies the player within this app only.)
|
||||
*
|
||||
* The `AppId` comes from the payload rather than config because it must be the app the
|
||||
* nonce was issued for — a different one simply fails, since the access token below
|
||||
* pairs it with our secret. `Source` is informational and ignored.
|
||||
*
|
||||
* Shape and retry policy follow the reference Go server's utils/oculus.go.
|
||||
*/
|
||||
|
||||
/** Meta's nonce-validation endpoint. Takes a form body, answers `{"is_valid":true}`. */
|
||||
const NONCE_VALIDATE_URL = 'https://graph.oculus.com/user_nonce_validate'
|
||||
|
||||
/**
|
||||
* Graph error codes worth retrying — 1 (unknown) and 2 (service temporarily
|
||||
* unavailable) are Meta-side hiccups, not a verdict on the nonce. Anything else is a
|
||||
* real answer and retrying it just delays a login that is going to fail anyway.
|
||||
*/
|
||||
const TRANSIENT_ERROR_CODES = new Set([1, 2])
|
||||
|
||||
/**
|
||||
* Attempts per verification. A login is latency-sensitive and a nonce is single-use
|
||||
* with a short life, so this is deliberately small: two quick retries (250ms, 1s of
|
||||
* backoff) ride out a blip, and a longer outage fails the login rather than hanging
|
||||
* the client on a headset loading screen.
|
||||
*/
|
||||
const MAX_ATTEMPTS = 3
|
||||
|
||||
/** The trustworthy identity proven by a validated nonce. */
|
||||
export interface VerifiedMetaIdentity {
|
||||
/** The Meta user id the nonce was issued to — app-scoped, numeric. */
|
||||
userId: string
|
||||
/** The Meta app the nonce was issued for. */
|
||||
appId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome of a verification. Failures carry a `reason` for the server log: the
|
||||
* client is told only that its platform_auth was rejected (it can't act on more), but
|
||||
* an operator debugging a headset that won't log in needs to know whether Meta said
|
||||
* "bad nonce", "bad access token" (the wrong app secret) or nothing at all.
|
||||
*/
|
||||
export type MetaVerification =
|
||||
{ ok: true; identity: VerifiedMetaIdentity } | { ok: false; reason: string }
|
||||
|
||||
/** The `{Nonce, AppId}` a Meta `platform_auth` payload carries. */
|
||||
export interface MetaPlatformAuth {
|
||||
nonce: string
|
||||
appId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Meta `platform_auth` payload, or null when it isn't one. The `AppId` must be
|
||||
* numeric — it is interpolated into the access token below, and this is what keeps a
|
||||
* client-supplied string out of that credential.
|
||||
*/
|
||||
export function parseMetaPlatformAuth(platformAuth: string): MetaPlatformAuth | null {
|
||||
let parsed: { Nonce?: unknown; AppId?: unknown }
|
||||
try {
|
||||
parsed = JSON.parse(platformAuth) as { Nonce?: unknown; AppId?: unknown }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const { Nonce: nonce, AppId: appId } = parsed
|
||||
if (typeof nonce !== 'string' || nonce === '') return null
|
||||
if (typeof appId !== 'string' || !/^\d+$/.test(appId)) return null
|
||||
return { nonce, appId }
|
||||
}
|
||||
|
||||
/** The graph response we care about; everything else in the body is ignored. */
|
||||
interface NonceValidateResponse {
|
||||
is_valid?: boolean
|
||||
error?: { message?: string; code?: number; type?: string; is_transient?: boolean }
|
||||
}
|
||||
|
||||
/** One validation round-trip. `retryable` says whether another attempt could differ. */
|
||||
async function validateOnce(
|
||||
form: URLSearchParams,
|
||||
fetcher: typeof fetch
|
||||
): Promise<{ ok: boolean; retryable: boolean; reason: string }> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetcher(NONCE_VALIDATE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString(),
|
||||
})
|
||||
} catch (err) {
|
||||
return { ok: false, retryable: true, reason: `request failed: ${String(err)}` }
|
||||
}
|
||||
|
||||
let body: NonceValidateResponse
|
||||
try {
|
||||
body = (await res.json()) as NonceValidateResponse
|
||||
} catch {
|
||||
// A non-JSON body is Meta's edge (a 5xx error page, a rate-limit page), not a
|
||||
// verdict — treat it the way a dropped connection is treated.
|
||||
return { ok: false, retryable: true, reason: `HTTP ${res.status} with a non-JSON body` }
|
||||
}
|
||||
|
||||
if (body.error) {
|
||||
const { code, message, is_transient } = body.error
|
||||
return {
|
||||
ok: false,
|
||||
retryable: is_transient === true || (code !== undefined && TRANSIENT_ERROR_CODES.has(code)),
|
||||
reason: `graph error ${code ?? '?'}: ${message ?? 'no message'}`,
|
||||
}
|
||||
}
|
||||
if (body.is_valid !== true) return { ok: false, retryable: false, reason: 'nonce rejected' }
|
||||
return { ok: true, retryable: false, reason: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Meta `platform_auth` payload against the `userId` it is claimed for, and
|
||||
* return the identity it proves. Only ever succeeds for a nonce Meta itself confirms
|
||||
* was issued to that user for that app.
|
||||
*
|
||||
* `appSecret` is the app's secret from the Meta developer dashboard; without it no
|
||||
* Meta login can be verified, so callers must treat an unset secret as a server
|
||||
* misconfiguration rather than a bad credential. `fetcher` is injectable so tests can
|
||||
* run the retry and response handling without reaching the network.
|
||||
*/
|
||||
export async function verifyMetaNonce(
|
||||
platformAuth: string,
|
||||
userId: string,
|
||||
appSecret: string,
|
||||
fetcher?: typeof fetch
|
||||
): Promise<MetaVerification> {
|
||||
if (appSecret === '') return { ok: false, reason: 'no app secret configured' }
|
||||
// The user id is what the nonce is checked against, so an absent or non-numeric one
|
||||
// can't be verified — reject before spending a round-trip on it.
|
||||
if (!/^\d+$/.test(userId)) return { ok: false, reason: 'missing or non-numeric platform_id' }
|
||||
const auth = parseMetaPlatformAuth(platformAuth)
|
||||
if (!auth) return { ok: false, reason: 'malformed platform_auth payload' }
|
||||
|
||||
// `OC|<app id>|<app secret>` is Meta's app access token — it authenticates the
|
||||
// *app*, which is why the secret never leaves the server.
|
||||
const form = new URLSearchParams({
|
||||
nonce: auth.nonce,
|
||||
user_id: userId,
|
||||
access_token: `OC|${auth.appId}|${appSecret}`,
|
||||
})
|
||||
|
||||
// Resolved per call, not at module load, so a test's stubbed global is honoured.
|
||||
const doFetch = fetcher ?? globalThis.fetch
|
||||
let last = { ok: false, retryable: false, reason: 'not attempted' }
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
last = await validateOnce(form, doFetch)
|
||||
if (last.ok) return { ok: true, identity: { userId, appId: auth.appId } }
|
||||
if (!last.retryable || attempt === MAX_ATTEMPTS) break
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * attempt * 250))
|
||||
}
|
||||
return { ok: false, reason: last.reason }
|
||||
}
|
||||
+26
-15
@@ -69,21 +69,33 @@ export const PlatformType = {
|
||||
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
||||
|
||||
/**
|
||||
* A PlatformType by value. Only Steam can actually be verified — see the
|
||||
* platform-auth notes on `POST /connect/token`.
|
||||
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
|
||||
* see the platform-auth notes on `POST /connect/token`.
|
||||
*/
|
||||
export const PlatformTypeSchema = z
|
||||
.union([z.literal(-1), z.int().min(0).max(Math.max(...Object.values(PlatformType)))])
|
||||
.union([
|
||||
z.literal(-1),
|
||||
z
|
||||
.int()
|
||||
.min(0)
|
||||
.max(Math.max(...Object.values(PlatformType))),
|
||||
])
|
||||
.describe(
|
||||
Object.entries(PlatformType)
|
||||
.map(([name, value]) => `${value} ${name}`)
|
||||
.join(', ')
|
||||
)
|
||||
|
||||
/** One entry on the client's login screen, from `toCachedLogin`. */
|
||||
/**
|
||||
* One entry on the client's login screen, from `toCachedLogin` — an account ↔ platform
|
||||
* identity LINK, not an account. An account linked to two platforms yields one entry in
|
||||
* each of their pickers, each reporting the identity that picker was asked about.
|
||||
*/
|
||||
export const CachedLogin = z.object({
|
||||
platform: PlatformTypeSchema,
|
||||
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
|
||||
platformId: z
|
||||
.string()
|
||||
.describe('The linked platform-native id — a SteamID64 for Steam, a user id for Meta'),
|
||||
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
|
||||
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
|
||||
requirePassword: z
|
||||
@@ -91,14 +103,6 @@ export const CachedLogin = z.object({
|
||||
.describe('Always false — platform ownership is the credential for a cached login'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The stubbed Oculus cached login. Same shape as `CachedLogin`, but `requirePassword`
|
||||
* is true — nothing proves platform ownership, so the client has to prompt.
|
||||
*/
|
||||
export const FakeCachedLogin = CachedLogin.extend({
|
||||
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
|
||||
})
|
||||
|
||||
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
|
||||
export const OAuthError = z.object({
|
||||
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
|
||||
@@ -139,11 +143,18 @@ export const TokenRequest = z.object({
|
||||
platform_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Unverified; ignored in favour of the Steam-verified id where a ticket is required'),
|
||||
.describe(
|
||||
'On Steam, unverified and ignored in favour of the id the ticket carries. On Meta it is ' +
|
||||
'the id the nonce is validated against, so it must be the real (numeric) user id'
|
||||
),
|
||||
platform_auth: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Steam session ticket. Required for cached_login and platform create_account'),
|
||||
.describe(
|
||||
'Platform proof, required for cached_login and platform create_account, and used to ' +
|
||||
'link the identity on a password grant. Steam: `{"Ticket":"<hex>","AppId":…}`. ' +
|
||||
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
|
||||
),
|
||||
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
|
||||
device_id: z
|
||||
.string()
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Platform identity links on the shared `recflare` D1 database (owned by the `auth`
|
||||
* worker, migration 0007). One row per (platform, platform id, account): the Steam
|
||||
* user 76561…211 is linked to account 42, the Meta user 27061… is linked to account
|
||||
* 42 as well, and both let that player into that account without a password.
|
||||
*
|
||||
* This table replaced the single `platformId`/`platform` pair on the account blob as
|
||||
* the thing logins are decided from, because that pair could only hold ONE identity —
|
||||
* a player with a PC and a headset had to pick which device got a cached login. The
|
||||
* blob fields are kept as the account's *primary* identity (the first one linked) for
|
||||
* the account DTO and the refresh grant's claims; nothing authorizes off them.
|
||||
*
|
||||
* It is deliberately the ONE source of truth for both halves of a cached login: the
|
||||
* picker (`/cachedlogin/forplatformid`) lists the accounts this table links to an
|
||||
* identity, and the `cached_login` grant asks this table whether the account it was
|
||||
* handed is linked to the identity that was proven. When those two disagreed the
|
||||
* client was offered an account it could never log into — see the regression test.
|
||||
*
|
||||
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
|
||||
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
|
||||
* account with no password", so an unproven `platform_id` must never reach it.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
|
||||
export const PLATFORM_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS platform_account (
|
||||
account_id INTEGER NOT NULL,
|
||||
platform INTEGER NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
linked_at TEXT NOT NULL,
|
||||
PRIMARY KEY (platform, platform_id, account_id)
|
||||
)`,
|
||||
// The picker's lookup: "which accounts does this identity open?". Covered by the
|
||||
// primary key's leading columns, so no separate index is needed for it.
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id)`,
|
||||
// Lookup by bare platform id, across platforms — the bulk (friends) route, which
|
||||
// resolves ids it has no platform for.
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* The one-time backfill 0007 ran after creating the table: every identity already bound
|
||||
* to an account became a link, so nobody lost their cached login at deploy. It has run;
|
||||
* this exists so a test can still exercise it, which is the only coverage that legacy
|
||||
* blob-bound accounts get a link at all.
|
||||
*
|
||||
* `platform` is COALESCEd to 0 because nothing ever defaulted that field — an account
|
||||
* can carry a platformId with no platform recorded, and back when Steam was the only
|
||||
* verifiable platform an unset one *was* Steam.
|
||||
*
|
||||
* NOT byte-identical to the migration any more, deliberately. 0007 selected the
|
||||
* `account.platform_id` generated column; 0008 drops it, so that text is unrunnable
|
||||
* against the head schema the tests build. This selects the blob directly instead —
|
||||
* the same values, since the dropped column was DEFINED as
|
||||
* `json_extract(data, '$.platformId')`. 0007 is left exactly as it ran on prod.
|
||||
*/
|
||||
export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
SELECT
|
||||
account_id,
|
||||
COALESCE(json_extract(data, '$.platform'), 0),
|
||||
json_extract(data, '$.platformId'),
|
||||
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
|
||||
FROM account
|
||||
WHERE json_extract(data, '$.platformId') IS NOT NULL
|
||||
AND json_extract(data, '$.platformId') <> ''`
|
||||
|
||||
/** One account ↔ platform identity link. */
|
||||
export interface PlatformLink {
|
||||
accountId: number
|
||||
platform: number
|
||||
platformId: string
|
||||
/** ISO-8601 time the link was made. */
|
||||
linkedAt: string
|
||||
}
|
||||
|
||||
interface LinkRow {
|
||||
accountId: number
|
||||
platform: number
|
||||
platformId: string
|
||||
linkedAt: string
|
||||
}
|
||||
|
||||
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
|
||||
linked_at AS linkedAt FROM platform_account`
|
||||
|
||||
/**
|
||||
* Link a verified platform identity to an account. Idempotent — re-logging in on the
|
||||
* same platform doesn't churn the row, and `linkedAt` keeps the time of the FIRST
|
||||
* link. Returns true when this created a new link.
|
||||
*
|
||||
* Callers must pass an identity the platform itself proved. Nothing in here can tell
|
||||
* a verified id from a spoofed one.
|
||||
*/
|
||||
export async function linkPlatformIdentity(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const res = await db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(accountId, platform, platformId, new Date().toISOString())
|
||||
.run()
|
||||
return res.meta.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts a platform identity opens — what the login-screen picker lists.
|
||||
* Ordered oldest link first so the list is stable between launches (D1 row order
|
||||
* isn't). Empty id yields nothing rather than matching every link.
|
||||
*/
|
||||
export async function getLinksForPlatformIdentity(
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Links for a bare platform id, whatever platform it belongs to. For the bulk
|
||||
* (friends-resolution) lookup, which posts ids with no platform alongside them, and
|
||||
* for the single-id route when the client sends a non-numeric platform.
|
||||
*/
|
||||
export async function getLinksForPlatformId(
|
||||
db: D1Database,
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
const { results } = await db
|
||||
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
|
||||
.bind(platformId)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
||||
export async function getLinksForAccount(
|
||||
db: D1Database,
|
||||
accountId: number
|
||||
): Promise<PlatformLink[]> {
|
||||
const { results } = await db
|
||||
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
|
||||
.bind(accountId)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account is linked to this platform identity — the single check the
|
||||
* `cached_login` grant authorizes on. An account with no link for the presented
|
||||
* identity cannot be cached-logged-into and must use a password.
|
||||
*/
|
||||
export async function isPlatformIdentityLinked(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT 1 AS ok FROM platform_account
|
||||
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
|
||||
)
|
||||
.bind(accountId, platform, platformId)
|
||||
.first<{ ok: number }>()
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* creating them under the cap and then linking more in.
|
||||
*/
|
||||
export async function countAccountsForPlatformIdentity(
|
||||
db: D1Database,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<number> {
|
||||
if (platformId === '') return 0
|
||||
const row = await db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM platform_account WHERE platform = ?1 AND platform_id = ?2`)
|
||||
.bind(platform, platformId)
|
||||
.first<{ n: number }>()
|
||||
return row?.n ?? 0
|
||||
}
|
||||
@@ -8,12 +8,18 @@ import {
|
||||
getAccountsByDeviceId,
|
||||
hashPassword,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { isLinkedToPlatformIdentity } from '../../auth.app'
|
||||
import {
|
||||
getLinksForAccount,
|
||||
linkPlatformIdentity,
|
||||
PLATFORM_BACKFILL_SQL,
|
||||
PLATFORM_SCHEMA_DDL,
|
||||
} from '../../platform-db'
|
||||
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
@@ -31,14 +37,28 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
|
||||
// accounts the login tests authenticate as (42, 77).
|
||||
const LOGIN_PASSWORD = 'correct-horse'
|
||||
|
||||
// Meta (Oculus) logins verify their nonce by calling graph.oculus.com authenticated
|
||||
// as the app, so the tests seed an app secret and stub that call — see metaLogin.
|
||||
const META_APP_SECRET = 'test-meta-app-secret'
|
||||
const META_APP_ID = '1232175103309633'
|
||||
const META_USER_ID = '27061366730207360'
|
||||
const META_NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
|
||||
/** Set in beforeAll; needed to overwrite the secret in the not-configured test. */
|
||||
let metaSecretId: string
|
||||
|
||||
// Apply the accounts schema so create_account can persist (mirrors the migration),
|
||||
// and seed the Orientation room (owned by the rooms worker) so signup can place
|
||||
// the new player there.
|
||||
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')
|
||||
// The Meta app secret, likewise — a Meta login is refused outright without one.
|
||||
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()
|
||||
// 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()
|
||||
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
@@ -49,12 +69,9 @@ beforeAll(async () => {
|
||||
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
|
||||
.run()
|
||||
}
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS room (
|
||||
data TEXT NOT NULL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
||||
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Subrooms live in their own table; seed the Orientation room and split its subroom into it.
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
@@ -101,6 +118,51 @@ async function postToken(
|
||||
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a Meta grant to /connect/token with graph.oculus.com stubbed to answer
|
||||
* `is_valid`. The worker runs in this isolate, so replacing the global fetch is what
|
||||
* stands in for Meta — `verifyMetaNonce` resolves `globalThis.fetch` per call for
|
||||
* exactly this reason. Returns the graph requests the worker made alongside the
|
||||
* response, so a test can assert WHICH user id the nonce was validated against.
|
||||
*/
|
||||
async function metaLogin(
|
||||
body: string,
|
||||
isValid: boolean
|
||||
): Promise<{ status: number; json: Record<string, unknown>; graphCalls: URLSearchParams[] }> {
|
||||
const graphCalls: URLSearchParams[] = []
|
||||
const realFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (url: string, init?: { body?: string }) => {
|
||||
if (url.startsWith('https://graph.oculus.com/')) {
|
||||
graphCalls.push(new URLSearchParams(init?.body ?? ''))
|
||||
return Response.json({ is_valid: isValid })
|
||||
}
|
||||
return realFetch(url, init)
|
||||
}) as unknown as typeof fetch
|
||||
try {
|
||||
return { ...(await postToken(body)), graphCalls }
|
||||
} finally {
|
||||
globalThis.fetch = realFetch
|
||||
}
|
||||
}
|
||||
|
||||
/** GET a JSON route on the worker and parse the body as `T`. */
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}`)
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
/** The picker entries a platform identity yields, as the client sees them. */
|
||||
function cachedLogins(platform: number, id: string) {
|
||||
return getJson<Array<Record<string, unknown> & { accountId: number; platform: number }>>(
|
||||
`/cachedlogin/forplatformid/${platform}/${id}`
|
||||
)
|
||||
}
|
||||
|
||||
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */
|
||||
function metaPlatformAuth(): string {
|
||||
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
|
||||
}
|
||||
|
||||
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
|
||||
function changePassword(body: string, token?: string): Promise<Response> {
|
||||
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
|
||||
@@ -122,32 +184,25 @@ describe('auth worker routes', () => {
|
||||
expect(await res.text()).toBe('"AA=="')
|
||||
})
|
||||
|
||||
// Platform 0 (Steam), not 1 — platform 1 is Oculus, which is stubbed below.
|
||||
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/abc123`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
test.each([
|
||||
['0 (Steam)', 0],
|
||||
['1 (Meta)', 1],
|
||||
])(
|
||||
'GET /cachedlogin/forplatformid/%s/:id returns [] for an unknown id',
|
||||
async (_label, platform) => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/cachedlogin/forplatformid/${platform}/abc123`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
// Oculus is stubbed: no DB lookup, one canned entry whatever the id.
|
||||
test('GET /cachedlogin/forplatformid/1/:id returns the canned Oculus entry', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/anything`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([
|
||||
{
|
||||
platform: 1,
|
||||
platformId: '1',
|
||||
accountId: 1,
|
||||
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
||||
requirePassword: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
// Only Steam (platform 0) can be verified (via its signed platform_auth ticket),
|
||||
// so every OTHER platform is rejected on the platform-authenticated grants — we
|
||||
// won't bind or authorize an identity we can't prove.
|
||||
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
|
||||
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
|
||||
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
|
||||
// on the platform-authenticated grants: we won't bind or authorize an identity we
|
||||
// can't prove.
|
||||
test.each([2, 3, 4, 5, 6, 7, 8])(
|
||||
'create_account rejects unverifiable platform %i',
|
||||
async (platform) => {
|
||||
const res = await postToken(
|
||||
@@ -155,11 +210,11 @@ describe('auth worker routes', () => {
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('only Steam')
|
||||
expect(res.json.error_description).toContain('only Steam and Meta')
|
||||
}
|
||||
)
|
||||
|
||||
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
|
||||
test.each([2, 3, 4, 5, 6, 7, 8])(
|
||||
'cached_login rejects unverifiable platform %i',
|
||||
async (platform) => {
|
||||
const res = await postToken(
|
||||
@@ -167,7 +222,7 @@ describe('auth worker routes', () => {
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('only Steam')
|
||||
expect(res.json.error_description).toContain('only Steam and Meta')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -191,6 +246,109 @@ describe('auth worker routes', () => {
|
||||
expect(res.json.error_description).toContain('platform_auth')
|
||||
})
|
||||
|
||||
test('Meta create_account requires a platform_auth nonce', async () => {
|
||||
// platform=1 with no nonce must not bind the spoofable platform_id field.
|
||||
const res = await postToken(`grant_type=create_account&platform=1&platform_id=${META_USER_ID}`)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('platform_auth')
|
||||
})
|
||||
|
||||
test('Meta create_account binds the id Meta validated the nonce against', async () => {
|
||||
const res = await metaLogin(
|
||||
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}&device_id=meta-device`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// The nonce was validated against the posted user id, authenticated as the app.
|
||||
expect(res.graphCalls).toHaveLength(1)
|
||||
expect(res.graphCalls[0].get('nonce')).toBe(META_NONCE)
|
||||
expect(res.graphCalls[0].get('user_id')).toBe(META_USER_ID)
|
||||
expect(res.graphCalls[0].get('access_token')).toBe(`OC|${META_APP_ID}|${META_APP_SECRET}`)
|
||||
|
||||
// The account is bound to platform 1 with that id — which is what makes the
|
||||
// cached-login picker offer it, and the cached_login grant accept it.
|
||||
const payload = decodePayload(res.json.access_token as string)
|
||||
const accountId = Number(payload.sub)
|
||||
const linked = await cachedLogins(1, META_USER_ID)
|
||||
expect(linked).toContainEqual(
|
||||
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
|
||||
)
|
||||
// Platform ownership is the credential, so the client is not asked for a password.
|
||||
expect(linked.every((a) => a.requirePassword === false)).toBe(true)
|
||||
})
|
||||
|
||||
test('Meta create_account is rejected when Meta does not vouch for the nonce', async () => {
|
||||
const res = await metaLogin(
|
||||
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
false
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('platform_auth')
|
||||
})
|
||||
|
||||
test('Meta cached_login logs into the linked account with no password', async () => {
|
||||
const userId = '27061366730209999'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 5150,
|
||||
username: 'MetaPlayer',
|
||||
platform: 1,
|
||||
platformId: userId,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 5150, 1, userId)
|
||||
const res = await metaLogin(
|
||||
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.graphCalls[0].get('user_id')).toBe(userId)
|
||||
const payload = decodePayload(res.json.access_token as string)
|
||||
expect(payload.sub).toBe('5150')
|
||||
})
|
||||
|
||||
test('a Meta user id cannot log into an account it is not linked to', async () => {
|
||||
// The Meta account seeded above, claimed by a different (but genuinely proven)
|
||||
// Meta user. Even with a nonce Meta vouches for, the identity has to be one the
|
||||
// account is actually linked to.
|
||||
const res = await metaLogin(
|
||||
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toContain('no linked account')
|
||||
})
|
||||
|
||||
test('a Meta login is refused (500) when META_APP_SECRET is unset', async () => {
|
||||
// An operator misconfiguration, not a bad credential: without the secret no nonce
|
||||
// can be validated, and the alternative — trusting the posted platform_id — would
|
||||
// let anyone log into any Meta-linked account by naming its user id.
|
||||
const admin = adminSecretsStore(env.META_APP_SECRET)
|
||||
await admin.update('', metaSecretId)
|
||||
try {
|
||||
const res = await metaLogin(
|
||||
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(500)
|
||||
expect(res.json.error).toBe('server_error')
|
||||
// Nothing was asked of Meta, and nothing was trusted.
|
||||
expect(res.graphCalls).toHaveLength(0)
|
||||
} finally {
|
||||
await admin.update(META_APP_SECRET, metaSecretId)
|
||||
}
|
||||
})
|
||||
|
||||
test('cachedlogin/forplatformid returns the DTO for a bound (Steam) account', async () => {
|
||||
// Seed a Steam-linked account directly (a real create_account needs a live
|
||||
// ticket); assert the picker projects the CachedLogin DTO the client expects.
|
||||
@@ -206,6 +364,7 @@ describe('auth worker routes', () => {
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 31380, 0, steamId)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([
|
||||
@@ -219,32 +378,69 @@ describe('auth worker routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('a Steam-linked account with no stored `platform` field still cached-logs in', async () => {
|
||||
// Regression: nothing defaults an account's `platform` (see defaultAccount), so a
|
||||
// Steam-linked account can carry a platformId with no platform. The picker offered
|
||||
// such an account (it treats a missing platform as Steam) while the cached_login
|
||||
// grant rejected it — "no linked account for this platform identity" forever.
|
||||
// Both now run the same check.
|
||||
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
|
||||
// The point of the link table. The same account is reachable from the PC and from
|
||||
// the headset, and each picker reports the identity IT was asked about — that's
|
||||
// what the client posts back on the cached_login grant.
|
||||
const steamId = '76561197962463777'
|
||||
const metaId = '27061366730207777'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 6200,
|
||||
username: 'CrossPlatform',
|
||||
platform: 0,
|
||||
platformId: steamId,
|
||||
lastLoginTime: '2026-08-01T10:00:00.000Z',
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 6200, 0, steamId)
|
||||
await linkPlatformIdentity(env.DB, 6200, 1, metaId)
|
||||
|
||||
const onSteam = await cachedLogins(0, steamId)
|
||||
const onMeta = await cachedLogins(1, metaId)
|
||||
|
||||
expect(onSteam).toEqual([
|
||||
expect.objectContaining({ accountId: 6200, platform: 0, platformId: steamId }),
|
||||
])
|
||||
expect(onMeta).toEqual([
|
||||
expect.objectContaining({ accountId: 6200, platform: 1, platformId: metaId }),
|
||||
])
|
||||
|
||||
// And the grant accepts both, without a password.
|
||||
const viaMeta = await metaLogin(
|
||||
`grant_type=cached_login&account_id=6200&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(viaMeta.status).toBe(200)
|
||||
expect(decodePayload(viaMeta.json.access_token as string).sub).toBe('6200')
|
||||
})
|
||||
|
||||
test('the picker and the cached_login grant read the same table', async () => {
|
||||
// Regression: the picker used to derive links from the account blob (treating a
|
||||
// missing `platform` as Steam) while the grant ran its own check, so the client
|
||||
// could be handed an account_id that answered "no linked account" forever. Both
|
||||
// now read platform_account, which is why an account with a stale blob identity
|
||||
// is NOT offered — and, since it isn't offered, never rejected either.
|
||||
const steamId = '76561197962463211'
|
||||
const account = { platformId: steamId } // no `platform` field
|
||||
|
||||
// The grant now accepts it — this is what was returning invalid_grant.
|
||||
expect(isLinkedToPlatformIdentity(account, 0, steamId)).toBe(true)
|
||||
|
||||
// The identity is still the credential: another SteamID, an account with no
|
||||
// platform identity, and an account bound to a different platform are all refused.
|
||||
expect(isLinkedToPlatformIdentity(account, 0, '76561197962463299')).toBe(false)
|
||||
expect(isLinkedToPlatformIdentity({}, 0, steamId)).toBe(false)
|
||||
expect(isLinkedToPlatformIdentity({ ...account, platform: 3 }, 0, steamId)).toBe(false)
|
||||
|
||||
// And the picker offers exactly the accounts the grant accepts.
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
|
||||
.run()
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
||||
const offered = (await res.json()) as Array<{ accountId: number; platform: number }>
|
||||
expect(offered.map((a) => a.accountId)).toContain(8)
|
||||
expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0)
|
||||
|
||||
// No link row yet: not offered.
|
||||
const before = await cachedLogins(0, steamId)
|
||||
expect(before.map((a) => a.accountId)).not.toContain(8)
|
||||
|
||||
// The 0007 backfill is what gives accounts like this one — bound before the link
|
||||
// table existed, and carrying no `platform` field at all — their link.
|
||||
await env.DB.prepare(PLATFORM_BACKFILL_SQL).run()
|
||||
|
||||
const after = await cachedLogins(0, steamId)
|
||||
expect(after.map((a) => a.accountId)).toContain(8)
|
||||
// COALESCEd to Steam, which is what an unset platform meant.
|
||||
expect(after.find((a) => a.accountId === 8)?.platform).toBe(0)
|
||||
})
|
||||
|
||||
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
|
||||
@@ -581,6 +777,137 @@ describe('auth worker routes', () => {
|
||||
expect(payload.platform_id).toBe('steam-123')
|
||||
})
|
||||
|
||||
// A password login is how a player who already has an account signs in on a NEW
|
||||
// device. The client posts its platform proof alongside the password, and linking
|
||||
// the two is what turns the next launch on that device into a cached login.
|
||||
describe('password grant links the platform identity it proves', () => {
|
||||
/** Seed an account with LOGIN_PASSWORD set and no platform identity at all. */
|
||||
async function seedPasswordAccount(id: number, username: string) {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: id,
|
||||
username,
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
})
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
test('a verified Meta login on an existing account links it, and cached login follows', async () => {
|
||||
// Exactly the client's flow: an account made elsewhere, signed into on a headset
|
||||
// with username + password, with the Meta nonce riding along.
|
||||
await seedPasswordAccount(7100, 'djdevin')
|
||||
const metaId = '27061366730201234'
|
||||
const login = await metaLogin(
|
||||
`grant_type=password&username=djdevin&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(login.status).toBe(200)
|
||||
expect(decodePayload(login.json.access_token as string).sub).toBe('7100')
|
||||
// The nonce was validated against the id being linked — an unproven id is never
|
||||
// linked, since a link is a password-free way into the account.
|
||||
expect(login.graphCalls[0].get('user_id')).toBe(metaId)
|
||||
|
||||
// The headset now gets a cached login: offered by the picker…
|
||||
const offered = await cachedLogins(1, metaId)
|
||||
expect(offered.map((a) => a.accountId)).toContain(7100)
|
||||
|
||||
// …and accepted by the grant, with no password.
|
||||
const cached = await metaLogin(
|
||||
`grant_type=cached_login&account_id=7100&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(cached.status).toBe(200)
|
||||
})
|
||||
|
||||
test('the first identity linked becomes the account primary; later ones just link', async () => {
|
||||
await seedPasswordAccount(7101, 'multiplatform')
|
||||
const metaId = '27061366730205678'
|
||||
await metaLogin(
|
||||
`grant_type=password&username=multiplatform&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
// The blob's primary identity was empty, so the first link fills it in — this is
|
||||
// what the account DTO and the refresh grant's claims report.
|
||||
const account = (await env.DB.prepare(
|
||||
'SELECT data FROM account WHERE account_id = 7101'
|
||||
).first<{ data: string }>())!
|
||||
expect(JSON.parse(account.data)).toMatchObject({ platform: 1, platformId: metaId })
|
||||
|
||||
// A second identity on another platform links without disturbing the primary.
|
||||
await linkPlatformIdentity(env.DB, 7101, 0, '76561197962465678')
|
||||
const links = await getLinksForAccount(env.DB, 7101)
|
||||
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
|
||||
[1, metaId],
|
||||
[0, '76561197962465678'],
|
||||
])
|
||||
})
|
||||
|
||||
test('an unverified platform_auth logs in but links nothing', async () => {
|
||||
// The password already proved who this is, so the login stands — but a link is a
|
||||
// password-free way in, and this identity was never proven, so none is written.
|
||||
await seedPasswordAccount(7102, 'unproven')
|
||||
const metaId = '27061366730209876'
|
||||
const login = await metaLogin(
|
||||
`grant_type=password&username=unproven&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
false // Meta rejects the nonce
|
||||
)
|
||||
expect(login.status).toBe(200)
|
||||
expect(await getLinksForAccount(env.DB, 7102)).toEqual([])
|
||||
})
|
||||
|
||||
test('a login with no platform_auth links nothing and asks Meta nothing', async () => {
|
||||
await seedPasswordAccount(7103, 'noproof')
|
||||
const login = await metaLogin(
|
||||
`grant_type=password&username=noproof&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=27061366730204321`,
|
||||
true
|
||||
)
|
||||
expect(login.status).toBe(200)
|
||||
expect(login.graphCalls).toHaveLength(0)
|
||||
expect(await getLinksForAccount(env.DB, 7103)).toEqual([])
|
||||
})
|
||||
|
||||
test('linking obeys the per-identity account cap, without failing the login', async () => {
|
||||
// Otherwise the signup cap would be trivially bypassable: create accounts with a
|
||||
// password, then link the capped identity into all of them.
|
||||
const metaId = '27061366730203333'
|
||||
for (let i = 0; i < 3; i++) await linkPlatformIdentity(env.DB, 8000 + i, 1, metaId)
|
||||
|
||||
await seedPasswordAccount(8100, 'overcap')
|
||||
const login = await metaLogin(
|
||||
`grant_type=password&username=overcap&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
// The password was valid, so the player is logged in — they just don't get a
|
||||
// cached login on this account.
|
||||
expect(login.status).toBe(200)
|
||||
expect(await getLinksForAccount(env.DB, 8100)).toEqual([])
|
||||
})
|
||||
|
||||
test('re-logging in on the same device does not duplicate the link', async () => {
|
||||
await seedPasswordAccount(7104, 'repeatlogin')
|
||||
const metaId = '27061366730207654'
|
||||
const body =
|
||||
`grant_type=password&username=repeatlogin&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`
|
||||
await metaLogin(body, true)
|
||||
await metaLogin(body, true)
|
||||
expect(await getLinksForAccount(env.DB, 7104)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
|
||||
const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
|
||||
const refreshToken = login.json.refresh_token as string
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { parseMetaPlatformAuth, verifyMetaNonce } from '../../meta-nonce'
|
||||
|
||||
// The payload shape a real Meta login posts, captured from a live client. `Source`
|
||||
// is informational and ignored; the AppId is Rec Room's Meta app.
|
||||
const NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
|
||||
const APP_ID = '1232175103309633'
|
||||
const USER_ID = '27061366730207360'
|
||||
const PLATFORM_AUTH = JSON.stringify({ Nonce: NONCE, AppId: APP_ID, Source: 'logged in user' })
|
||||
const APP_SECRET = 'test-app-secret'
|
||||
|
||||
/**
|
||||
* A fetch stub answering with `bodies` (one body, or one per attempt), recording every
|
||||
* request it was handed. Typed to what `verifyMetaNonce` actually passes — a string URL
|
||||
* and a string body — rather than the whole of `fetch`, then cast at the boundary.
|
||||
*/
|
||||
function stubFetch(bodies: unknown, status = 200) {
|
||||
const queue = Array.isArray(bodies) ? [...(bodies as unknown[])] : [bodies]
|
||||
const calls: Array<{ url: string; form: URLSearchParams }> = []
|
||||
const fetcher = (async (url: string, init?: { body?: string }) => {
|
||||
calls.push({ url, form: new URLSearchParams(init?.body ?? '') })
|
||||
const body = queue.length > 1 ? queue.shift() : queue[0]
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
return { fetcher, calls }
|
||||
}
|
||||
|
||||
describe('meta-nonce', () => {
|
||||
test('parses the platform_auth payload the client posts', () => {
|
||||
expect(parseMetaPlatformAuth(PLATFORM_AUTH)).toEqual({ nonce: NONCE, appId: APP_ID })
|
||||
})
|
||||
|
||||
test.each([
|
||||
['not json', 'nonsense'],
|
||||
['no nonce', JSON.stringify({ AppId: APP_ID })],
|
||||
['empty nonce', JSON.stringify({ Nonce: '', AppId: APP_ID })],
|
||||
['no app id', JSON.stringify({ Nonce: NONCE })],
|
||||
// The app id is interpolated into the graph access token, so a non-numeric one
|
||||
// is refused rather than sent.
|
||||
['non-numeric app id', JSON.stringify({ Nonce: NONCE, AppId: 'OC|evil' })],
|
||||
])('rejects a malformed payload (%s)', (_label, payload) => {
|
||||
expect(parseMetaPlatformAuth(payload)).toBeNull()
|
||||
})
|
||||
|
||||
test('validates the nonce against the posted user id and returns the identity', async () => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: true })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result).toEqual({ ok: true, identity: { userId: USER_ID, appId: APP_ID } })
|
||||
|
||||
// The request Meta actually sees: the nonce is bound to THIS user id, and the
|
||||
// app authenticates itself with `OC|<app id>|<secret>`.
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toBe('https://graph.oculus.com/user_nonce_validate')
|
||||
expect(calls[0].form.get('nonce')).toBe(NONCE)
|
||||
expect(calls[0].form.get('user_id')).toBe(USER_ID)
|
||||
expect(calls[0].form.get('access_token')).toBe(`OC|${APP_ID}|${APP_SECRET}`)
|
||||
})
|
||||
|
||||
test('rejects a nonce Meta does not vouch for', async () => {
|
||||
const { fetcher } = stubFetch({ is_valid: false })
|
||||
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)).toEqual({
|
||||
ok: false,
|
||||
reason: 'nonce rejected',
|
||||
})
|
||||
})
|
||||
|
||||
// The whole point of validating against the posted id: a nonce genuinely issued to
|
||||
// one user does not authenticate another. Meta answers is_valid:false for the
|
||||
// mismatch, so nobody can log in by naming someone else's Meta user id.
|
||||
test('a nonce presented for the wrong user id fails', async () => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: false })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, '99999999999999999', APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(calls[0].form.get('user_id')).toBe('99999999999999999')
|
||||
})
|
||||
|
||||
test.each([
|
||||
['missing', ''],
|
||||
['non-numeric', 'not-an-id'],
|
||||
])('refuses a %s user id without calling Meta', async (_label, userId) => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: true })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, userId, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('refuses to attempt verification with no app secret', async () => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: true })
|
||||
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, '', fetcher)).toEqual({
|
||||
ok: false,
|
||||
reason: 'no app secret configured',
|
||||
})
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('surfaces a graph error with its code, for the server log', async () => {
|
||||
const { fetcher } = stubFetch({
|
||||
error: { code: 100, message: 'Invalid OAuth access token', type: 'OAuthException' },
|
||||
})
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'graph error 100: Invalid OAuth access token',
|
||||
})
|
||||
})
|
||||
|
||||
test('a non-retryable graph error is not retried', async () => {
|
||||
const { fetcher, calls } = stubFetch({ error: { code: 100, message: 'bad token' } })
|
||||
await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('retries a transient graph error and succeeds on a later attempt', async () => {
|
||||
const { fetcher, calls } = stubFetch([
|
||||
{ error: { code: 2, message: 'service temporarily unavailable' } },
|
||||
{ is_valid: true },
|
||||
])
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('gives up after three attempts when Meta stays unavailable', async () => {
|
||||
const { fetcher, calls } = stubFetch({ error: { code: 1, message: 'unknown error' } })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(calls).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('treats a network failure as transient', async () => {
|
||||
let attempts = 0
|
||||
const fetcher = (async () => {
|
||||
attempts++
|
||||
throw new Error('connection reset')
|
||||
}) as unknown as typeof fetch
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
test('treats a non-JSON body (an edge error page) as transient', async () => {
|
||||
let attempts = 0
|
||||
const fetcher = (async () => {
|
||||
attempts++
|
||||
return new Response('<html>502</html>', { status: 502 })
|
||||
}) as unknown as typeof fetch
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result).toEqual({ ok: false, reason: 'HTTP 502 with a non-JSON body' })
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -22,11 +22,23 @@
|
||||
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
||||
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
||||
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
|
||||
//
|
||||
// META_APP_SECRET is the Meta (Oculus) app secret, bound only by this worker: Meta
|
||||
// logins are verified by asking Meta to validate the login nonce, which requires
|
||||
// authenticating as the app (see src/meta-nonce.ts). Both secrets must EXIST in the
|
||||
// store or the deploy fails — an operator with no Meta app still has to create
|
||||
// META_APP_SECRET (any placeholder will do); Meta logins then fail with a 500 until
|
||||
// it holds the real value, and nothing else is affected. See DEPLOYING.md.
|
||||
"secrets_store_secrets": [
|
||||
{
|
||||
"binding": "JWT_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "JWT_SECRET"
|
||||
},
|
||||
{
|
||||
"binding": "META_APP_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "META_APP_SECRET"
|
||||
}
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"ImageName": "gay",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:27:25Z"
|
||||
},
|
||||
@@ -23,7 +23,7 @@
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "tip.jpg",
|
||||
"ImageName": "gay",
|
||||
"PlatformMask": 167,
|
||||
"CreatedAt": "2019-02-28T18:15:33Z"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import '../../chat.app'
|
||||
|
||||
import { NotificationType } from '../../../../notify/src/notification-types'
|
||||
import {
|
||||
ChatModerationState,
|
||||
getMessage,
|
||||
@@ -721,7 +722,7 @@ describe('ChatMessageReceived push', () => {
|
||||
/** The stubbed hub (see vitest.config.ts) records what it was sent. */
|
||||
interface SentNotification {
|
||||
playerId: number
|
||||
notificationType: number
|
||||
notificationType: NotificationType
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
||||
@@ -759,8 +760,9 @@ describe('ChatMessageReceived push', () => {
|
||||
|
||||
const sent = await hub.getByName('global').takeSent()
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
|
||||
// NotificationType.ChatMessageReceived
|
||||
expect(sent.every((n) => n.notificationType === 90)).toBe(true)
|
||||
expect(
|
||||
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
|
||||
).toBe(true)
|
||||
expect(sent[0]!.data).toEqual({
|
||||
chatMessageId: chatThread.latestMessage.chatMessageId,
|
||||
chatThreadId: chatThread.chatThreadId,
|
||||
@@ -982,7 +984,9 @@ describe('POST /thread/:id', () => {
|
||||
it('pushes ChatMessageReceived to every member', async () => {
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as {
|
||||
getByName(name: string): {
|
||||
takeSent(): Promise<Array<{ playerId: number; notificationType: number }>>
|
||||
takeSent(): Promise<
|
||||
Array<{ playerId: number; notificationType: NotificationType }>
|
||||
>
|
||||
}
|
||||
}
|
||||
const caller = 889005
|
||||
@@ -992,7 +996,9 @@ describe('POST /thread/:id', () => {
|
||||
await send(caller, `/thread/${chatThreadId}`)
|
||||
const sent = await hub.getByName('global').takeSent()
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006])
|
||||
expect(sent.every((n) => n.notificationType === 90)).toBe(true)
|
||||
expect(
|
||||
sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('reports invalid arguments for blank contents without storing anything', async () => {
|
||||
|
||||
+16
-100
@@ -2,14 +2,7 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
consumeGift,
|
||||
createGift,
|
||||
getGift,
|
||||
getOutfits,
|
||||
getPendingGifts,
|
||||
setOutfit,
|
||||
} from '@repo/domain'
|
||||
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
@@ -19,13 +12,11 @@ import { NotificationType } from '../../notify/src/notification-types'
|
||||
import adCarouselItems from '../static/ad-carousel-items.json'
|
||||
import defaultAvatarItems from '../static/default-avatar-items.json'
|
||||
import defaultAvatar from '../static/default-avatar.json'
|
||||
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
|
||||
import myProgress from '../static/my-progress.json'
|
||||
import weeklyChallenge from '../static/weekly-challenge.json'
|
||||
import { getAvatar, setAvatar } from './avatar-db'
|
||||
import {
|
||||
ALL_PLATFORMS,
|
||||
CurrencyType,
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
getBalance,
|
||||
isSpendable,
|
||||
@@ -38,19 +29,15 @@ import {
|
||||
grantConsumable,
|
||||
} from './consumables-db'
|
||||
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
|
||||
import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db'
|
||||
import { getInventory, grantItem } from './inventory-db'
|
||||
import {
|
||||
AUTHED,
|
||||
AvatarItemV4Dto,
|
||||
AvatarV2Dto,
|
||||
BalanceEntry,
|
||||
BuyItemRequest,
|
||||
BuyItemResponse,
|
||||
ChallengeProgressRequest,
|
||||
ChallengeProgressResponse,
|
||||
ChecklistCompleteResponse,
|
||||
ChecklistEntry,
|
||||
CompleteChecklistRequest,
|
||||
ConsumeConsumableRequest,
|
||||
ConsumeEnvelope,
|
||||
ConsumeGiftRequest,
|
||||
@@ -68,14 +55,16 @@ import {
|
||||
SubscriptionResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from './openapi'
|
||||
import { getOutfits, setOutfit } from './outfit-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { GiftContent, Outfit, StoredGift } from '@repo/domain'
|
||||
import type { GiftContent, StoredGift } from '@repo/domain'
|
||||
import type { Avatar } from './avatar-db'
|
||||
import type { ConsumeResult } from './consumables-db'
|
||||
import type { App } from './context'
|
||||
import type { Equipment } from './equipment-db'
|
||||
import type { AvatarItem } from './inventory-db'
|
||||
import type { Outfit } from './outfit-db'
|
||||
|
||||
/**
|
||||
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
||||
@@ -358,22 +347,6 @@ function toGiftContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default NUX checklist for a brand-new account. `Objective` is an `ObjectiveType`
|
||||
* ordinal (from the client's `ProgressionManager`) that the client matches its own
|
||||
* progress events against — the names below are what those ordinals mean.
|
||||
*/
|
||||
const DEFAULT_CHECKLIST = [
|
||||
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 }, // SaveOutfitSlot
|
||||
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 }, // VisitACustomRoom
|
||||
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 }, // AddAFriend
|
||||
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 }, // GoToRecCenter
|
||||
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 }, // CheerAPlayer
|
||||
]
|
||||
|
||||
/** The `UpdateResponse` context a checklist reward is reported under. */
|
||||
const CHECKLIST_REWARD_CONTEXT = 303
|
||||
|
||||
/**
|
||||
* A concise `describeRoute` spec for a route that serves an opaque JSON array — either
|
||||
* a static catalog served verbatim or an empty-list stub. `auth` adds the bearer
|
||||
@@ -415,12 +388,11 @@ const app = new Hono<App>({ strict: false })
|
||||
(c) => c.json(defaultAvatarItems)
|
||||
)
|
||||
|
||||
// The base items UGC clothing is built on top of — served from bundled static JSON,
|
||||
// separate from the `defaultunlocked` catalog. No auth.
|
||||
// Default base avatar items — empty stub for now. No auth.
|
||||
.get(
|
||||
'/api/avatar/v1/defaultbaseavataritems',
|
||||
listRoute('Default base avatar items', 'The bundled base items UGC clothing builds on'),
|
||||
(c) => c.json(defaultBaseAvatarItems)
|
||||
listRoute('Default base avatar items', 'Empty stub for now'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// The player's avatar items — the items they've bought (from `buyItem`, stored in
|
||||
@@ -434,12 +406,10 @@ const app = new Hono<App>({ strict: false })
|
||||
description: [
|
||||
'The items the player has bought (from buyItem, in the inventory table) prepended',
|
||||
'to the default catalog. A player who has bought nothing gets just the catalog.',
|
||||
'Both sources are projected into the camelCase v4 DTO — the sibling item endpoints',
|
||||
'(`defaultunlocked`, `defaultbaseavataritems`) serve their records raw instead.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(AvatarItemV4Dto.array(), 'Owned items followed by the default catalog'),
|
||||
200: json(JsonArray, 'Owned items followed by the default catalog'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -447,7 +417,7 @@ const app = new Hono<App>({ strict: false })
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const owned = await getInventory(c.env.DB, id)
|
||||
return c.json([...owned, ...defaultAvatarItems].map(toAvatarItemV4))
|
||||
return c.json([...owned, ...defaultAvatarItems])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -562,69 +532,15 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// NUX checklist — the client fetches this on the econ host during load, on either
|
||||
// version path. A 404 here can abort the load orchestration before matchmake. We
|
||||
// serve the default brand-new-account list to everyone: nothing records per-player
|
||||
// checklist progress yet, so it never shrinks as steps are done.
|
||||
.on(
|
||||
'GET',
|
||||
['/api/checklist/v1/current', '/api/checklist/v2/current'],
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'NUX checklist',
|
||||
description:
|
||||
'The new-user checklist, as the default brand-new-account list — nothing records ' +
|
||||
'per-player progress yet, so the same rows come back however much the player has ' +
|
||||
'done. `Objective` is an `ObjectiveType` ordinal the client matches its own ' +
|
||||
'progress events against. v1 and v2 serve the same list.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(ChecklistEntry.array(), 'The checklist rows, in `Order`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
// NUX checklist — the client fetches this on the econ host during load. []
|
||||
// with no DB. A 404 here can abort the load orchestration before matchmake.
|
||||
.get(
|
||||
'/api/checklist/v1/current',
|
||||
listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(DEFAULT_CHECKLIST)
|
||||
}
|
||||
)
|
||||
|
||||
// Mark a checklist row done. [Authorize]. Stubbed: there is no objective-progress
|
||||
// table to record the completion in, and no reward ledger to make the 25-token grant
|
||||
// once-only — without one, re-posting the same row would mint tokens indefinitely, so
|
||||
// we grant nothing and report a change of 0. The envelope is still the balance-update
|
||||
// shape the client parses, so the flow completes instead of erroring.
|
||||
.on(
|
||||
'POST',
|
||||
['/api/checklist/v1/complete', '/api/checklist/v2/complete'],
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Complete a checklist row (stub)',
|
||||
description:
|
||||
'Marks a NUX checklist row done. Stubbed: nothing records the completion (no ' +
|
||||
'objective-progress table) and nothing is granted — a reward is worth 25 XP and 25 ' +
|
||||
'tokens, but making that once-only needs a ledger we do not have, and without one ' +
|
||||
're-posting the same row would mint tokens indefinitely. The response is still the ' +
|
||||
'balance-update envelope, with `Balance` (the change) 0. v1 and v2 behave alike.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(CompleteChecklistRequest, 'Which row was completed — `{ ItemIndex }`'),
|
||||
responses: {
|
||||
200: json(ChecklistCompleteResponse, 'The balance-update envelope, granting nothing'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
// The body names the row (`{ ItemIndex: 1 }`, or `Id` as a fallback) — read only
|
||||
// once there is somewhere to record it.
|
||||
return c.json({
|
||||
BalanceUpdates: [{ UpdateResponse: CHECKLIST_REWARD_CONTEXT, Data: [] }],
|
||||
Balance: 0,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
BalanceType: -2,
|
||||
})
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -40,43 +40,6 @@ export interface AvatarItem extends Record<string, unknown> {
|
||||
Rarity: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The camelCase DTO `GET /api/avatar/v4/items` serves. Distinct from the PascalCase
|
||||
* `AvatarItem` we store and from what the sibling item endpoints (`defaultunlocked`,
|
||||
* `defaultbaseavataritems`) serve — those hand back their stored/bundled records raw.
|
||||
*/
|
||||
export interface AvatarItemV4 {
|
||||
avatarItemId: number
|
||||
avatarItemDesc: string
|
||||
friendlyName: string
|
||||
tooltip: string
|
||||
tagList: string
|
||||
avatarItemType: number
|
||||
rarity: number
|
||||
isBaseAvatarItem: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored or bundled avatar item into the v4 DTO. Neither source carries an
|
||||
* `AvatarItemId`, a `TagList` or an `IsBaseAvatarItem` flag — the storefront gift-drops
|
||||
* we grant from have none and the default catalog has none either — so those default to
|
||||
* 0 / "" / false rather than being invented.
|
||||
*/
|
||||
export function toAvatarItemV4(item: Record<string, unknown>): AvatarItemV4 {
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||
const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
||||
return {
|
||||
avatarItemId: num(item.AvatarItemId),
|
||||
avatarItemDesc: str(item.AvatarItemDesc),
|
||||
friendlyName: str(item.FriendlyName),
|
||||
tooltip: str(item.Tooltip),
|
||||
tagList: str(item.TagList),
|
||||
avatarItemType: num(item.AvatarItemType),
|
||||
rarity: num(item.Rarity),
|
||||
isBaseAvatarItem: item.IsBaseAvatarItem === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant an item into a player's inventory. Upserts on (account_id, avatar_item_desc):
|
||||
* owning an item is boolean, so re-buying it refreshes the stored DTO rather than
|
||||
|
||||
@@ -98,56 +98,6 @@ export const CustomAvatarItemsResponse = z.object({
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One item as `GET /api/avatar/v4/items` serves it — camelCase, unlike the PascalCase
|
||||
* records the sibling item endpoints hand back. `avatarItemId` is 0 and `tagList` empty
|
||||
* for every item we have: neither the default catalog nor a storefront gift-drop carries
|
||||
* them.
|
||||
*/
|
||||
export const AvatarItemV4Dto = z.object({
|
||||
avatarItemId: z.int(),
|
||||
avatarItemDesc: z.string().describe('The comma-delimited item descriptor, commas and all'),
|
||||
friendlyName: z.string(),
|
||||
tooltip: z.string(),
|
||||
tagList: z.string(),
|
||||
avatarItemType: z.int(),
|
||||
rarity: z.int(),
|
||||
isBaseAvatarItem: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/checklist/v1|v2/complete` JSON body — which checklist row was finished.
|
||||
* The client posts just `{ "ItemIndex": 1 }`; `Id` is the fallback key read when
|
||||
* `ItemIndex` is absent or 0.
|
||||
*/
|
||||
export const CompleteChecklistRequest = z.object({
|
||||
ItemIndex: z.int().describe('The row’s index — what the client actually sends'),
|
||||
Id: z.int().optional().describe('Fallback row id, read when ItemIndex is absent or 0'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/checklist/v1|v2/complete` — the balance-update envelope, the same shape
|
||||
* buyItem answers with. `Balance` is the CHANGE applied, so a stubbed (ungranted)
|
||||
* completion reports 0. `UpdateResponse` 303 is the checklist-reward context.
|
||||
*/
|
||||
export const ChecklistCompleteResponse = z.object({
|
||||
BalanceUpdates: z.array(z.object({ UpdateResponse: z.int(), Data: z.array(JsonObject) })),
|
||||
Balance: z.int().describe('The change applied — 0 while completion is stubbed'),
|
||||
CurrencyType: z.int(),
|
||||
BalanceType: z.int().describe('-2 = account-wide'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One row of the new-user checklist (`GET /api/checklist/v1|v2/current`). `Objective` is
|
||||
* an `ObjectiveType` ordinal the client matches its own progress events against.
|
||||
*/
|
||||
export const ChecklistEntry = z.object({
|
||||
Order: z.int().describe('Position in the list, from 0'),
|
||||
Objective: z.int().describe('ObjectiveType ordinal, e.g. 38 = SaveOutfitSlot'),
|
||||
Count: z.int().describe('How many times the objective must happen'),
|
||||
CreditAmount: z.int().describe('Tokens awarded on completion'),
|
||||
})
|
||||
|
||||
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both fields null (no subs yet). */
|
||||
export const SubscriptionResponse = z.object({
|
||||
subscription: z.null(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Saved outfits on the shared `recflare` D1 database — the outfit slots a player
|
||||
* saves from the avatar screen.
|
||||
* saves from the avatar screen (`POST /api/avatar/v3/saved/set`) and loads back from
|
||||
* `GET /api/avatar/v3/saved`.
|
||||
*
|
||||
* One row per (account, slot). The outfit itself is stored as the opaque JSON payload
|
||||
* the client posted: we never query inside it, and its fields (OutfitSelectionsV2,
|
||||
@@ -8,19 +9,11 @@
|
||||
* serializer. Round-tripping it verbatim is both the simplest and the safest thing —
|
||||
* re-encoding risks changing a payload the client has to parse back.
|
||||
*
|
||||
* The `econ` worker owns the schema/migration (apps/econ/migrations/0002_outfit.sql) and
|
||||
* serves the slot list (`GET /api/avatar/v3/saved`, `POST /api/avatar/v3/saved/set`). The
|
||||
* `api` worker reads and writes SLOT 0 through `/outfits/me` — the newer client treats
|
||||
* slot 0 as the outfit currently worn. Both import these helpers so the table name and
|
||||
* row shape live in one place.
|
||||
*
|
||||
* Note the two write paths store DIFFERENT payload shapes into the same column: econ's
|
||||
* saved-outfit slots hold the old flat PascalCase outfit, while `/outfits/me` holds the
|
||||
* newer `{ DataVersion, LegacyData, CustomizationSettings, … }` envelope. Each endpoint
|
||||
* serves back what it stored, so don't add a projection that assumes either one.
|
||||
* The `econ` worker owns this table and its migration (apps/econ/migrations/
|
||||
* 0002_outfit.sql).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of apps/econ/migrations/0002_outfit.sql) — also builds the table in tests. */
|
||||
/** Schema DDL (mirror of migrations 0002_outfit.sql) — also used to build the table in tests. */
|
||||
export const OUTFIT_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS outfit (
|
||||
account_id INTEGER NOT NULL,
|
||||
@@ -34,15 +27,13 @@ export const OUTFIT_SCHEMA_DDL: string[] = [
|
||||
* A saved outfit, as the client posts it. `Slot` is the outfit slot it occupies (the
|
||||
* `set_id` column) — saving to a slot the player already used overwrites it, which is
|
||||
* exactly what the avatar screen's "save over this outfit" does. The rest of the
|
||||
* payload is stored and served back untouched.
|
||||
* payload (PreviewImageName, OutfitSelections, FaceFeatures, SkinColor, HairColor,
|
||||
* CustomAvatarItems, …) is stored and served back untouched.
|
||||
*/
|
||||
export interface Outfit extends Record<string, unknown> {
|
||||
Slot: number
|
||||
}
|
||||
|
||||
/** The slot the newer client wears — what `/outfits/me` reads and writes. */
|
||||
export const CURRENT_OUTFIT_SLOT = 0
|
||||
|
||||
/** Every outfit a player has saved, ordered by slot. */
|
||||
export async function getOutfits(db: D1Database, accountId: number): Promise<Outfit[]> {
|
||||
const { results } = await db
|
||||
@@ -52,19 +43,6 @@ export async function getOutfits(db: D1Database, accountId: number): Promise<Out
|
||||
return results.map((r) => JSON.parse(r.avatar) as Outfit)
|
||||
}
|
||||
|
||||
/** One slot's outfit, or null when the player has never saved into it. */
|
||||
export async function getOutfit(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
slot: number
|
||||
): Promise<Outfit | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT avatar FROM outfit WHERE account_id = ?1 AND set_id = ?2')
|
||||
.bind(accountId, slot)
|
||||
.first<{ avatar: string }>()
|
||||
return row ? (JSON.parse(row.avatar) as Outfit) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an outfit into one of the player's slots, replacing whatever was there. The
|
||||
* upsert is keyed on (account_id, set_id), so re-saving a slot overwrites rather than
|
||||
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../econ.app'
|
||||
|
||||
import { OUTFIT_SCHEMA_DDL, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain'
|
||||
import { RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain'
|
||||
|
||||
import { SCHEMA_DDL } from '../../avatar-db'
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
||||
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db'
|
||||
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
||||
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
@@ -98,15 +99,10 @@ describe('econ endpoints', () => {
|
||||
expect(body[0]).toHaveProperty('AvatarItemDesc')
|
||||
})
|
||||
|
||||
test('GET /api/avatar/v1/defaultbaseavataritems returns the base items (no auth)', async () => {
|
||||
test('GET /api/avatar/v1/defaultbaseavataritems is an empty stub (no auth)', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/defaultbaseavataritems`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Array<Record<string, unknown>>
|
||||
expect(body.map((i) => i.AvatarItemId)).toEqual([2184, 2918])
|
||||
// The client keys these off IsBaseAvatarItem, and the trailing comma in the desc
|
||||
// is part of the item descriptor — both are served verbatim.
|
||||
expect(body.every((i) => i.IsBaseAvatarItem === true)).toBe(true)
|
||||
expect(body[0]?.AvatarItemDesc).toBe('c5d70cb4-71dd-4fe4-b719-34fe2073c611,')
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/avatar/v4/items 401s without a token', async () => {
|
||||
@@ -114,34 +110,16 @@ describe('econ endpoints', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /api/avatar/v4/items serves the catalog in the camelCase v4 shape', async () => {
|
||||
test('GET /api/avatar/v4/items returns the item catalog with a valid token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Array<Record<string, unknown>>
|
||||
const body = (await res.json()) as unknown[]
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body.length).toBeGreaterThan(0)
|
||||
// Every key of the DTO is present on every item, and nothing PascalCase leaks
|
||||
// through from the stored/bundled records.
|
||||
for (const item of body) {
|
||||
expect(Object.keys(item).sort()).toEqual([
|
||||
'avatarItemDesc',
|
||||
'avatarItemId',
|
||||
'avatarItemType',
|
||||
'friendlyName',
|
||||
'isBaseAvatarItem',
|
||||
'rarity',
|
||||
'tagList',
|
||||
'tooltip',
|
||||
])
|
||||
}
|
||||
expect(typeof body[0]?.avatarItemDesc).toBe('string')
|
||||
expect(typeof body[0]?.friendlyName).toBe('string')
|
||||
// The catalog carries no ids, tags or base flag — those default rather than
|
||||
// being invented.
|
||||
expect(body[0]?.avatarItemId).toBe(0)
|
||||
expect(body[0]?.tagList).toBe('')
|
||||
expect(body[0]?.isBaseAvatarItem).toBe(false)
|
||||
expect(body[0]).toHaveProperty('AvatarItemDesc')
|
||||
expect(body[0]).toHaveProperty('FriendlyName')
|
||||
})
|
||||
|
||||
test('GET /api/avatar/v2 401s without a token', async () => {
|
||||
@@ -292,53 +270,14 @@ describe('econ endpoints', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/checklist/v1|v2/current 401s without a token, serves the NUX list with one', async () => {
|
||||
const expected = [
|
||||
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 },
|
||||
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 },
|
||||
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 },
|
||||
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 },
|
||||
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 },
|
||||
]
|
||||
// Both version paths are live and serve the same list.
|
||||
for (const path of ['/api/checklist/v1/current', '/api/checklist/v2/current']) {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}${path}`)
|
||||
expect(anon.status).toBe(401)
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}`, { headers: await bearer() })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual(expected)
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /api/checklist/v1|v2/complete 401s without a token, grants nothing with one', async () => {
|
||||
for (const path of ['/api/checklist/v1/complete', '/api/checklist/v2/complete']) {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ItemIndex: 1 }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('33')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ItemIndex: 1 }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
BalanceUpdates: [{ UpdateResponse: 303, Data: [] }],
|
||||
Balance: 0,
|
||||
CurrencyType: 2,
|
||||
BalanceType: -2,
|
||||
})
|
||||
}
|
||||
|
||||
// Stubbed, so completing rows does not move the balance — re-posting cannot farm
|
||||
// tokens, and the checklist still lists every row.
|
||||
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||
headers: await bearer('33'),
|
||||
test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`)
|
||||
expect(anon.status).toBe(401)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }])
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/itemWishlists/v1/wishlist/me 401s without a token, returns [] with one', async () => {
|
||||
@@ -727,9 +666,9 @@ describe('econ endpoints', () => {
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('20'),
|
||||
})
|
||||
const list = (await items.json()) as Array<{ avatarItemDesc: string; friendlyName: string }>
|
||||
expect(list[0].friendlyName).toBe('Bowtie (White)')
|
||||
expect(list[0].avatarItemDesc).toBe(gift.AvatarItemDesc)
|
||||
const list = (await items.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }>
|
||||
expect(list[0].FriendlyName).toBe('Bowtie (White)')
|
||||
expect(list[0].AvatarItemDesc).toBe(gift.AvatarItemDesc)
|
||||
|
||||
// And a pending gift box is waiting to be opened.
|
||||
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
|
||||
@@ -810,8 +749,8 @@ describe('econ endpoints', () => {
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('25'),
|
||||
})
|
||||
const list = (await items.json()) as Array<{ friendlyName: string }>
|
||||
expect(list.every((i) => i.friendlyName !== 'Supreme Pizza')).toBe(true)
|
||||
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||
expect(list.every((i) => i.FriendlyName !== 'Supreme Pizza')).toBe(true)
|
||||
|
||||
// Buying it again stacks: a second instance, count summed to 2.
|
||||
expect((await buy()).status).toBe(200)
|
||||
@@ -877,8 +816,8 @@ describe('econ endpoints', () => {
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('31'),
|
||||
})
|
||||
const list = (await items.json()) as Array<{ friendlyName: string }>
|
||||
expect(list.every((i) => i.friendlyName !== 'Disc Skin (Coop)')).toBe(true)
|
||||
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||
expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true)
|
||||
|
||||
expect(first[0].Favorited).toBe(false)
|
||||
|
||||
@@ -977,8 +916,8 @@ describe('econ endpoints', () => {
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('23'),
|
||||
})
|
||||
const list = (await items.json()) as Array<{ friendlyName: string }>
|
||||
expect(list.every((i) => i.friendlyName !== 'Bowtie (White)')).toBe(true)
|
||||
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
|
||||
})
|
||||
|
||||
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
|
||||
@@ -1018,8 +957,8 @@ describe('econ endpoints', () => {
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('24'),
|
||||
})
|
||||
const list = (await items.json()) as Array<{ friendlyName: string }>
|
||||
expect(list.some((i) => i.friendlyName === 'Bowtie (White)')).toBe(true)
|
||||
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||
expect(list.some((i) => i.FriendlyName === 'Bowtie (White)')).toBe(true)
|
||||
|
||||
// Opening it again is a harmless no-op — still 200.
|
||||
const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, {
|
||||
@@ -1230,7 +1169,6 @@ describe('econ endpoints', () => {
|
||||
'GET /api/avatar/v4/items',
|
||||
'GET /api/challenge/v2/getCurrent',
|
||||
'GET /api/checklist/v1/current',
|
||||
'GET /api/checklist/v2/current',
|
||||
'GET /api/consumables/v2/getUnlocked',
|
||||
'GET /api/equipment/v2/getUnlocked',
|
||||
'GET /api/gamerewards/v1/pending',
|
||||
@@ -1253,8 +1191,6 @@ describe('econ endpoints', () => {
|
||||
'POST /api/avatar/v3/saved/set',
|
||||
'POST /api/avatar/v4/saved/set',
|
||||
'POST /api/challenge/v2/updateProgress',
|
||||
'POST /api/checklist/v1/complete',
|
||||
'POST /api/checklist/v2/complete',
|
||||
'POST /api/consumables/v1/consume',
|
||||
'POST /api/gamerewards/v1/request',
|
||||
'POST /api/objectives/v1/cleargroup',
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
[
|
||||
{
|
||||
"AvatarItemDesc": "c5d70cb4-71dd-4fe4-b719-34fe2073c611,",
|
||||
"AvatarItemType": 0,
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "(UGCTee_Shirt) ",
|
||||
"Tooltip": "",
|
||||
"Rarity": -1,
|
||||
"TagList": "",
|
||||
"AvatarItemId": 2184,
|
||||
"IsBaseAvatarItem": true,
|
||||
"CreatedAt": "2022-04-19T23:40:30.807Z",
|
||||
"ThumbnailImage": "KXfytDhXzES2yco-rwqDSA.png"
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "95a519de-f2cb-429c-b014-508477f20d42,",
|
||||
"AvatarItemType": 0,
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "(UGCPulloverHoodie_Shirt) ",
|
||||
"Tooltip": "",
|
||||
"Rarity": -1,
|
||||
"TagList": "",
|
||||
"AvatarItemId": 2918,
|
||||
"IsBaseAvatarItem": true,
|
||||
"CreatedAt": "2023-04-07T17:07:07.04Z",
|
||||
"ThumbnailImage": "m4UIuZjNzEWsCP1gpZBgjg.png"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
+1
-184
@@ -30,14 +30,13 @@ import {
|
||||
subRoomDataBlob,
|
||||
} from '@repo/domain'
|
||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { generatePhotonAuthToken, validateAndGetAccountId } from '@repo/jwt'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
// Value import of the notify worker's NotificationType enum (its bundle has no runtime
|
||||
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import {
|
||||
AUTHED,
|
||||
ConnectionInfoResponse,
|
||||
EMPTY_OK,
|
||||
ExclusiveLoginResponse,
|
||||
form,
|
||||
@@ -50,7 +49,6 @@ import {
|
||||
MatchmakeRoomRequest,
|
||||
NotifyDisconnectRequest,
|
||||
PlayerDto,
|
||||
QosRegion,
|
||||
RoomInstanceDto,
|
||||
StatusVisibilityRequest,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
@@ -87,54 +85,6 @@ const NULL_CONNECTION_INFO = {
|
||||
experiments: null,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The Photon applications the client connects to (`GET /player/connection-info`).
|
||||
* Temporary placeholders — move them to wrangler vars before they need to differ per
|
||||
* environment. `photonRegion` matches the value `roomInstanceFromRoom` stamps on every
|
||||
* instance, so the two can't disagree ('us' resolves to us-east1 for QoS).
|
||||
*/
|
||||
const PHOTON_APPS = {
|
||||
photonRealtimeAppId: '',
|
||||
photonVoiceAppId: '',
|
||||
photonChatAppId: '',
|
||||
photonRegion: 'us',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Networking feature flags the client reads off its connection info. Verbatim from
|
||||
* the reference server — the client changes how it replicates based on these, so they
|
||||
* are not free to tune. The load-bearing one is `shouldUseGameServerNetworking`:
|
||||
* true makes the client connect to a local game server (127.0.0.1:7777) instead of
|
||||
* Photon, which is not what recflare runs.
|
||||
*/
|
||||
const PHOTON_EXPERIMENTS = {
|
||||
networkTransformSyncInterval: 10.0,
|
||||
shouldUseUnreliableOnChange: false,
|
||||
shouldAvoidDiscontinuityRPCs: true,
|
||||
shouldAvoidRedundantDiscontinuity: false,
|
||||
r2RuntimeStaticBaking: true,
|
||||
r2AutoEmbodiment: true,
|
||||
r2RuntimeStaticBakingMinShapeThreshold: 1,
|
||||
r2UseCheapReplicas: true,
|
||||
shouldUseGameServerNetworking: false,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The regions the client probes for latency (`GET /player/qos`), reporting the results
|
||||
* back through `PUT /player/photonregionpings`. Rec Room's own QoS endpoints, served
|
||||
* verbatim: recflare doesn't run probe servers, and the client only uses the timings to
|
||||
* rank regions — a ranking it can't act on here, since `PHOTON_APPS.photonRegion` pins
|
||||
* every session to one region regardless. `address` is `host:port`, not a URL.
|
||||
*/
|
||||
const QOS_REGIONS = [
|
||||
{ id: 'us-west1', address: '34.169.254.144:50000' },
|
||||
{ id: 'europe-west1', address: '35.205.141.119:50000' },
|
||||
{ id: 'asia-northeast1', address: '35.200.67.228:50000' },
|
||||
{ id: 'us-east1', address: '34.73.244.122:50000' },
|
||||
{ id: 'us-central1', address: '34.69.179.51:50000' },
|
||||
{ id: 'northamerica-northeast1', address: '34.152.4.100:50000' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* A player's presence as the client reads it (`/player`, `/player/heartbeat`).
|
||||
* `isOnline` means "has a live presence row" — presence rows expire, so a player who
|
||||
@@ -1099,41 +1049,6 @@ const app = new Hono<App>()
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
}
|
||||
)
|
||||
// Matchmake with no target. The client posts this when it needs an instance but isn't
|
||||
// going anywhere in particular — at startup, and while sitting in Orientation. It
|
||||
// answers the instance the player is ALREADY in, so it never warps anyone out of the
|
||||
// room they're standing in; only a player with no live presence falls back to their
|
||||
// dorm. Either way presence is re-committed, which refreshes its TTL.
|
||||
.post(
|
||||
'/matchmake/none',
|
||||
describeRoute({
|
||||
tags: ['Navigation'],
|
||||
summary: 'Matchmake with no target',
|
||||
description: [
|
||||
'Answers the instance the caller is already in, rather than sending them anywhere —',
|
||||
'this is what the client posts at startup and while in Orientation, so forcing a',
|
||||
'destination here would warp the player out of the room they are standing in. A',
|
||||
'caller with no live presence (their TTL lapsed, or they have never entered a room)',
|
||||
'falls back to their personal dorm. Re-commits presence either way, refreshing its',
|
||||
'TTL.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(MatchmakeResponse, 'The caller’s current instance, or their dorm'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
const current = presence?.roomInstance ?? (await playerDormInstance(c, id))
|
||||
await enterRoom(c, id, current)
|
||||
return c.json({ errorCode: 0, roomInstance: current })
|
||||
}
|
||||
)
|
||||
|
||||
.post(
|
||||
'/matchmake/dorm',
|
||||
describeRoute({
|
||||
@@ -1160,104 +1075,6 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// The realtime credentials the caller should connect with: a freshly minted Photon
|
||||
// auth token, the Photon applications, and the Photon room they belong in. That last
|
||||
// one comes from the caller's own presence — the instance matchmaking put them in —
|
||||
// so it's the same name every other player in that instance is given. The reference
|
||||
// reads presence and nothing else; we fall back to looking the `roomInstanceId` query
|
||||
// param up when presence has no room (it expires on a TTL, and the client sometimes
|
||||
// asks before matchmaking has landed), and to an empty string when neither resolves.
|
||||
.get(
|
||||
'/player/connection-info',
|
||||
describeRoute({
|
||||
tags: ['Presence'],
|
||||
summary: 'Photon connection info',
|
||||
description: [
|
||||
'The realtime (Photon) credentials the caller should connect with, in a',
|
||||
'`{ success, value, error }` envelope: a freshly minted `photonAuthToken`, the',
|
||||
'Photon application ids, and the `photonRoomId` of the instance the caller is in',
|
||||
'(from their presence, falling back to the `roomInstanceId` query param). There is',
|
||||
'no separate voice server, so the voice fields are null. `experiments` carries the',
|
||||
'client’s networking flags.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'roomInstanceId',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'The instance being connected to; used only when presence has no room',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(ConnectionInfoResponse, 'The Photon credentials, room, and experiment flags'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
// Presence first (it's the instance the player is actually in); the query param
|
||||
// only stands in when there's no live presence to read.
|
||||
let photonRoomId = presence?.roomInstance?.photonRoomId ?? ''
|
||||
if (!photonRoomId) {
|
||||
const requested = Number.parseInt(c.req.query('roomInstanceId') ?? '', 10)
|
||||
if (!Number.isNaN(requested)) {
|
||||
photonRoomId = (await getRoomInstance(c.env.DB, requested))?.photonRoomId ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
// Identifies the player to Photon. Signed with the shared JWT secret; the token's
|
||||
// `aud` is the realtime app it's for. Nothing verifies it while Photon is
|
||||
// self-hosted, so it's identifying rather than authorizing.
|
||||
const photonAuthToken = await generatePhotonAuthToken(
|
||||
id,
|
||||
{
|
||||
platformId: (await getAccount(c.env.DB, id))?.platformId ?? '',
|
||||
platform: presence?.platform ?? 0,
|
||||
deviceClass: presence?.deviceClass ?? 0,
|
||||
audience: PHOTON_APPS.photonRealtimeAppId,
|
||||
},
|
||||
await c.env.JWT_SECRET.get()
|
||||
)
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
value: {
|
||||
photonAuthToken,
|
||||
...PHOTON_APPS,
|
||||
photonRoomId,
|
||||
voiceConnectionInfo: null,
|
||||
voiceServerId: null,
|
||||
experiments: PHOTON_EXPERIMENTS,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// The regions to probe, which the two ping-report routes below are the other half of.
|
||||
// Unauthenticated: it's a fixed public list, and the client fetches it early. A bare
|
||||
// array — no `{ success, value, error }` envelope.
|
||||
.get(
|
||||
'/player/qos',
|
||||
describeRoute({
|
||||
tags: ['Presence'],
|
||||
summary: 'QoS probe targets',
|
||||
description: [
|
||||
'The regions the client pings to measure latency, reporting the results back through',
|
||||
'`PUT /player/photonregionpings`. Rec Room’s own probe endpoints, served verbatim —',
|
||||
'recflare runs none of its own, and the resulting ranking is unused anyway: every',
|
||||
'session is pinned to the one region `/player/connection-info` hands out.',
|
||||
].join(' '),
|
||||
responses: { 200: json(QosRegion.array(), 'The regions to probe, as `host:port`') },
|
||||
}),
|
||||
(c) => c.json(QOS_REGIONS)
|
||||
)
|
||||
|
||||
// Region ping reports — accept-and-ack (the reference returns Ok()).
|
||||
.put(
|
||||
'/player/photonregionpings',
|
||||
|
||||
@@ -135,65 +135,6 @@ export const MatchmakeResponse = z.object({
|
||||
/** `POST /player/exclusivelogin` — a bare error code. */
|
||||
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
|
||||
|
||||
/**
|
||||
* The networking feature flags the client reads off its connection info — verbatim
|
||||
* from the reference server. The client changes how it replicates based on these, so
|
||||
* they are not free to tune. `shouldUseGameServerNetworking` is the load-bearing one:
|
||||
* true points the client at a local game server (127.0.0.1:7777) instead of Photon.
|
||||
*/
|
||||
export const ConnectionExperiments = z.object({
|
||||
networkTransformSyncInterval: z.number(),
|
||||
shouldUseUnreliableOnChange: z.boolean(),
|
||||
shouldAvoidDiscontinuityRPCs: z.boolean(),
|
||||
shouldAvoidRedundantDiscontinuity: z.boolean(),
|
||||
r2RuntimeStaticBaking: z.boolean(),
|
||||
r2AutoEmbodiment: z.boolean(),
|
||||
r2RuntimeStaticBakingMinShapeThreshold: z.int(),
|
||||
r2UseCheapReplicas: z.boolean(),
|
||||
shouldUseGameServerNetworking: z
|
||||
.boolean()
|
||||
.describe('true connects to a local game server instead of Photon'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /player/connection-info` — the realtime (Photon) credentials, in a
|
||||
* `{ success, value, error }` envelope. The applications and region are fixed for
|
||||
* recflare; what varies per caller is `photonAuthToken` (minted for them on the spot)
|
||||
* and `photonRoomId`, the Photon room of the instance their presence says they're in
|
||||
* — the same name every other player in that instance is handed. There's no separate
|
||||
* voice server, so both voice fields are null. `photonRegion` matches the one stamped
|
||||
* on every room instance, so the two can't disagree.
|
||||
*/
|
||||
export const ConnectionInfo = z.object({
|
||||
photonAuthToken: z.string().describe('Short-lived HS256 token identifying the caller to Photon'),
|
||||
photonRealtimeAppId: z.string().describe('Photon Realtime application id'),
|
||||
photonVoiceAppId: z.string().describe('Photon Voice application id'),
|
||||
photonChatAppId: z.string().describe('Photon Chat application id'),
|
||||
photonRegion: z.string().describe('Region id, matching a room instance’s `photonRegion`'),
|
||||
photonRoomId: z.string().describe('The caller’s current instance; empty when they’re in none'),
|
||||
voiceConnectionInfo: z.null().describe('Null — no separate voice server'),
|
||||
voiceServerId: z.null().describe('Null — no separate voice server'),
|
||||
experiments: ConnectionExperiments,
|
||||
})
|
||||
|
||||
/** `GET /player/connection-info` — the connection info in the client's standard envelope. */
|
||||
export const ConnectionInfoResponse = z.object({
|
||||
success: z.literal(true),
|
||||
value: ConnectionInfo,
|
||||
error: z.null(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One QoS probe target (`GET /player/qos`) — a region the client pings to measure
|
||||
* latency, then reports back through `PUT /player/photonregionpings`. A bare array,
|
||||
* not the `{ success, value, error }` envelope. `id` is the region id the pings are
|
||||
* keyed by; `address` is `host:port`, not a URL.
|
||||
*/
|
||||
export const QosRegion = z.object({
|
||||
id: z.string().describe('Region id, e.g. `us-east1`'),
|
||||
address: z.string().describe('`host:port` of the probe endpoint'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The session `LoginLock` GUID form field. The client posts it on every presence
|
||||
* lifecycle call — `POST /player/login`, `/player/exclusivelogin`, `/player/logout`,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getRoomInstance,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
ROOM_INSTANCE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
@@ -83,15 +84,9 @@ const TEST_ROOMS = [
|
||||
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')
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS room (
|
||||
data TEXT NOT NULL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
|
||||
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
||||
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Subrooms live in their own table now; seed each room and split its subrooms into it.
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||
@@ -470,25 +465,6 @@ describe('public endpoints', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
test('GET /player/connection-info 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /player/qos returns the probe targets', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/qos`)
|
||||
expect(res.status).toBe(200)
|
||||
// A bare array, not the { success, value, error } envelope connection-info uses.
|
||||
expect(await res.json()).toEqual([
|
||||
{ id: 'us-west1', address: '34.169.254.144:50000' },
|
||||
{ id: 'europe-west1', address: '35.205.141.119:50000' },
|
||||
{ id: 'asia-northeast1', address: '35.200.67.228:50000' },
|
||||
{ id: 'us-east1', address: '34.73.244.122:50000' },
|
||||
{ id: 'us-central1', address: '34.69.179.51:50000' },
|
||||
{ id: 'northamerica-northeast1', address: '34.152.4.100:50000' },
|
||||
])
|
||||
})
|
||||
|
||||
test('PUT /player/photonregionpings returns 200', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/photonregionpings`, { method: 'PUT' })
|
||||
expect(res.status).toBe(200)
|
||||
@@ -530,99 +506,6 @@ describe('auth-gated endpoints', () => {
|
||||
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
|
||||
})
|
||||
|
||||
test('GET /player/connection-info hands back the Photon room the caller matchmade into', async () => {
|
||||
const matchmaked = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('960'),
|
||||
})
|
||||
).json()) as { roomInstance: { photonRoomId: string } }
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
|
||||
headers: await bearer('960'),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
success: true,
|
||||
value: {
|
||||
// A signed JWT, not an opaque id — three base64url segments.
|
||||
photonAuthToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/),
|
||||
photonRealtimeAppId: '',
|
||||
photonVoiceAppId: '',
|
||||
photonChatAppId: '',
|
||||
// Matches the region every room instance is stamped with.
|
||||
photonRegion: 'us',
|
||||
// The room the client is told to join has to be the one matchmaking placed
|
||||
// them in, or they end up alone in a room of their own.
|
||||
photonRoomId: matchmaked.roomInstance.photonRoomId,
|
||||
voiceConnectionInfo: null,
|
||||
voiceServerId: null,
|
||||
experiments: {
|
||||
networkTransformSyncInterval: 10,
|
||||
shouldUseUnreliableOnChange: false,
|
||||
shouldAvoidDiscontinuityRPCs: true,
|
||||
shouldAvoidRedundantDiscontinuity: false,
|
||||
r2RuntimeStaticBaking: true,
|
||||
r2AutoEmbodiment: true,
|
||||
r2RuntimeStaticBakingMinShapeThreshold: 1,
|
||||
r2UseCheapReplicas: true,
|
||||
// true would send the client to a local game server instead of Photon.
|
||||
shouldUseGameServerNetworking: false,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /player/connection-info mints a token carrying the caller’s id', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
|
||||
headers: await bearer('961'),
|
||||
})
|
||||
const body = (await res.json()) as { value: { photonAuthToken: string } }
|
||||
const claims = JSON.parse(atob(body.value.photonAuthToken.split('.')[1]!)) as {
|
||||
sub: string
|
||||
aud: string
|
||||
exp: number
|
||||
'rn.env': string
|
||||
}
|
||||
expect(claims.sub).toBe('961')
|
||||
// Scoped to the realtime app, and short-lived.
|
||||
expect(claims.aud).toBe('xx')
|
||||
expect(claims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000))
|
||||
// The client is built against prod regardless of which environment we run in.
|
||||
expect(claims['rn.env']).toBe('prod')
|
||||
})
|
||||
|
||||
test('GET /player/connection-info falls back to ?roomInstanceId when presence has no room', async () => {
|
||||
// Player 962 never matchmade, so there's no presence to read the room from; the
|
||||
// param names the instance they're trying to connect to.
|
||||
const instance = await createRoomInstance(env.DB, {
|
||||
roomId: 2,
|
||||
subRoomId: 2,
|
||||
roomInstanceType: 0,
|
||||
photonRoomId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
maxCapacity: 12,
|
||||
isPrivate: false,
|
||||
ownerAccountId: 962,
|
||||
})
|
||||
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/player/connection-info?roomInstanceId=${instance.roomInstanceId}`,
|
||||
{ headers: await bearer('962') }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { value: { photonRoomId: string } }
|
||||
expect(body.value.photonRoomId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
})
|
||||
|
||||
test('GET /player/connection-info serves an empty photonRoomId when nothing resolves', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
|
||||
headers: await bearer('963'),
|
||||
})
|
||||
const body = (await res.json()) as { value: { photonRoomId: string } }
|
||||
expect(body.value.photonRoomId).toBe('')
|
||||
})
|
||||
|
||||
test('re-matchmaking into your current room returns a different instance (id must change)', async () => {
|
||||
// The client keys the room transition off a changing roomInstanceId; handing back
|
||||
// the instance the player is already in hangs their join. RecCenter (cap 12) so
|
||||
@@ -676,45 +559,6 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /matchmake/none 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /matchmake/none keeps the caller where they are, else falls back to the dorm', async () => {
|
||||
const none = async (sub: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/none`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
).json()) as { errorCode: number; roomInstance: { roomId: number; roomInstanceId: number } }
|
||||
|
||||
// Account 44 has never entered a room → their personal dorm, and a second call is
|
||||
// idempotent now that presence holds it.
|
||||
const fresh = await none('44')
|
||||
expect(fresh.errorCode).toBe(0)
|
||||
expect(fresh.roomInstance.roomId).toBeGreaterThan(2)
|
||||
expect((await none('44')).roomInstance).toMatchObject({
|
||||
roomId: fresh.roomInstance.roomId,
|
||||
roomInstanceId: fresh.roomInstance.roomInstanceId,
|
||||
})
|
||||
|
||||
// Once in a real room, `none` must NOT warp them out of it — that is the whole
|
||||
// point of the endpoint, since the client posts it while sitting in Orientation.
|
||||
const entered = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('44'),
|
||||
})
|
||||
).json()) as { roomInstance: { roomId: number; roomInstanceId: number } }
|
||||
expect(entered.roomInstance.roomId).toBe(2)
|
||||
expect((await none('44')).roomInstance).toMatchObject({
|
||||
roomId: 2,
|
||||
roomInstanceId: entered.roomInstance.roomInstanceId,
|
||||
})
|
||||
})
|
||||
|
||||
test('each player’s dorm gets a distinct global subroom id', async () => {
|
||||
// Dorms used to copy the template subroom verbatim, so every dorm carried SubRoomId 1.
|
||||
// With subrooms minted from the global sequence, each dorm gets its own unique id.
|
||||
@@ -1484,15 +1328,12 @@ describe('auth-gated endpoints', () => {
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /player',
|
||||
'GET /player/connection-info',
|
||||
'GET /player/qos',
|
||||
'GET /room/{roomId}/instances',
|
||||
'GET /rooms/requiring/developer',
|
||||
'GET /rooms/requiring/rrplus',
|
||||
'POST /invite',
|
||||
'POST /matchmake/club/{clubId}',
|
||||
'POST /matchmake/dorm',
|
||||
'POST /matchmake/none',
|
||||
'POST /matchmake/player/{playerId}',
|
||||
'POST /matchmake/room/{roomId}',
|
||||
'POST /matchmake/room/{roomId}/{subRoomId}',
|
||||
|
||||
@@ -12,6 +12,9 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/
|
||||
export type Env = SharedHonoEnv & {
|
||||
// HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere.
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
// Meta (Oculus) app secret, from the same store. Read only by `auth`, to validate a
|
||||
// headset login's nonce with Meta (see apps/auth/src/meta-nonce.ts).
|
||||
META_APP_SECRET: SecretsStoreSecret
|
||||
// Shared `recflare` database (accounts, auth, api, clubs, match, rooms, …).
|
||||
DB: D1Database
|
||||
// Image storage bucket (api, img).
|
||||
|
||||
@@ -50,13 +50,20 @@
|
||||
"crons": ["*/5 * * * *"]
|
||||
},
|
||||
"logpush": false,
|
||||
// Shared Secrets Store holding the HS256 JWT signing key. "local" store_id replaced
|
||||
// with RECFLARE_SECRETS_STORE at deploy.
|
||||
// Shared Secrets Store holding the HS256 JWT signing key, plus the Meta app secret
|
||||
// the mounted `auth` app needs to verify Oculus logins. "local" store_id replaced
|
||||
// with RECFLARE_SECRETS_STORE at deploy. Both must exist in the store or the deploy
|
||||
// fails — see DEPLOYING.md.
|
||||
"secrets_store_secrets": [
|
||||
{
|
||||
"binding": "JWT_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "JWT_SECRET"
|
||||
},
|
||||
{
|
||||
"binding": "META_APP_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "META_APP_SECRET"
|
||||
}
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
|
||||
+4
-11
@@ -7,18 +7,11 @@ discover every service host (Accounts, API, Auth, Econ, Matchmaking,
|
||||
Notifications, …).
|
||||
|
||||
Each host is built at runtime from the `DOMAIN` var (the base domain) plus the
|
||||
service → subdomain map in `src/endpoints.ts`, with the `SUBDOMAINS` var applied
|
||||
on top. Both vars are injected at deploy time from `RECFLARE_DOMAIN` and
|
||||
`RECFLARE_SUBDOMAINS` (see `run-wrangler-deploy`) and default to
|
||||
`rec.example.com` / `{}` in `wrangler.jsonc` for local dev.
|
||||
service → subdomain map in `src/endpoints.ts`. `DOMAIN` is injected at deploy
|
||||
time from `RECFLARE_DOMAIN` (see `run-wrangler-deploy`) and defaults to
|
||||
`rec.example.com` in `wrangler.jsonc` for local dev.
|
||||
|
||||
## Updating endpoints
|
||||
|
||||
- To change the base domain, set `RECFLARE_DOMAIN` (in `.env`) and redeploy.
|
||||
- To point one service at a different host, add it to `RECFLARE_SUBDOMAINS` (in
|
||||
`.env`) and redeploy. It's keyed by the service's _default_ subdomain — the
|
||||
same object `run-wrangler-deploy` reads to pick a worker's host, so an entry
|
||||
moves the deployed worker and the advertised host together. An entry for a
|
||||
service with no worker (e.g. `{"moderation":"api"}`) is a pure client-side
|
||||
redirect onto a host another worker already serves.
|
||||
- To add or rename a service, edit the map in `src/endpoints.ts`.
|
||||
- To add or rename a service host, edit the map in `src/endpoints.ts`.
|
||||
|
||||
@@ -8,14 +8,6 @@ export type Env = SharedHonoEnv & {
|
||||
* for local dev and tests.
|
||||
*/
|
||||
DOMAIN: string
|
||||
|
||||
/**
|
||||
* Per-service subdomain overrides as a raw JSON object keyed by default subdomain,
|
||||
* e.g. `{"moderation":"api"}`. The operator's `RECFLARE_SUBDOMAINS`, injected at deploy
|
||||
* time via `--var SUBDOMAINS`; defaults to `{}` in `wrangler.jsonc`. See
|
||||
* `parseOverrides` in `endpoints.ts`.
|
||||
*/
|
||||
SUBDOMAINS: string
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* Service-discovery map: service label → default subdomain. The game client fetches the
|
||||
* Service-discovery map: service label → subdomain. The game client fetches the
|
||||
* generated `{ label: "https://<subdomain>.<domain>" }` document from `/`.
|
||||
*
|
||||
* The base domain is injected at deploy time via the `DOMAIN` var (see
|
||||
* `run-wrangler-deploy`), so the real domain never lives in a versioned file. The
|
||||
* subdomains here are defaults — an operator redirects any of them from `.env`, see
|
||||
* `applyOverrides` below.
|
||||
* `run-wrangler-deploy`), so the real domain never lives in a versioned file.
|
||||
*/
|
||||
const SERVICE_SUBDOMAINS = {
|
||||
Accounts: 'accounts',
|
||||
@@ -46,48 +44,9 @@ const SERVICE_SUBDOMAINS = {
|
||||
WWW: 'www',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parses the `SUBDOMAINS` var — the operator's `RECFLARE_SUBDOMAINS` object, injected at
|
||||
* deploy time by `run-wrangler-deploy`.
|
||||
*
|
||||
* It is keyed by the DEFAULT subdomain above, not by the service label, because the deploy
|
||||
* script reads the very same object keyed by a worker's directory name — and every worker's
|
||||
* directory name is its default subdomain. So one `.env` entry moves both sides at once:
|
||||
* `{"playersettings":"settings"}` both deploys the `playersettings` worker onto
|
||||
* `settings.<domain>` and advertises that host to the client. Entries naming a service with
|
||||
* no worker of its own are pure client-side redirects — `{"moderation":"api"}` points the
|
||||
* client's Moderation calls at the `api` worker, which is where the
|
||||
* `/api/PlayerReporting/…` routes actually live.
|
||||
*
|
||||
* A malformed value is ignored rather than thrown: this document is the first thing the
|
||||
* client fetches, so a typo in `.env` should cost one redirect, not every service host.
|
||||
*/
|
||||
function parseOverrides(subdomains: string | undefined): Record<string, string> {
|
||||
if (!subdomains) return {}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(subdomains)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
|
||||
/** Builds the endpoints document for `domain`, e.g. `rec.example.com`. */
|
||||
export function buildEndpoints(domain: string): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== ''
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the endpoints document for `domain`, e.g. `rec.example.com`, applying any
|
||||
* subdomain overrides from `subdomains` (the raw `SUBDOMAINS` var JSON).
|
||||
*/
|
||||
export function buildEndpoints(domain: string, subdomains?: string): Record<string, string> {
|
||||
const overrides = parseOverrides(subdomains)
|
||||
return Object.fromEntries(
|
||||
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [
|
||||
label,
|
||||
`https://${overrides[sub] ?? sub}.${domain}`,
|
||||
])
|
||||
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [label, `https://${sub}.${domain}`])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,6 @@ const app = new Hono<App>()
|
||||
.notFound(withNotFound())
|
||||
|
||||
// Endpoints document, derived from the deploy-time base domain.
|
||||
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN, c.env.SUBDOMAINS)))
|
||||
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN)))
|
||||
|
||||
export default app
|
||||
|
||||
@@ -18,19 +18,6 @@ describe('ns endpoints', () => {
|
||||
expect(body).toEqual(buildEndpoints(TEST_DOMAIN))
|
||||
})
|
||||
|
||||
test('a subdomain override redirects that service only', () => {
|
||||
const endpoints = buildEndpoints(TEST_DOMAIN, '{"moderation":"api"}')
|
||||
expect(endpoints.Moderation).toBe(`https://api.${TEST_DOMAIN}`)
|
||||
expect(endpoints.API).toBe(`https://api.${TEST_DOMAIN}`)
|
||||
expect(endpoints.Accounts).toBe(`https://accounts.${TEST_DOMAIN}`)
|
||||
})
|
||||
|
||||
test('a malformed override object is ignored', () => {
|
||||
for (const bad of ['', '{', 'null', '[]', '{"moderation":42}', '{"moderation":""}']) {
|
||||
expect(buildEndpoints(TEST_DOMAIN, bad)).toEqual(buildEndpoints(TEST_DOMAIN))
|
||||
}
|
||||
})
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
||||
"DOMAIN": "rec.example.com", // base domain; overridden during deployment
|
||||
"SUBDOMAINS": "{}" // per-service subdomain overrides; overridden during deployment
|
||||
"DOMAIN": "rec.example.com" // base domain; overridden during deployment
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Per-subroom permission overrides. `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions`
|
||||
-- is how a room's creator changes what a role may do in one subroom (spawn inventions,
|
||||
-- invite, use the delete-all button, …). The client addresses an entry by the
|
||||
-- (`Permission`, `Role`) pair and re-PUTs that pair to change it, so the pair is the
|
||||
-- primary key: sending it again overwrites the stored row rather than appending a second.
|
||||
--
|
||||
-- A row IS an override, so the client's `Override` flag is not a column. It's the checkbox
|
||||
-- the client draws next to each permission — `Override: true` stores the value, and
|
||||
-- `Override: false` means "fall back to the default", which deletes the row. Reads always
|
||||
-- serve `Override: true`.
|
||||
--
|
||||
-- Read on one path only — `GET /photon_access_token`, where a stored entry overwrites the
|
||||
-- matching default in the permission table the client applies when it spawns. That's why
|
||||
-- this is its own table rather than a field on the subroom's `data` blob: that blob is
|
||||
-- served to the client verbatim inside the room, and nothing client-facing reads these.
|
||||
--
|
||||
-- `value` is the client's string kept verbatim: usually `True`/`False`, but a permission
|
||||
-- whose UI isn't a True/False picker carries something else, and we don't interpret it.
|
||||
--
|
||||
-- Generated from packages/domain/src/rooms-db.ts (SUBROOM_SCHEMA_DDL) — keep in sync.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subroom_permission (
|
||||
sub_room_id INTEGER NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
role INTEGER NOT NULL,
|
||||
type INTEGER NOT NULL DEFAULT 0,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (sub_room_id, permission, role)
|
||||
);
|
||||
@@ -64,6 +64,12 @@ export const FORBIDDEN_RESPONSE = {
|
||||
description: 'A valid token, but not the room’s creator or a co-owner (empty body)',
|
||||
}
|
||||
|
||||
/** The 403 the friends-only routes return (empty body). */
|
||||
export const NOT_FRIENDS_RESPONSE = {
|
||||
description:
|
||||
'A valid token, but the caller is not that player (nor a friend of theirs) (empty body)',
|
||||
}
|
||||
|
||||
// ---- Parameters ------------------------------------------------------------
|
||||
|
||||
/** A digits-only id path parameter (the route patterns constrain these to `[0-9]+`). */
|
||||
@@ -83,6 +89,9 @@ export const roomIdParam = idParam('roomId', 'Room id')
|
||||
/** The `:subRoomId` path parameter. */
|
||||
export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)')
|
||||
|
||||
/** The `:playerId` path parameter (an account id). */
|
||||
export const playerIdParam = idParam('playerId', 'The account whose list to read')
|
||||
|
||||
/** An optional string query parameter. */
|
||||
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
||||
@@ -123,7 +132,11 @@ export const RoomTagDto = z.object({
|
||||
Type: z.int().describe('0 = owner-set, 2 = auto'),
|
||||
})
|
||||
|
||||
/** A room's engagement counters. Nothing increments these yet, so they stay at 0. */
|
||||
/**
|
||||
* A room's engagement counters. `CheerCount`/`FavoriteCount` are aggregated from the
|
||||
* per-player `interaction` rows on every read; nothing records visits yet, so
|
||||
* `VisitorCount`/`VisitCount` stay at 0.
|
||||
*/
|
||||
export const RoomStatsDto = z.object({
|
||||
CheerCount: z.int(),
|
||||
FavoriteCount: z.int(),
|
||||
@@ -479,6 +492,35 @@ export const SubRoomAccessibilityRequest = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions` — the entries to change, keyed by
|
||||
* (`Permission`, `Role`). Only the pairs sent are touched. `Override` is the client's
|
||||
* checkbox: true stores the entry, false clears it back to the default.
|
||||
*/
|
||||
export const SubRoomPermissionsRequest = z
|
||||
.array(
|
||||
z.object({
|
||||
Permission: z
|
||||
.string()
|
||||
.describe('e.g. `CAN_SAVE_INVENTIONS`, `CAN_INVITE`, `CAN_USE_DELETE_ALL_BUTTON`'),
|
||||
Role: z.int().describe('The role tier the entry applies to (0 = everyone, 30 = co-owner)'),
|
||||
Override: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'The override checkbox, and a JSON boolean unlike `Value`: true stores this entry, ' +
|
||||
'false DELETES any stored one so the pair falls back to its default'
|
||||
),
|
||||
Type: z.int().describe('Always 0 in what the client sends; stored verbatim'),
|
||||
Value: z
|
||||
.string()
|
||||
.describe(
|
||||
'A STRING, not a boolean — usually `True` / `False`, but kept verbatim: not every ' +
|
||||
'permission’s UI is a True/False picker. Ignored when `Override` is false'
|
||||
),
|
||||
})
|
||||
)
|
||||
.describe('An array — the client sends one even when changing a single permission')
|
||||
|
||||
/**
|
||||
* `POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save` — promotes one save to live.
|
||||
* Any id from the subroom's history works, so this is both publish and restore.
|
||||
@@ -543,11 +585,13 @@ export const SubRoomSavesPage = z.object({
|
||||
|
||||
/** One entry of the permission table the client applies when it spawns into a room. */
|
||||
export const RoomPermissionDto = z.object({
|
||||
Override: z.boolean(),
|
||||
Override: z.boolean().describe('Always true on an entry that came from a subroom’s overrides'),
|
||||
Permission: z.string().describe('e.g. `CAN_USE_MAKER_PEN`, `CAN_SAVE_INVENTIONS`'),
|
||||
Role: z.int().describe('The role tier the permission applies to (0 = everyone)'),
|
||||
Type: z.int(),
|
||||
Value: z.string().describe('Always `True` — a permission is present or absent'),
|
||||
Value: z
|
||||
.string()
|
||||
.describe('A STRING, not a boolean — `True` on the defaults, anything on an override'),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -556,6 +600,11 @@ export const RoomPermissionDto = z.object({
|
||||
* `PhotonAccessToken` is deliberately empty: the reference server signs it with a
|
||||
* secret/algorithm we don't have, and our Photon setup accepts an empty token. The
|
||||
* global (Role 0) maker pen is granted only to the hardcoded dev accounts.
|
||||
*
|
||||
* `Permissions` is the default table with the overrides stored on the subroom the caller
|
||||
* is standing in merged over it (see
|
||||
* `PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions`): an override replaces the
|
||||
* default with the same (`Permission`, `Role`), and one naming a new pair is appended.
|
||||
*/
|
||||
export const PhotonAccessTokenDto = z.object({
|
||||
Permissions: z.array(RoomPermissionDto),
|
||||
|
||||
+219
-35
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
Accessibility,
|
||||
areFriends,
|
||||
canManageRoom,
|
||||
cloneRoom,
|
||||
cloneSubRoom,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
getRoomsByCreator,
|
||||
getRoomsByIds,
|
||||
getSimilarRooms,
|
||||
getSubRoomPermissions,
|
||||
getSubRoomSaves,
|
||||
getVisitedRooms,
|
||||
modifySubRoom,
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
setRoomImage,
|
||||
setRoomName,
|
||||
setRoomRole,
|
||||
setSubRoomPermissions,
|
||||
toggleCheer,
|
||||
toggleFavorite,
|
||||
toggleRoomTag,
|
||||
@@ -63,10 +66,12 @@ import {
|
||||
MissingLookupParam,
|
||||
ModifySubRoomRequest,
|
||||
NameRequest,
|
||||
NOT_FRIENDS_RESPONSE,
|
||||
PagedRooms,
|
||||
pageParams,
|
||||
PhotonAccessTokenDto,
|
||||
PlayerDataDto,
|
||||
playerIdParam,
|
||||
PublishSaveRequest,
|
||||
RestrictionsRequest,
|
||||
RoleRequest,
|
||||
@@ -81,6 +86,7 @@ import {
|
||||
stringQuery,
|
||||
SubRoomAccessibilityRequest,
|
||||
subRoomIdParam,
|
||||
SubRoomPermissionsRequest,
|
||||
SubRoomSavesPage,
|
||||
TagRequest,
|
||||
UNAUTHORIZED_EMPTY,
|
||||
@@ -90,6 +96,7 @@ import {
|
||||
} from './openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { RoomPermission } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
@@ -131,9 +138,14 @@ const DEFAULT_MAX_ROOMS_PER_ACCOUNT = 10
|
||||
* hardcoded moderator/dev accounts. */
|
||||
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
|
||||
|
||||
/** The slice of the shared presence row we read — the caller's current room instance. */
|
||||
/**
|
||||
* The slice of the shared presence row we read — the caller's current room instance.
|
||||
* `subRoomId` is what scopes the stored permission overrides: they belong to the subroom
|
||||
* the player is standing in, not to the room.
|
||||
*/
|
||||
interface PresenceView {
|
||||
roomInstanceId?: number
|
||||
subRoomId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,16 +155,26 @@ interface PresenceView {
|
||||
* they aren't in one). `PhotonAccessToken` stays empty — the reference server
|
||||
* signs it via `ClientSecurity`, whose secret/algorithm we don't have; our
|
||||
* Photon setup accepts an empty token.
|
||||
*
|
||||
* `overrides` are the permissions the room's creator saved on the subroom the caller is
|
||||
* in (see `PUT …/subrooms/{subRoomId}/permissions`). They are matched against the
|
||||
* defaults by (`Permission`, `Role`) — the same pair the client addresses an entry by —
|
||||
* and win, so a subroom that revokes the Role 0 maker pen revokes it for a dev account
|
||||
* standing in it as well.
|
||||
*/
|
||||
function photonAccessToken(accountId: number, roomInstanceId: number | null) {
|
||||
const perm = (Permission: string, Role: number, Override: boolean) => ({
|
||||
function photonAccessToken(
|
||||
accountId: number,
|
||||
roomInstanceId: number | null,
|
||||
overrides: RoomPermission[] = []
|
||||
) {
|
||||
const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({
|
||||
Override,
|
||||
Permission,
|
||||
Role,
|
||||
Type: 0,
|
||||
Value: 'True',
|
||||
})
|
||||
const permissions = [
|
||||
const permissions: RoomPermission[] = [
|
||||
perm('CAN_USE_ROOM_RESET_BUTTON', 0, true),
|
||||
perm('CAN_USE_DELETE_ALL_BUTTON', 0, true),
|
||||
perm('CAN_SAVE_INVENTIONS', 0, true),
|
||||
@@ -165,9 +187,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
|
||||
perm('CAN_SPAWN_INVENTIONS', 30, true),
|
||||
perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true),
|
||||
]
|
||||
|
||||
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
|
||||
permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true))
|
||||
}
|
||||
|
||||
// The subroom's stored table wins, applied LAST and over the dev grant too: a
|
||||
// (Permission, Role) the table already carries is replaced in place — so the order
|
||||
// doesn't shift under the client, and no pair is ever listed twice with two values —
|
||||
// and one it doesn't (e.g. CAN_INVITE) is appended.
|
||||
for (const override of overrides) {
|
||||
const i = permissions.findIndex(
|
||||
(p) => p.Permission === override.Permission && p.Role === override.Role
|
||||
)
|
||||
if (i === -1) permissions.push(override)
|
||||
else permissions[i] = override
|
||||
}
|
||||
return {
|
||||
Permissions: permissions,
|
||||
PhotonAccessToken: '',
|
||||
@@ -176,16 +211,22 @@ function photonAccessToken(accountId: number, roomInstanceId: number | null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Photon access-token handler (served bare and under `/roomserver`). Auth-gated:
|
||||
* resolves the caller, reads their current room instance from the shared
|
||||
* `presence` table (see @repo/domain), and returns the permissions + token.
|
||||
* Photon access-token handler. Auth-gated: resolves the caller, reads their current
|
||||
* room instance from the shared `presence` table (see @repo/domain), and returns the
|
||||
* permissions + token.
|
||||
*/
|
||||
async function handlePhotonAccessToken(c: Context<App>) {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
const presence = await getPresence<PresenceView>(c.env.DB, accountId)
|
||||
const roomInstanceId = presence?.roomInstance?.roomInstanceId ?? null
|
||||
return c.json(photonAccessToken(accountId, roomInstanceId))
|
||||
const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
|
||||
// The permission overrides are the ones saved on the subroom the caller is standing in.
|
||||
// A player in no instance — sitting in the lobby, or an instance predating subroom
|
||||
// tracking — gets the default table untouched.
|
||||
const overrides =
|
||||
typeof instance?.subRoomId === 'number'
|
||||
? await getSubRoomPermissions(c.env.DB, instance.subRoomId)
|
||||
: []
|
||||
return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides))
|
||||
}
|
||||
|
||||
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
|
||||
@@ -214,6 +255,59 @@ function parseAccessibility(value: unknown): number | undefined {
|
||||
return named ? (named[1] as number) : undefined
|
||||
}
|
||||
|
||||
/** Parse an integer from the number or numeric string a JSON body may carry. */
|
||||
function parseInt10(value: unknown): number | undefined {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : undefined
|
||||
if (typeof value !== 'string') return undefined
|
||||
const n = Number.parseInt(value.trim(), 10)
|
||||
return Number.isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's `Value`, kept as the STRING it sends. Usually `"True"`/`"False"` — the
|
||||
* True/False picker beside the override checkbox — but a permission whose UI is something
|
||||
* else carries a different value, so nothing here interprets it. A JSON boolean or number
|
||||
* is rendered the way the client would have written it.
|
||||
*/
|
||||
function permissionValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'boolean') return value ? 'True' : 'False'
|
||||
if (typeof value === 'number') return String(value)
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the subroom-permissions PUT body: a JSON ARRAY of
|
||||
* `{ Permission, Role, Override, Type, Value }` entries.
|
||||
*
|
||||
* `Override` is the client's checkbox, not data — see {@link setSubRoomPermissions}: true
|
||||
* stores `Value` for that (`Permission`, `Role`), false clears any stored entry so the
|
||||
* pair falls back to the default. It is carried through as sent.
|
||||
*
|
||||
* Entries without a permission name or a usable role are dropped rather than rejected —
|
||||
* the client ignores the response either way, so half a table applied beats none.
|
||||
*/
|
||||
function parseRoomPermissions(body: unknown): RoomPermission[] {
|
||||
if (!Array.isArray(body)) return []
|
||||
const permissions: RoomPermission[] = []
|
||||
for (const entry of body) {
|
||||
if (typeof entry !== 'object' || entry === null) continue
|
||||
const e = entry as Record<string, unknown>
|
||||
const permission = typeof e.Permission === 'string' ? e.Permission.trim() : ''
|
||||
const role = parseInt10(e.Role)
|
||||
if (permission === '' || role === undefined) continue
|
||||
permissions.push({
|
||||
Permission: permission,
|
||||
Role: role,
|
||||
// Sent as a JSON boolean, unlike `Value` — accept the string form regardless.
|
||||
Override: e.Override === true || String(e.Override).toLowerCase() === 'true',
|
||||
Type: parseInt10(e.Type) ?? 0,
|
||||
Value: permissionValue(e.Value),
|
||||
})
|
||||
}
|
||||
return permissions
|
||||
}
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
@@ -410,19 +504,27 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// "Hot" rooms feed — public, non-dorm rooms ordered by engagement, optionally
|
||||
// filtered to a single `tag` (e.g. `rro`). Paginated via skip/take (take
|
||||
// defaults to 100). Returns `{ Results, TotalResults }` like search.
|
||||
// "Hot" rooms feed — public, non-dorm rooms ordered by live player count (their
|
||||
// instances' presence), then stored engagement, optionally filtered to a single
|
||||
// `tag` (e.g. `rro`). `tag=new` is a pseudo-tag no room carries: it serves the
|
||||
// player-made (non-RRO) rooms newest-first. Paginated via skip/take (take defaults
|
||||
// to 100). Returns `{ Results, TotalResults }` like search.
|
||||
.get(
|
||||
'/rooms/hot',
|
||||
describeRoute({
|
||||
tags: ['Discovery'],
|
||||
summary: 'The “hot” rooms feed',
|
||||
description: [
|
||||
'Public, non-dorm rooms ordered by engagement, optionally narrowed to a single `tag`',
|
||||
'(the browse screen’s filter chips post one, e.g. `rro`).',
|
||||
'Public, non-dorm rooms ordered by how many players are in them right now — live',
|
||||
'presence summed across each room’s instances — falling back to stored engagement',
|
||||
'for rooms nobody is in. Optionally narrowed to a single `tag` (the browse screen’s',
|
||||
'filter chips post one, e.g. `rro`). The `new` chip is a pseudo-tag — no room carries',
|
||||
'a `new` tag — and instead serves the player-made (non-RRO) rooms, newest first.',
|
||||
].join(' '),
|
||||
parameters: [stringQuery('tag', 'Restrict to rooms carrying this tag'), ...pageParams(100)],
|
||||
parameters: [
|
||||
stringQuery('tag', 'Restrict to rooms carrying this tag (or `new`, a pseudo-tag)'),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(PagedRooms, 'The feed page') },
|
||||
}),
|
||||
async (c) => {
|
||||
@@ -672,6 +774,45 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Another player's visited rooms — what the client shows on a friend's profile.
|
||||
// Auth-gated (401), and FRIENDS-ONLY: a valid token for someone who isn't that
|
||||
// player and isn't a mutual friend of theirs is a 403, since where a player has
|
||||
// been is not public. Registered after `visitedby/me` so the literal path wins.
|
||||
// Paginated via skip/take (take defaults to 100) and, like `visitedby/me`, a bare
|
||||
// array — the client's room-source loaders expect a plain list, not a page.
|
||||
.get(
|
||||
'/rooms/visitedby/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'A friend’s visited rooms',
|
||||
description: [
|
||||
'The rooms another player has visited, as a bare array. Friends only: the caller must',
|
||||
'be that player or a mutual friend of theirs (403 otherwise) — visit history is not',
|
||||
'public.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [playerIdParam, ...pageParams(100)],
|
||||
responses: {
|
||||
200: json(RoomDto.array(), 'That player’s visited rooms'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: NOT_FRIENDS_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
// Your own history is always readable (the client sometimes sends the id
|
||||
// rather than `me`); anyone else's needs a mutual friendship.
|
||||
if (playerId !== accountId && !(await areFriends(c.env.DB, accountId, playerId))) {
|
||||
return c.body(null, 403)
|
||||
}
|
||||
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, playerId, skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// The current player's interaction state with a room (cheered/favorited/last
|
||||
// visited), read from the `interaction` table. Auth-gated.
|
||||
.get(
|
||||
@@ -1820,6 +1961,67 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Set a subroom's permission overrides — what each role may do in that subroom. The
|
||||
// body is a JSON ARRAY of the entries to change, keyed by (Permission, Role): `Override`
|
||||
// is the client's checkbox, so true stores the entry for that pair and false clears it
|
||||
// back to the default. The stored table then overwrites the matching defaults in
|
||||
// `GET /photon_access_token`. Auth-gated (401) and creator-only (403), like the other
|
||||
// subroom mutations. Answers an EMPTY 200 — the client fires this and re-reads nothing,
|
||||
// so there is no envelope to match.
|
||||
.put(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/permissions',
|
||||
describeRoute({
|
||||
tags: ['Subrooms'],
|
||||
summary: 'Set a subroom’s permissions',
|
||||
description: [
|
||||
'Stores the permission entries a room’s creator changed for one subroom — who may',
|
||||
'save inventions, invite players, use the delete-all button, and so on. The body is a',
|
||||
'JSON ARRAY; each entry is addressed by its (`Permission`, `Role`) pair, so re-sending',
|
||||
'a pair overwrites the stored entry rather than adding a second, and pairs that were',
|
||||
'never sent are left alone.',
|
||||
'',
|
||||
'`Override` is the checkbox the client draws beside each permission, not data:',
|
||||
'`true` stores `Value` for that pair, and `false` means “fall back to the default”, so',
|
||||
'it DELETES any stored entry. Nothing is stored with `Override: false`, and reads',
|
||||
'always serve `true`. `Value` is a string — usually `True`/`False`, but it is kept',
|
||||
'verbatim, since not every permission’s UI is a True/False picker.',
|
||||
'',
|
||||
'What this feeds is `GET /photon_access_token`: a stored entry replaces the default',
|
||||
'with the same (`Permission`, `Role`) in the table the client applies when it spawns,',
|
||||
'and one naming a pair the defaults don’t carry (e.g. `CAN_INVITE`) is added to it.',
|
||||
'The overrides apply to the subroom the caller is standing in, resolved from presence.',
|
||||
'',
|
||||
'Creator-only — co-owners may build in a room but not decide what a role may do.',
|
||||
'The response body is EMPTY: the client doesn’t read one.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam, subRoomIdParam],
|
||||
requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'),
|
||||
responses: {
|
||||
200: { description: 'Stored (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: { description: 'No such room or subroom' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
|
||||
// Scoped through the room so a subroom id from another room can't be written.
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
|
||||
if (room.CreatorAccountId !== accountId) return c.body(null, 403)
|
||||
|
||||
const permissions = parseRoomPermissions(await c.req.json().catch(() => null))
|
||||
await setSubRoomPermissions(c.env.DB, subRoomId, permissions)
|
||||
return c.body(null, 200)
|
||||
}
|
||||
)
|
||||
|
||||
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
|
||||
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
|
||||
// returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new
|
||||
@@ -2041,8 +2243,7 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Photon access token + room permissions the client needs to spawn into a
|
||||
// room. The client calls it on the rooms host both bare and under `/roomserver`.
|
||||
// Photon access token + room permissions the client needs to spawn into a room.
|
||||
.get(
|
||||
'/photon_access_token',
|
||||
describeRoute({
|
||||
@@ -2065,23 +2266,6 @@ const app = new Hono<App>()
|
||||
}),
|
||||
handlePhotonAccessToken
|
||||
)
|
||||
.get(
|
||||
'/roomserver/photon_access_token',
|
||||
describeRoute({
|
||||
tags: ['Session'],
|
||||
summary: 'Photon token + room permissions (legacy path)',
|
||||
description: [
|
||||
'Identical to `GET /photon_access_token` — the client calls it both bare and under the',
|
||||
'`/roomserver` prefix, so both forms are registered.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
handlePhotonAccessToken
|
||||
)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
|
||||
@@ -58,6 +58,25 @@ beforeAll(async () => {
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
|
||||
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||
|
||||
// Relationship table (owned by the api worker) — `visitedby/:playerId` reads it to
|
||||
// check the caller is a friend of the player whose history they're asking for.
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS relationship (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
requester_id INTEGER NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
relationship_type INTEGER NOT NULL DEFAULT 0
|
||||
)`
|
||||
).run()
|
||||
const insertRel = env.DB.prepare(
|
||||
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
|
||||
)
|
||||
await env.DB.batch([
|
||||
insertRel.bind(791, 790, 3), // friends — the caller (791) is the requester
|
||||
insertRel.bind(790, 792, 3), // friends — the caller (792) is the target
|
||||
insertRel.bind(793, 790, 1), // request out, not accepted — 793 is NOT a friend
|
||||
])
|
||||
})
|
||||
|
||||
describe('rooms endpoints', () => {
|
||||
@@ -260,6 +279,62 @@ describe('rooms endpoints', () => {
|
||||
expect(other).toEqual([])
|
||||
})
|
||||
|
||||
it('GET /rooms/visitedby/:playerId serves a friend’s visited rooms and 403s everyone else', async () => {
|
||||
// Give 790 a visit history (cheering/favoriting stamps a last-visit).
|
||||
const subject = await bearer('790')
|
||||
await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, {
|
||||
method: 'PUT',
|
||||
headers: subject,
|
||||
})
|
||||
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, {
|
||||
method: 'PUT',
|
||||
headers: subject,
|
||||
})
|
||||
|
||||
// A mutual friend reads it — a bare array, regardless of which side of the
|
||||
// relationship row the caller sits on.
|
||||
for (const friend of ['791', '792']) {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, {
|
||||
headers: await bearer(friend),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Array<{ RoomId: number }>
|
||||
expect(body.map((r) => r.RoomId).sort((a, b) => a - b)).toEqual([2, 12])
|
||||
}
|
||||
|
||||
// Paginated via skip/take.
|
||||
const page = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/visitedby/790?skip=0&take=1`, {
|
||||
headers: await bearer('791'),
|
||||
})
|
||||
).json()) as unknown[]
|
||||
expect(page.length).toBe(1)
|
||||
|
||||
// Your own history is readable by id, not just via `me`.
|
||||
const own = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, { headers: subject })
|
||||
).json()) as unknown[]
|
||||
expect(own.length).toBe(2)
|
||||
|
||||
// A pending request is not a friendship, and a stranger is not either → 403.
|
||||
for (const outsider of ['793', '794']) {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`, {
|
||||
headers: await bearer(outsider),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
}
|
||||
|
||||
// No token at all → 401, never a fallback account.
|
||||
const anon = await SELF.fetch(`${ORIGIN}/rooms/visitedby/790`)
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
// `visitedby/me` still routes to the literal handler, not the id pattern.
|
||||
const me = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers: subject })
|
||||
).json()) as unknown[]
|
||||
expect(me.length).toBe(2)
|
||||
})
|
||||
|
||||
it('GET /rooms/hot returns a paginated { Results, TotalResults } of public rooms', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -293,6 +368,49 @@ describe('rooms endpoints', () => {
|
||||
expect(body.TotalResults).toBeGreaterThan(body.Results.length)
|
||||
})
|
||||
|
||||
it('GET /rooms/hot ranks rooms by the live presence in their instances', async () => {
|
||||
const feed = async (): Promise<number[]> =>
|
||||
(
|
||||
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)).json()) as {
|
||||
Results: Array<{ RoomId: number }>
|
||||
}
|
||||
).Results.map((r) => r.RoomId)
|
||||
|
||||
// Two rooms from the tail of the engagement-ordered feed, so any move to the
|
||||
// front can only come from presence.
|
||||
const before = await feed()
|
||||
const busiest = before[before.length - 1]
|
||||
const quieter = before[before.length - 2]
|
||||
|
||||
// Two players in two different instances of `busiest`, one in `quieter`, plus a
|
||||
// lobby presence (no instance) that must not count for anyone.
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 900
|
||||
const seed = env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
await env.DB.batch(
|
||||
[
|
||||
{ accountId: 90001, roomInstance: { roomInstanceId: 1000901, roomId: busiest } },
|
||||
{ accountId: 90002, roomInstance: { roomInstanceId: 1000902, roomId: busiest } },
|
||||
{ accountId: 90003, roomInstance: { roomInstanceId: 1000903, roomId: quieter } },
|
||||
{ accountId: 90004, roomInstance: null },
|
||||
].map((p) => seed.bind(JSON.stringify({ ...p, expiresAt })))
|
||||
)
|
||||
|
||||
expect((await feed()).slice(0, 2)).toEqual([busiest, quieter])
|
||||
|
||||
// Expired presence doesn't count — the feed falls back to engagement order.
|
||||
await env.DB.prepare(
|
||||
`UPDATE presence SET data = json_set(data, '$.expiresAt', ?1)
|
||||
WHERE account_id IN (90001, 90002, 90003, 90004)`
|
||||
)
|
||||
.bind(Math.floor(Date.now() / 1000) - 1)
|
||||
.run()
|
||||
expect(await feed()).toEqual(before)
|
||||
|
||||
await env.DB.prepare(
|
||||
'DELETE FROM presence WHERE account_id IN (90001, 90002, 90003, 90004)'
|
||||
).run()
|
||||
})
|
||||
|
||||
it('GET /rooms/hot aliases #recroomoriginal to the rro tag', async () => {
|
||||
const aliased = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=recroomoriginal`)
|
||||
@@ -304,6 +422,66 @@ describe('rooms endpoints', () => {
|
||||
expect(aliased.TotalResults).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('GET /rooms/hot?tag=new serves player-made rooms newest-first (pseudo-tag)', async () => {
|
||||
type Feed = { Results: Array<{ Name: string }>; TotalResults: number }
|
||||
const feed = async (): Promise<Feed> =>
|
||||
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=new&skip=0&take=100`)).json()) as Feed
|
||||
const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name)
|
||||
|
||||
// No room carries a `new` tag, and every seeded room is a Rec Room Original — so
|
||||
// the feed is empty until a player makes something.
|
||||
expect(await feed()).toEqual({ Results: [], TotalResults: 0 })
|
||||
|
||||
const seeded: number[] = []
|
||||
const seed = async (room: Record<string, unknown>) => {
|
||||
seeded.push(Number(room.RoomId))
|
||||
await seedRoomWithSubRooms(env.DB, { Accessibility: 1, IsDorm: false, IsRRO: false, ...room })
|
||||
}
|
||||
|
||||
// Two player-made public rooms and one that isn't public.
|
||||
await seed({ RoomId: 9001, Name: 'OlderPlayerRoom', CreatedAt: '2026-07-01T00:00:00Z' })
|
||||
await seed({ RoomId: 9002, Name: 'NewerPlayerRoom', CreatedAt: '2026-07-02T00:00:00Z' })
|
||||
await seed({
|
||||
RoomId: 9003,
|
||||
Name: 'UnlistedPlayerRoom',
|
||||
CreatedAt: '2026-07-03T00:00:00Z',
|
||||
Accessibility: 2,
|
||||
})
|
||||
|
||||
// Newest first, and the non-public room is excluded as it is everywhere else.
|
||||
expect(await feed()).toMatchObject({
|
||||
Results: [{ Name: 'NewerPlayerRoom' }, { Name: 'OlderPlayerRoom' }],
|
||||
TotalResults: 2,
|
||||
})
|
||||
|
||||
// An RRO stays out even when it's the newest room in the database — by the flag,
|
||||
// or by the auto-derived `rro` tag alone.
|
||||
await seed({
|
||||
RoomId: 9004,
|
||||
Name: 'BrandNewRRO',
|
||||
CreatedAt: '2026-07-04T00:00:00Z',
|
||||
IsRRO: true,
|
||||
})
|
||||
await seed({
|
||||
RoomId: 9005,
|
||||
Name: 'TaggedRRO',
|
||||
CreatedAt: '2026-07-05T00:00:00Z',
|
||||
Tags: [{ Tag: 'rro', Type: 2 }],
|
||||
})
|
||||
expect(await names()).toEqual(['NewerPlayerRoom', 'OlderPlayerRoom'])
|
||||
|
||||
// Paging comes off the same order.
|
||||
const page = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=new&skip=1&take=1`)
|
||||
).json()) as Feed
|
||||
expect(page).toMatchObject({ Results: [{ Name: 'OlderPlayerRoom' }], TotalResults: 2 })
|
||||
|
||||
// Leave the shared feeds as they were for the tests that follow.
|
||||
const ids = seeded.join(',')
|
||||
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids})`).run()
|
||||
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
|
||||
})
|
||||
|
||||
it('GET /rooms/base returns a bare array of base/template rooms (incl. non-public)', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/base`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -1433,13 +1611,10 @@ describe('rooms endpoints', () => {
|
||||
})
|
||||
|
||||
it('GET /photon_access_token 401s without a token', async () => {
|
||||
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
|
||||
const res = await SELF.fetch(`${ORIGIN}${path}`)
|
||||
expect(res.status).toBe(401)
|
||||
}
|
||||
expect((await SELF.fetch(`${ORIGIN}/photon_access_token`)).status).toBe(401)
|
||||
})
|
||||
|
||||
it('GET /photon_access_token (bare + /roomserver) returns permissions + presence instance', async () => {
|
||||
it('GET /photon_access_token returns permissions + presence instance', async () => {
|
||||
// Seed the caller's presence so RoomInstanceId reflects their current instance.
|
||||
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
@@ -1450,22 +1625,19 @@ describe('rooms endpoints', () => {
|
||||
})
|
||||
)
|
||||
.run()
|
||||
const headers = await bearer('777')
|
||||
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
|
||||
const res = await SELF.fetch(`${ORIGIN}${path}`, { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Permissions: Array<{ Permission: string; Role: number }>
|
||||
PhotonAccessToken: string
|
||||
RoomInstanceId: number | null
|
||||
}
|
||||
expect(body.Permissions.length).toBe(11)
|
||||
expect(body.RoomInstanceId).toBe(1000042)
|
||||
// A non-dev account does NOT get the global (Role 0) maker pen.
|
||||
expect(
|
||||
body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)
|
||||
).toBe(false)
|
||||
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('777') })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Permissions: Array<{ Permission: string; Role: number }>
|
||||
PhotonAccessToken: string
|
||||
RoomInstanceId: number | null
|
||||
}
|
||||
expect(body.Permissions.length).toBe(11)
|
||||
expect(body.RoomInstanceId).toBe(1000042)
|
||||
// A non-dev account does NOT get the global (Role 0) maker pen.
|
||||
expect(body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('GET /photon_access_token returns null RoomInstanceId when the caller has no presence', async () => {
|
||||
@@ -1526,6 +1698,55 @@ describe('rooms endpoints', () => {
|
||||
expect(otherGet).toMatchObject({ Cheered: false, Favorited: false })
|
||||
})
|
||||
|
||||
it('room Stats aggregate cheers/favorites from the interaction table', async () => {
|
||||
type Stats = {
|
||||
CheerCount: number
|
||||
FavoriteCount: number
|
||||
VisitorCount: number
|
||||
VisitCount: number
|
||||
}
|
||||
// Room 15 (CrimsonCauldron) is untouched by the other interaction tests.
|
||||
const searched = async (): Promise<Stats> => {
|
||||
const body = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/search?query=crimsoncauldron`)
|
||||
).json()) as { Results: Array<{ Stats: Stats }> }
|
||||
return body.Results[0]!.Stats
|
||||
}
|
||||
const direct = async (): Promise<Stats> =>
|
||||
((await (await SELF.fetch(`${ORIGIN}/rooms/15`)).json()) as { Stats: Stats }).Stats
|
||||
const interact = async (player: string, action: string, method: string) =>
|
||||
SELF.fetch(`${ORIGIN}/rooms/15/interactionby/me/${action}`, {
|
||||
method,
|
||||
headers: await bearer(player),
|
||||
})
|
||||
|
||||
// Nobody has interacted with it yet.
|
||||
expect(await searched()).toEqual({
|
||||
CheerCount: 0,
|
||||
FavoriteCount: 0,
|
||||
VisitorCount: 0,
|
||||
VisitCount: 0,
|
||||
})
|
||||
|
||||
// Two players cheer it; one of them also favorites it.
|
||||
await interact('561', 'cheer', 'PUT')
|
||||
await interact('562', 'cheer', 'PUT')
|
||||
await interact('561', 'favorite', 'PUT')
|
||||
|
||||
// Both the search results and the room itself report the aggregate.
|
||||
expect(await searched()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
|
||||
expect(await direct()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
|
||||
|
||||
// Clearing a cheer decrements it. Nothing records visits, so those stay 0.
|
||||
await interact('562', 'cheer', 'DELETE')
|
||||
expect(await direct()).toEqual({
|
||||
CheerCount: 1,
|
||||
FavoriteCount: 1,
|
||||
VisitorCount: 0,
|
||||
VisitCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('DELETE /rooms/:id/interactionby/me/cheer clears the cheer (auth-gated, idempotent)', async () => {
|
||||
type Interaction = { Cheered: boolean; Favorited: boolean }
|
||||
const headers = await bearer('557')
|
||||
@@ -1676,6 +1897,254 @@ describe('rooms endpoints', () => {
|
||||
expect(await accessibilityOf()).toBe(1)
|
||||
})
|
||||
|
||||
// The permission table a room's creator saves on a subroom, and how it reaches the
|
||||
// client: `PUT …/permissions` stores entries keyed by (Permission, Role), and
|
||||
// `GET /photon_access_token` merges them over its defaults for whoever is standing in
|
||||
// that subroom. Room 2 / subroom 2 is owned by account 1; account 743 is the visitor
|
||||
// whose presence points at it.
|
||||
describe('subroom permissions', () => {
|
||||
type Permission = { Permission: string; Role: number; Override: boolean; Value: string }
|
||||
|
||||
const putPermissions = async (path: string, body: unknown, sub?: string) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
// Put a player in an instance of the given subroom, then read the permission table
|
||||
// the client would apply when it spawns there.
|
||||
const permissionsIn = async (accountId: number, subRoomId: number): Promise<Permission[]> => {
|
||||
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
roomInstance: { roomInstanceId: 1000900 + subRoomId, roomId: 2, subRoomId },
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 900,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, {
|
||||
headers: await bearer(String(accountId)),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as { Permissions: Permission[] }).Permissions
|
||||
}
|
||||
|
||||
const entry = (list: Permission[], permission: string, role: number) =>
|
||||
list.find((p) => p.Permission === permission && p.Role === role)
|
||||
|
||||
it('is auth-gated and creator-only', async () => {
|
||||
const body = [
|
||||
{ Permission: 'CAN_SAVE_INVENTIONS', Role: 30, Override: false, Type: 0, Value: 'True' },
|
||||
]
|
||||
// No token → 401.
|
||||
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body)).status).toBe(401)
|
||||
// A valid token that isn't the room's creator → 403.
|
||||
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '999')).status).toBe(
|
||||
403
|
||||
)
|
||||
// Not even a co-owner: account 2 holds Role 30 on the seeded rooms. Co-owners may
|
||||
// build in a room but don't decide what a role may do.
|
||||
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '2')).status).toBe(403)
|
||||
// Unknown room / unknown subroom → 404.
|
||||
expect((await putPermissions('/rooms/99999/subrooms/2/permissions', body, '1')).status).toBe(
|
||||
404
|
||||
)
|
||||
// A subroom id belonging to another room doesn't resolve either.
|
||||
expect((await putPermissions('/rooms/2/subrooms/9999/permissions', body, '1')).status).toBe(
|
||||
404
|
||||
)
|
||||
})
|
||||
|
||||
it('answers an empty 200 — the client reads no body', async () => {
|
||||
const res = await putPermissions(
|
||||
'/rooms/2/subrooms/2/permissions',
|
||||
[{ Permission: 'CAN_SPAWN_INVENTIONS', Role: 30, Override: true, Type: 0, Value: 'True' }],
|
||||
'1'
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('')
|
||||
})
|
||||
|
||||
it('a checked Override replaces the matching default in place', async () => {
|
||||
const before = await permissionsIn(743, 2)
|
||||
expect(before.length).toBe(11)
|
||||
const at = before.findIndex((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 30)
|
||||
// The default for this pair is an un-overridden grant.
|
||||
expect(before[at]).toMatchObject({ Override: false, Value: 'True' })
|
||||
|
||||
expect(
|
||||
(
|
||||
await putPermissions(
|
||||
'/rooms/2/subrooms/2/permissions',
|
||||
[
|
||||
{
|
||||
Permission: 'CAN_USE_MAKER_PEN',
|
||||
Role: 30,
|
||||
Override: true,
|
||||
Type: 0,
|
||||
Value: 'False',
|
||||
},
|
||||
],
|
||||
'1'
|
||||
)
|
||||
).status
|
||||
).toBe(200)
|
||||
|
||||
const after = await permissionsIn(743, 2)
|
||||
// Replaced, not appended — and at the same index, so the table doesn't reshuffle.
|
||||
expect(after.length).toBe(11)
|
||||
expect(after[at]).toMatchObject({
|
||||
Permission: 'CAN_USE_MAKER_PEN',
|
||||
Role: 30,
|
||||
Override: true,
|
||||
Value: 'False',
|
||||
})
|
||||
|
||||
// Re-sending the same (Permission, Role) updates that entry rather than adding one.
|
||||
await putPermissions(
|
||||
'/rooms/2/subrooms/2/permissions',
|
||||
[{ Permission: 'CAN_USE_MAKER_PEN', Role: 30, Override: true, Type: 0, Value: 'True' }],
|
||||
'1'
|
||||
)
|
||||
const changed = await permissionsIn(743, 2)
|
||||
expect(changed.length).toBe(11)
|
||||
expect(changed[at]).toMatchObject({ Override: true, Value: 'True' })
|
||||
})
|
||||
|
||||
it('an unchecked Override erases the entry, back to the default', async () => {
|
||||
const stored = async () =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM subroom_permission
|
||||
WHERE sub_room_id = 2 AND permission = 'CAN_USE_MAKER_PEN' AND role = 30`
|
||||
).first<{ n: number }>())!.n
|
||||
|
||||
// The previous test left this pair overridden.
|
||||
expect(await stored()).toBe(1)
|
||||
|
||||
// `Override: false` means "fall back to the default" — the `Value` riding along is
|
||||
// not stored, it's whatever the picker happened to show.
|
||||
expect(
|
||||
(
|
||||
await putPermissions(
|
||||
'/rooms/2/subrooms/2/permissions',
|
||||
[
|
||||
{
|
||||
Permission: 'CAN_USE_MAKER_PEN',
|
||||
Role: 30,
|
||||
Override: false,
|
||||
Type: 0,
|
||||
Value: 'True',
|
||||
},
|
||||
],
|
||||
'1'
|
||||
)
|
||||
).status
|
||||
).toBe(200)
|
||||
|
||||
// The row is gone, and the token serves the default for the pair again.
|
||||
expect(await stored()).toBe(0)
|
||||
const table = await permissionsIn(743, 2)
|
||||
expect(table.length).toBe(11)
|
||||
expect(entry(table, 'CAN_USE_MAKER_PEN', 30)).toMatchObject({
|
||||
Override: false,
|
||||
Value: 'True',
|
||||
})
|
||||
|
||||
// Clearing a pair that was never overridden is a no-op, not an insert.
|
||||
await putPermissions(
|
||||
'/rooms/2/subrooms/2/permissions',
|
||||
[{ Permission: 'CAN_INVITE', Role: 0, Override: false, Type: 0, Value: 'True' }],
|
||||
'1'
|
||||
)
|
||||
expect((await permissionsIn(743, 2)).length).toBe(11)
|
||||
})
|
||||
|
||||
it('appends a permission the defaults do not carry, and scopes it to its subroom', async () => {
|
||||
// CAN_INVITE is in none of the defaults, so it lands as a new entry.
|
||||
await putPermissions(
|
||||
'/rooms/2/subrooms/2/permissions',
|
||||
[{ Permission: 'CAN_INVITE', Role: 30, Override: true, Type: 0, Value: 'False' }],
|
||||
'1'
|
||||
)
|
||||
const inSubRoom2 = await permissionsIn(744, 2)
|
||||
expect(inSubRoom2.length).toBe(12)
|
||||
expect(entry(inSubRoom2, 'CAN_INVITE', 30)).toMatchObject({
|
||||
Override: true,
|
||||
Value: 'False',
|
||||
})
|
||||
|
||||
// A different subroom is untouched — the table is per-subroom, not per-room.
|
||||
expect((await permissionsIn(744, 3)).length).toBe(11)
|
||||
// And so is a player in no instance at all.
|
||||
await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(744).run()
|
||||
const lobby = await SELF.fetch(`${ORIGIN}/photon_access_token`, {
|
||||
headers: await bearer('744'),
|
||||
})
|
||||
expect(((await lobby.json()) as { Permissions: Permission[] }).Permissions.length).toBe(11)
|
||||
})
|
||||
|
||||
it('keeps a Value that isn’t True/False verbatim', async () => {
|
||||
// Not every permission's UI is the True/False picker, so nothing interprets the
|
||||
// string — it goes to the client exactly as the creator set it.
|
||||
await putPermissions(
|
||||
'/rooms/2/subrooms/2/permissions',
|
||||
[{ Permission: 'MAX_SPAWNED_INVENTIONS', Role: 0, Override: true, Type: 0, Value: '25' }],
|
||||
'1'
|
||||
)
|
||||
expect(entry(await permissionsIn(747, 2), 'MAX_SPAWNED_INVENTIONS', 0)).toMatchObject({
|
||||
Override: true,
|
||||
Value: '25',
|
||||
})
|
||||
})
|
||||
|
||||
it('applies over the dev accounts’ global maker pen, without listing a pair twice', async () => {
|
||||
await putPermissions(
|
||||
'/rooms/2/subrooms/2/permissions',
|
||||
[
|
||||
{ Permission: 'CAN_USE_MAKER_PEN', Role: 0, Override: true, Type: 0, Value: 'False' },
|
||||
// The third sample body — a Role 0 grant the defaults already carry.
|
||||
{
|
||||
Permission: 'CAN_USE_DELETE_ALL_BUTTON',
|
||||
Role: 0,
|
||||
Override: true,
|
||||
Type: 0,
|
||||
Value: 'True',
|
||||
},
|
||||
],
|
||||
'1'
|
||||
)
|
||||
// Account 3 is one of the hardcoded dev accounts, so it gets the global (Role 0)
|
||||
// maker pen prepended — which this subroom then revokes. The merge runs last and
|
||||
// replaces it in place, so the pair appears exactly ONCE: a table listing it twice
|
||||
// with two values would leave which one applies up to the client.
|
||||
const devTable = await permissionsIn(3, 2)
|
||||
expect(devTable.filter((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)).toEqual([
|
||||
{ Override: true, Permission: 'CAN_USE_MAKER_PEN', Role: 0, Type: 0, Value: 'False' },
|
||||
])
|
||||
expect(entry(devTable, 'CAN_USE_DELETE_ALL_BUTTON', 0)).toMatchObject({ Value: 'True' })
|
||||
|
||||
// A normal player in the same subroom sees the same revocation.
|
||||
expect(entry(await permissionsIn(745, 2), 'CAN_USE_MAKER_PEN', 0)).toMatchObject({
|
||||
Value: 'False',
|
||||
})
|
||||
})
|
||||
|
||||
it('a cloned subroom inherits the source’s permission table', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/clone`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('1'),
|
||||
})
|
||||
const room = (await res.json()) as { value: { SubRooms: Array<{ SubRoomId: number }> } }
|
||||
const cloneId = Math.max(...room.value.SubRooms.map((s) => s.SubRoomId))
|
||||
|
||||
const inClone = await permissionsIn(746, cloneId)
|
||||
expect(entry(inClone, 'CAN_INVITE', 30)).toMatchObject({ Value: 'False' })
|
||||
expect(entry(inClone, 'CAN_USE_MAKER_PEN', 0)).toMatchObject({ Value: 'False' })
|
||||
})
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => {
|
||||
const clone = async (roomId: number, subRoomId: number, sub?: string) =>
|
||||
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
|
||||
@@ -1939,12 +2408,12 @@ describe('rooms endpoints', () => {
|
||||
'GET /rooms/recommendations',
|
||||
'GET /rooms/search',
|
||||
'GET /rooms/visitedby/me',
|
||||
'GET /rooms/visitedby/{playerId}',
|
||||
'GET /rooms/{roomId}',
|
||||
'GET /rooms/{roomId}/interactionby/me',
|
||||
'GET /rooms/{roomId}/playerdata/me',
|
||||
'GET /rooms/{roomId}/similar',
|
||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
|
||||
'GET /roomserver/photon_access_token',
|
||||
'GET /roomserver/rooms/createdby/me',
|
||||
'POST /rooms/{roomId}/clone',
|
||||
'POST /rooms/{roomId}/subrooms',
|
||||
@@ -1963,6 +2432,7 @@ describe('rooms endpoints', () => {
|
||||
'PUT /rooms/{roomId}/roles/{accountId}',
|
||||
'PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility',
|
||||
'PUT /rooms/{roomId}/subrooms/{subRoomId}/modify',
|
||||
'PUT /rooms/{roomId}/subrooms/{subRoomId}/permissions',
|
||||
'PUT /rooms/{roomId}/tags',
|
||||
'PUT /rooms/{roomId}/warning',
|
||||
])
|
||||
|
||||
@@ -150,8 +150,14 @@ const app = new Hono<App>()
|
||||
// does the extension, which is why it goes on the key, not just the name.
|
||||
const datePrefix = new Date().toISOString().slice(0, 10)
|
||||
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}`
|
||||
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, await file.arrayBuffer(), {
|
||||
const bytes = await file.arrayBuffer()
|
||||
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, bytes, {
|
||||
httpMetadata: { contentType: file.type || 'application/octet-stream' },
|
||||
// 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
|
||||
// only a checksum given at put time is readable later — this lets the `api`
|
||||
// worker answer one from a HEAD instead of downloading the blob to digest it.
|
||||
sha256: await crypto.subtle.digest('SHA-256', bytes),
|
||||
})
|
||||
return c.json({ filename })
|
||||
}
|
||||
|
||||
+47
-1
@@ -25,8 +25,9 @@ Upstream hosts are derived from the shared base domain (`auth.<DOMAIN>`,
|
||||
|
||||
| Method | Path | Upstream |
|
||||
| ------ | --------------- | -------------------------------------------------------- |
|
||||
| GET | `/api/config` | none — whether signup is open, plus the Turnstile key |
|
||||
| POST | `/api/signup` | auth `POST /connect/token` (`grant_type=create_account`) |
|
||||
| POST | `/api/login` | auth `POST /connect/token` (account id + password) |
|
||||
| POST | `/api/login` | auth `POST /connect/token` (username + password) |
|
||||
| POST | `/api/logout` | clears the session cookie |
|
||||
| GET | `/api/me` | accounts `GET /account/me` |
|
||||
| POST | `/api/email` | accounts `POST /account/me/email` |
|
||||
@@ -35,6 +36,51 @@ Upstream hosts are derived from the shared base domain (`auth.<DOMAIN>`,
|
||||
On signup/login the access token returned by `auth` is stored in an httpOnly
|
||||
`rf_token` cookie; the other routes read it and forward it as a Bearer token.
|
||||
|
||||
`/api/signup` also takes an optional `email`, saved with a second call to accounts
|
||||
`POST /account/me/email` once the session exists — `create_account` has no email
|
||||
field, the accounts worker owns it. The address is format-checked before the
|
||||
account is created, since a rejection afterwards would leave a player with an
|
||||
account whose email silently didn't save; a failure of the save itself is logged
|
||||
and does not fail the signup, because by then the account is real and a retry
|
||||
would spend another slot against auth's per-IP cap.
|
||||
|
||||
### Signup and Turnstile
|
||||
|
||||
`POST /api/signup` creates an account with no platform identity (a password
|
||||
account), so it's the one BFF route a bot could farm — `auth` binds no Steam id to
|
||||
it and only its coarse per-IP cap applies. It therefore runs behind a
|
||||
[Turnstile](https://developers.cloudflare.com/turnstile/) check: the browser posts
|
||||
the widget's token, and the worker verifies it against Turnstile's `siteverify`
|
||||
server-side before calling `auth`. The secret key never leaves the worker, and the
|
||||
browser never talks to `siteverify` itself.
|
||||
|
||||
Two Secrets Store secrets configure it, `TURNSTILE_SITE_KEY` and
|
||||
`TURNSTILE_SECRET_KEY`, bound from the same account-level store every worker uses
|
||||
for `JWT_SECRET` (see `wrangler.jsonc` and `src/turnstile.ts`) — the site key is
|
||||
public, but keeping it with its secret makes the pair the single switch. Creating
|
||||
both is what opens signup; if either fails to resolve, `/api/config` reports
|
||||
`signupEnabled: false` (so the SPA shows sign-in only) and `/api/signup` returns
|
||||
403, so an unconfigured worker serves no signup rather than an unprotected one.
|
||||
A store read that throws is treated the same as a missing key — `/api/config` is on
|
||||
the homepage's critical path and must not 500 when signup isn't set up.
|
||||
|
||||
Because `.get()` caches per isolate, changing either value in the store needs a
|
||||
`www` redeploy before a warm worker picks it up.
|
||||
|
||||
For local dev, seed the two names into the **local** store (miniflare's, keyed by
|
||||
the literal `local` store id — it is per-directory, so run these in `apps/www`)
|
||||
with Turnstile's documented always-passes test keypair, which needs no widget and
|
||||
no account:
|
||||
|
||||
```sh
|
||||
printf '1x00000000000000000000AA' |
|
||||
wrangler secrets-store secret create local --name TURNSTILE_SITE_KEY --scopes workers
|
||||
printf '1x0000000000000000000000000000000AA' |
|
||||
wrangler secrets-store secret create local --name TURNSTILE_SECRET_KEY --scopes workers
|
||||
```
|
||||
|
||||
The tests seed the same pair into their own local store in `beforeAll`.
|
||||
|
||||
## Development
|
||||
|
||||
### Run in dev mode
|
||||
|
||||
+379
-57
@@ -1,6 +1,12 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { DISCORD_INVITE, DOWNLOAD_URL, LICENSE_URL, SOURCE_REPO } from '../links'
|
||||
import {
|
||||
DISCORD_INVITE,
|
||||
DOWNLOAD_URL,
|
||||
LICENSE_URL,
|
||||
QUEST_DOWNLOAD_URL,
|
||||
SOURCE_REPO,
|
||||
} from '../links'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
@@ -14,6 +20,16 @@ interface SelfAccount {
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Site config from the BFF (`/api/config`). `signupEnabled` is false when the operator
|
||||
* has no Turnstile keypair configured — web signup runs behind that bot check, so
|
||||
* without it the endpoint is closed and the UI must not offer the form.
|
||||
*/
|
||||
interface SiteConfig {
|
||||
signupEnabled: boolean
|
||||
turnstileSiteKey: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a www BFF endpoint. GET when no body is given, else POST JSON. Throws with
|
||||
* the upstream error message (auth uses `error`/`error_description`, the account
|
||||
@@ -85,12 +101,18 @@ function Link({
|
||||
export function App() {
|
||||
// undefined = still checking the session; null = signed out.
|
||||
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
|
||||
// undefined until the config lands. Signup is treated as closed until told otherwise,
|
||||
// so a slow (or failed) config fetch can't flash a form the server would refuse.
|
||||
const [config, setConfig] = useState<SiteConfig | undefined>(undefined)
|
||||
const { path, navigate } = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
api<SelfAccount>('/api/me')
|
||||
.then((me) => setAccount(me))
|
||||
.catch(() => setAccount(null))
|
||||
api<SiteConfig>('/api/config')
|
||||
.then((c) => setConfig(c))
|
||||
.catch(() => setConfig({ signupEnabled: false, turnstileSiteKey: null }))
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
@@ -102,12 +124,22 @@ export function App() {
|
||||
return (
|
||||
<>
|
||||
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
|
||||
{path === '/login' ? (
|
||||
<LoginPage account={account} 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
|
||||
// config in wrangler.jsonc), so a cold load of /signup reaches the SPA.
|
||||
<LoginPage
|
||||
account={account}
|
||||
config={config}
|
||||
initialTab={path === '/signup' ? 'signup' : 'login'}
|
||||
navigate={navigate}
|
||||
onAuthed={setAccount}
|
||||
/>
|
||||
) : path === '/account' ? (
|
||||
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
|
||||
) : (
|
||||
<HomePage />
|
||||
<HomePage account={account} config={config} navigate={navigate} />
|
||||
)}
|
||||
<SiteFooter />
|
||||
</>
|
||||
@@ -205,12 +237,25 @@ function useSlideshow() {
|
||||
* on top of them. Everything about how the thing is built sits below, for whoever
|
||||
* scrolls looking for it.
|
||||
*/
|
||||
function HomePage() {
|
||||
function HomePage({
|
||||
account,
|
||||
config,
|
||||
navigate,
|
||||
}: {
|
||||
account: SelfAccount | null | undefined
|
||||
config: SiteConfig | undefined
|
||||
navigate: Navigate
|
||||
}) {
|
||||
const feed = useSlideshow()
|
||||
|
||||
// The signup offer only makes sense to a signed-out visitor when the server would
|
||||
// actually take one. `account === undefined` is still-checking, so it shows nothing
|
||||
// rather than offering an account to someone who already has one.
|
||||
const offerSignup = account === null && config?.signupEnabled === true
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Stage slides={feed.slides} />
|
||||
<Stage slides={feed.slides} offerSignup={offerSignup} navigate={navigate} />
|
||||
<div className="shell home">
|
||||
<About slides={feed.slides} error={feed.error} />
|
||||
</div>
|
||||
@@ -219,31 +264,36 @@ function HomePage() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero: a rotating in-game photo with the headline and the way in over it. The
|
||||
* photo is the backdrop, never the payload — when the feed is slow or down the stage
|
||||
* still renders, so "Play now!" is reachable either way.
|
||||
* The hero: the headline and the way in on the left, a rotating in-game photo on the
|
||||
* right. The photo is proof, never the payload — when the feed is slow or down the
|
||||
* frame holds its space and the left half reads the same, so "Play now!" is reachable
|
||||
* either way.
|
||||
*/
|
||||
function Stage({ slides }: { slides: Slide[] | null }) {
|
||||
function Stage({
|
||||
slides,
|
||||
offerSignup,
|
||||
navigate,
|
||||
}: {
|
||||
slides: Slide[] | null
|
||||
offerSignup: boolean
|
||||
navigate: Navigate
|
||||
}) {
|
||||
const [idx, setIdx] = useState(0)
|
||||
const count = slides?.length ?? 0
|
||||
|
||||
// A timeout keyed on the current slide rather than one long-lived interval: steering
|
||||
// by hand re-arms it, so a photo you just picked gets its full six seconds.
|
||||
useEffect(() => {
|
||||
if (!slides || slides.length < 2) return
|
||||
const t = setInterval(() => setIdx((i) => (i + 1) % slides.length), 6000)
|
||||
return () => clearInterval(t)
|
||||
}, [slides])
|
||||
if (count < 2) return
|
||||
const t = setTimeout(() => setIdx((i) => (i + 1) % count), 6000)
|
||||
return () => clearTimeout(t)
|
||||
}, [count, idx])
|
||||
|
||||
const slide = slides && slides.length > 0 ? slides[idx] : null
|
||||
const step = (by: number) => setIdx((i) => (i + by + count) % count)
|
||||
|
||||
return (
|
||||
<section className="stage">
|
||||
{slide && (
|
||||
<img
|
||||
className="stage-photo"
|
||||
key={slide.url}
|
||||
src={slide.url}
|
||||
alt={`Photo taken in game by ${slide.username}`}
|
||||
/>
|
||||
)}
|
||||
<div className="stage-body">
|
||||
{/* Deliberately doesn't name the game: this is a fan project, so the
|
||||
trademark stays out of the headline and appears lower down, in
|
||||
@@ -251,40 +301,90 @@ function Stage({ slides }: { slides: Slide[] | null }) {
|
||||
<h1 className="stage-title">
|
||||
Play like it's <em>2023</em>.
|
||||
</h1>
|
||||
<p className="stage-lede">
|
||||
The servers you remember, rebuilt and running — free, open source, and up right now.
|
||||
</p>
|
||||
<div className="stage-actions">
|
||||
<a className="cta" href={DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||
Download for PC
|
||||
</a>
|
||||
<a className="cta" href={QUEST_DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||
Download for Quest
|
||||
</a>
|
||||
<a className="cta discord" href={DISCORD_INVITE} target="_blank" rel="noreferrer">
|
||||
Join the Discord
|
||||
</a>
|
||||
</div>
|
||||
{/* A line rather than a fourth button: the download is the point of this page,
|
||||
and launching the game makes an account by itself — signing up here is the
|
||||
way in for someone who wants one first. Hidden entirely when signup is
|
||||
closed, matching /login, which hides its create-account tab the same way. */}
|
||||
{offerSignup && (
|
||||
<p className="stage-alt">
|
||||
New here?{' '}
|
||||
<Link to="/signup" navigate={navigate}>
|
||||
Create an account
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{slide && (
|
||||
<div className="stage-show">
|
||||
<div className="stage-frame">
|
||||
{slide && (
|
||||
<img
|
||||
className="stage-photo"
|
||||
key={slide.url}
|
||||
src={slide.url}
|
||||
alt={`Photo taken in game by ${slide.username}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Always mounted, so the frame doesn't shift down when the feed lands. */}
|
||||
<div className="stage-foot">
|
||||
<span className="credit">
|
||||
Photo by @{slide.username}
|
||||
{slide.roomName && ` in ${slide.roomName}`}
|
||||
</span>
|
||||
{slides && slides.length > 1 && (
|
||||
<span className="dots">
|
||||
{slides.map((s, i) => (
|
||||
<button
|
||||
key={s.url}
|
||||
className={i === idx ? 'on' : ''}
|
||||
onClick={() => setIdx(i)}
|
||||
aria-label={`Show photo ${i + 1} of ${slides.length}`}
|
||||
aria-current={i === idx}
|
||||
/>
|
||||
))}
|
||||
{slide && (
|
||||
<span className="credit">
|
||||
Photo by @{slide.username}
|
||||
{slide.roomName && ` in ${slide.roomName}`}
|
||||
</span>
|
||||
)}
|
||||
{/* Arrows and a count, not a dot per photo: the feed runs to SLIDESHOW_LIMIT
|
||||
(130) images, and a dot each is both unusable and wide enough to shove
|
||||
the headline's half of the split off the page. */}
|
||||
{count > 1 && (
|
||||
<span className="steer">
|
||||
<button onClick={() => step(-1)} aria-label="Previous photo">
|
||||
<Chevron />
|
||||
</button>
|
||||
<span className="count">
|
||||
{idx + 1} / {count}
|
||||
</span>
|
||||
<button onClick={() => step(1)} aria-label="Next photo">
|
||||
<Chevron next />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The slideshow's back/forward mark. Decorative — the buttons carry the label. */
|
||||
function Chevron({ next }: { next?: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||
<path
|
||||
d={next ? 'M9 5l7 7-7 7' : 'M15 5l-7 7 7 7'}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** What RecFlare is, under the fold, for whoever wants it. */
|
||||
function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
||||
// The feed answering is proof the server replied, so the indicator can't claim
|
||||
@@ -297,8 +397,8 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
||||
<h2 className="about-title">An open source rebuild of the 2023 servers</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 — no gatekeeping, no basement
|
||||
server.
|
||||
<strong>feature-complete</strong> and infinitely scalable —{' '}
|
||||
<strong>architected for the cloud</strong>, no gatekeeping, no basement server.
|
||||
</p>
|
||||
</div>
|
||||
<div className="about-side">
|
||||
@@ -324,34 +424,85 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** The sign-in page. Redirects to the account page once a session exists. */
|
||||
/**
|
||||
* The sign-in page — sign in, plus create-account when the server says signup is open
|
||||
* (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a
|
||||
* session exists, however it was obtained.
|
||||
*/
|
||||
function LoginPage({
|
||||
account,
|
||||
config,
|
||||
initialTab,
|
||||
navigate,
|
||||
onAuthed,
|
||||
}: {
|
||||
account: SelfAccount | null | undefined
|
||||
config: SiteConfig | undefined
|
||||
initialTab: 'signup' | 'login'
|
||||
navigate: Navigate
|
||||
onAuthed: (a: SelfAccount) => void
|
||||
}) {
|
||||
// The tab IS the route (`/login` vs `/signup`) rather than local state, so the two can
|
||||
// never disagree — switching tabs pushes history, and back goes back to the other one.
|
||||
const tab = initialTab
|
||||
|
||||
useEffect(() => {
|
||||
if (account) navigate('/account')
|
||||
}, [account, navigate])
|
||||
|
||||
const authed = (a: SelfAccount) => {
|
||||
onAuthed(a)
|
||||
navigate('/account')
|
||||
}
|
||||
|
||||
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<section className="card">
|
||||
<h2>Sign in</h2>
|
||||
<p className="muted">
|
||||
Launch the game first — that creates an account linked to your Steam ID. Once you set a
|
||||
password, use your username and that password to sign in here.
|
||||
</p>
|
||||
<LoginForm
|
||||
onAuthed={(a) => {
|
||||
onAuthed(a)
|
||||
navigate('/account')
|
||||
}}
|
||||
/>
|
||||
{siteKey && (
|
||||
<div className="tabs">
|
||||
<button className={tab === 'login' ? 'active' : ''} onClick={() => navigate('/login')}>
|
||||
Sign in
|
||||
</button>
|
||||
<button
|
||||
className={tab === 'signup' ? 'active' : ''}
|
||||
onClick={() => navigate('/signup')}
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{siteKey && tab === 'signup' ? (
|
||||
<>
|
||||
<h2>Create account</h2>
|
||||
<p className="muted">
|
||||
A username is assigned for you — you'll see it on your account page. Choose a
|
||||
password, and the two together sign you in here and in the game.
|
||||
</p>
|
||||
<SignupForm siteKey={siteKey} onAuthed={authed} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>Sign in</h2>
|
||||
<p className="muted">
|
||||
Use your username and password. Launching the game also creates an account, linked to
|
||||
your Steam ID — set a password on it and it signs in here too.
|
||||
</p>
|
||||
<LoginForm onAuthed={authed} />
|
||||
{/* 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. */}
|
||||
{siteKey && (
|
||||
<p className="muted swap">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/signup" navigate={navigate}>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
@@ -409,9 +560,180 @@ function useAction() {
|
||||
return { pending, error, done, run }
|
||||
}
|
||||
|
||||
// Manual web signups are disabled for now, so only sign-in is exposed (accounts are
|
||||
// created via the game/platform, not the website). To bring signups back, restore a
|
||||
// SignupForm calling POST /api/signup and re-enable that endpoint in www.app.ts.
|
||||
/**
|
||||
* Turnstile's browser API, as much of it as the signup widget uses. Loaded from
|
||||
* Cloudflare at runtime (see loadTurnstile) rather than bundled, so it isn't in
|
||||
* node_modules and has no types of its own.
|
||||
*/
|
||||
interface TurnstileApi {
|
||||
render: (
|
||||
el: HTMLElement,
|
||||
opts: {
|
||||
sitekey: string
|
||||
action?: string
|
||||
callback?: (token: string) => void
|
||||
'expired-callback'?: () => void
|
||||
}
|
||||
) => string | undefined
|
||||
reset: (widgetId?: string) => void
|
||||
remove: (widgetId?: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Turnstile's script, once per page, resolving when `window.turnstile` is ready.
|
||||
* `render=explicit` stops it scanning the document for widgets: this is a SPA, so the
|
||||
* container mounts and unmounts with the form and we render into it ourselves.
|
||||
*
|
||||
* The promise is cached at module scope, so switching tabs back and forth reuses the
|
||||
* loaded script instead of appending another tag. A rejection is cached too — the retry
|
||||
* is a page reload, which is what the error message asks for.
|
||||
*/
|
||||
let turnstileScript: Promise<void> | null = null
|
||||
function loadTurnstile(): Promise<void> {
|
||||
turnstileScript ??= new Promise<void>((resolve, reject) => {
|
||||
const el = document.createElement('script')
|
||||
el.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
el.async = true
|
||||
el.defer = true
|
||||
el.onload = () => resolve()
|
||||
el.onerror = () => reject(new Error('load failed'))
|
||||
document.head.appendChild(el)
|
||||
})
|
||||
return turnstileScript
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a Turnstile widget and hand back the token it produces. No token means no
|
||||
* submit: the BFF refuses a signup without one, so the form gates its button on it
|
||||
* rather than letting the request fail.
|
||||
*
|
||||
* `reset` re-arms the widget for another attempt — a token is single-use, so a rejected
|
||||
* signup can't be retried with the same one.
|
||||
*/
|
||||
function useTurnstile(siteKey: string) {
|
||||
const container = useRef<HTMLDivElement | null>(null)
|
||||
const widgetId = useRef<string | undefined>(undefined)
|
||||
const [token, setToken] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
loadTurnstile()
|
||||
.then(() => {
|
||||
// StrictMode mounts twice, and the cleanup below removes the first widget; bail
|
||||
// if this effect is the stale one so we don't render into a detached container.
|
||||
if (!live || !container.current || !window.turnstile) return
|
||||
widgetId.current = window.turnstile.render(container.current, {
|
||||
sitekey: siteKey,
|
||||
// Marker Cloudflare uses to segment Turnstile integrations; carries no user data.
|
||||
action: 'turnstile-spin-v1',
|
||||
callback: (t) => setToken(t),
|
||||
// Tokens expire after a few minutes; drop ours so the button locks again and
|
||||
// Turnstile can hand us a fresh one.
|
||||
'expired-callback': () => setToken(''),
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setError("Couldn't load the bot check — reload the page to try again.")
|
||||
})
|
||||
|
||||
return () => {
|
||||
live = false
|
||||
if (widgetId.current) window.turnstile?.remove(widgetId.current)
|
||||
widgetId.current = undefined
|
||||
}
|
||||
}, [siteKey])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setToken('')
|
||||
if (widgetId.current) window.turnstile?.reset(widgetId.current)
|
||||
}, [])
|
||||
|
||||
return { container, token, error, reset }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an account from the website: a password, plus a Turnstile token proving a human
|
||||
* filled the form. The username comes back auto-assigned from `auth` (players don't pick
|
||||
* one), and the session is live on success — so this lands on the account page, where the
|
||||
* username is shown.
|
||||
*/
|
||||
function SignupForm({
|
||||
siteKey,
|
||||
onAuthed,
|
||||
}: {
|
||||
siteKey: string
|
||||
onAuthed: (a: SelfAccount) => void
|
||||
}) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const { container, token, error: widgetError, reset } = useTurnstile(siteKey)
|
||||
const { pending, error, run } = useAction()
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void run(async () => {
|
||||
try {
|
||||
const { account } = await api<{ account: SelfAccount }>('/api/signup', {
|
||||
password,
|
||||
email,
|
||||
turnstileToken: token,
|
||||
})
|
||||
onAuthed(account)
|
||||
return ''
|
||||
} catch (err) {
|
||||
// The token is spent either way, so re-arm the widget before they retry.
|
||||
reset()
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{/* Optional, and the button doesn't wait on it — but it's the only contact detail
|
||||
an account has, so the hint says plainly what it's for rather than leaving it
|
||||
to be guessed. `type="email"` gets the right keyboard on mobile and a free
|
||||
format check; the worker re-checks it before the account is created. */}
|
||||
<label>
|
||||
Email <span className="optional">optional</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
autoComplete="email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<span className="hint">
|
||||
How you get back in if you forget your password — there's no other way to reach you.
|
||||
You can add it later on your account page.
|
||||
</span>
|
||||
</label>
|
||||
<div className="turnstile" ref={container} />
|
||||
{widgetError && <p className="error">{widgetError}</p>}
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" disabled={pending || token === ''}>
|
||||
{pending ? 'Creating…' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
+186
-70
@@ -84,7 +84,7 @@ body {
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
/* The homepage: the stage runs full-bleed above this, so it brings its own top space. */
|
||||
/* The homepage: the stage above brings its own top space and shares this width. */
|
||||
.shell.home {
|
||||
max-width: 1040px;
|
||||
padding-top: 0;
|
||||
@@ -153,19 +153,42 @@ body {
|
||||
/* ---- The stage (hero) --------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* Full-bleed, edge to edge under the nav: a photo somebody actually took in game,
|
||||
* with the headline and the way in over it. The photo is the backdrop and never the
|
||||
* payload — with no photo the stage is still a solid panel carrying the same words.
|
||||
* Split down the middle: what this is and the way in on the left, a photo somebody
|
||||
* actually took in game on the right. The photo is proof and never the payload — with
|
||||
* no photo the frame holds its space and the left half carries the same words.
|
||||
*/
|
||||
.stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 20px 16px;
|
||||
}
|
||||
|
||||
/* min-width: 0 on both halves, or the split isn't one: a `1fr` track's automatic
|
||||
minimum is its content's min-content width, so a wide child (the slideshow controls,
|
||||
a long unbroken credit) grows its column past 50% and takes the space out of the
|
||||
other one — which is how this last read as 30/70 with the buttons crushed. */
|
||||
.stage-show {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: min(40vh, 320px);
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Fixed shape, filled by whatever lands: screenshots arrive at any aspect ratio, and a
|
||||
frame that resized per photo would jog the headline beside it on every rotation. The
|
||||
ratio is landscape rather than 4:3 so the photo doesn't tower over the column beside
|
||||
it — equal columns still read unequal when one is half again as tall. */
|
||||
.stage-frame {
|
||||
position: relative;
|
||||
aspect-ratio: 3 / 2;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-hi);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.stage-photo {
|
||||
@@ -174,11 +197,6 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: -2;
|
||||
/* Softened so the headline reads over any screenshot. Scaled up past the frame
|
||||
because blur samples past the edges and would otherwise feather them. */
|
||||
filter: blur(5px) saturate(1.08);
|
||||
transform: scale(1.06);
|
||||
animation: photo-in 0.7s ease;
|
||||
}
|
||||
|
||||
@@ -188,40 +206,20 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrim: enough weight at the bottom to hold white text over any screenshot. */
|
||||
.stage::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgb(10 7 4 / 90%) 0%,
|
||||
rgb(10 7 4 / 74%) 40%,
|
||||
rgb(10 7 4 / 44%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.stage-body {
|
||||
max-width: 1040px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 28px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* No width cap here, unlike the headings below: this one shares a row with the photo,
|
||||
and a headline that stops short of its column makes the split read as 30/70. */
|
||||
.stage-title {
|
||||
font-family: var(--display);
|
||||
font-weight: 800;
|
||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: #fff;
|
||||
margin: 0 0 20px;
|
||||
max-width: 15ch;
|
||||
margin: 0 0 16px;
|
||||
text-wrap: balance;
|
||||
/* Bloom: a wide, soft shadow rather than a hard one, so it separates the type from
|
||||
a bright screenshot without reading as a drop shadow. */
|
||||
text-shadow: 0 2px 30px rgb(8 5 2 / 55%);
|
||||
}
|
||||
|
||||
/* The one place the orange carries meaning in the headline: the year it restores. */
|
||||
@@ -230,55 +228,84 @@ body {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Credit line and slide dots, sitting under the headline on the photo itself. */
|
||||
.stage-lede {
|
||||
font-size: 1.05rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
/* The signup offer under the hero buttons. Deliberately quieter than a CTA — it sits
|
||||
below the downloads without competing with them — but the link itself carries the
|
||||
accent so it reads as the action it is. */
|
||||
.stage-alt {
|
||||
font-size: 0.925rem;
|
||||
color: var(--muted);
|
||||
margin: 18px 0 0;
|
||||
}
|
||||
|
||||
.stage-alt a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.stage-alt a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Credit line and slideshow controls, under the photo rather than on it. */
|
||||
.stage-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px 20px;
|
||||
max-width: 1040px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 22px;
|
||||
gap: 8px 20px;
|
||||
/* Reserved even while the feed is in flight, so nothing shifts when it lands. */
|
||||
min-height: 28px;
|
||||
font-size: 0.8rem;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
text-shadow: 0 1px 12px rgb(8 5 2 / 60%);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* The dots are the only way to steer the stage, so each one gets a 24px target
|
||||
even though the mark itself is 7px. */
|
||||
.dots {
|
||||
/* Long usernames and room names wrap instead of widening the column. */
|
||||
.credit {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Back / forward, with the position between them. Fixed width whatever the feed
|
||||
length is — see the note on .stage-show for what a per-photo control did here. */
|
||||
.steer {
|
||||
display: flex;
|
||||
margin: -8px -6px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.dots button {
|
||||
.steer button {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dots button::after {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 34%);
|
||||
transition: background 0.2s ease;
|
||||
.steer button:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.dots button:hover::after {
|
||||
background: rgb(255 255 255 / 65%);
|
||||
}
|
||||
|
||||
.dots button.on::after {
|
||||
background: var(--accent);
|
||||
/* Tabular figures so the frame doesn't twitch as the index rolls 9 → 10. */
|
||||
.count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* ---- What it is (below the stage) --------------------------------------- */
|
||||
@@ -544,6 +571,40 @@ h2 {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Sign in / create account, at the top of the auth card. Two of a kind, so they read as
|
||||
one control rather than as two buttons competing with the orange submit below. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
padding: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: var(--body);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
background: var(--surface-hi);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Forms -------------------------------------------------------------- */
|
||||
|
||||
label {
|
||||
@@ -572,6 +633,27 @@ textarea {
|
||||
min-height: 76px;
|
||||
}
|
||||
|
||||
/* Marks a field the form will submit without. Quiet, but next to the label rather than
|
||||
inside the input, so it survives the field being filled in. */
|
||||
.optional {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* Why a field is worth filling in, under the input it belongs to. Sits inside the label,
|
||||
so it's read out with the field rather than as loose text after it. */
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -579,6 +661,14 @@ textarea:focus {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Where the Turnstile iframe mounts. It brings its own chrome, so this only reserves the
|
||||
space (the widget is 300×65 at normal size) — otherwise the submit button jumps down
|
||||
the moment the check finishes loading. */
|
||||
.turnstile {
|
||||
min-height: 65px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -602,6 +692,22 @@ button[type='submit']:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The cross-link under an auth form ("Don't have an account? Create one"). Sits under
|
||||
the submit button, which hugs its label, so it needs its own separation from it. */
|
||||
.swap {
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
.swap a {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.swap a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- Utilities ---------------------------------------------------------- */
|
||||
|
||||
.big {
|
||||
@@ -633,6 +739,15 @@ button[type='submit']:disabled {
|
||||
|
||||
/* ---- Responsive --------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 860px) {
|
||||
/* One column: the words lead, the photo follows. */
|
||||
.stage {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 32px;
|
||||
padding: 40px 20px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
/* One column: the copy first, then the links and the status under it. */
|
||||
.about {
|
||||
@@ -643,8 +758,9 @@ button[type='submit']:disabled {
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.stage {
|
||||
min-height: min(38vh, 300px);
|
||||
.stage-actions .cta {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-links .cta {
|
||||
|
||||
@@ -6,6 +6,23 @@ export type Env = SharedHonoEnv & {
|
||||
DOMAIN: string
|
||||
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
||||
ASSETS: Fetcher
|
||||
/**
|
||||
* The Turnstile widget's public site key. Public by design — it ships to the browser so
|
||||
* the widget can render — but it lives in the Secrets Store beside its secret, so one
|
||||
* place configures signup and there's a single place to look.
|
||||
*
|
||||
* Resolve the value with `await env.TURNSTILE_SITE_KEY.get()`.
|
||||
*/
|
||||
TURNSTILE_SITE_KEY: SecretsStoreSecret
|
||||
/**
|
||||
* The Turnstile widget's secret key — the one that turns a widget token into a verdict.
|
||||
* Same shared account-level store as JWT_SECRET; the store id is spliced into
|
||||
* wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
|
||||
*
|
||||
* Store values survive a deploy, so both are created once and left alone. Either one
|
||||
* failing to resolve closes web signup — see src/turnstile.ts.
|
||||
*/
|
||||
TURNSTILE_SECRET_KEY: SecretsStoreSecret
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -13,6 +13,9 @@ export const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz'
|
||||
/** Where the stage's "Download for PC" button goes: the client's release listing. */
|
||||
export const DOWNLOAD_URL = 'https://github.com/djdevin/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/6w1HPL3j2'
|
||||
|
||||
/** The public source repo, linked from the homepage and footer. */
|
||||
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
|
||||
|
||||
|
||||
@@ -23,11 +23,14 @@ import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL, SOURCE_REPO } from './links'
|
||||
* the claim Privacy.2 is judged on, and it goes stale the moment a worker stores
|
||||
* something new.
|
||||
*
|
||||
* "How you sign in" describes Meta SSO (PlatformType.Oculus), which the auth worker
|
||||
* still stubs — see the FAKE_OCULUS_CACHED_LOGIN branch in apps/auth/src/auth.app.ts.
|
||||
* When that lands, check the text still matches what the integration actually requests
|
||||
* from Meta: Privacy.2 asks for extra detail about platform features specifically, and
|
||||
* the same disclosure has to agree with the Data Use Checkup filed for the app.
|
||||
* "How you sign in" describes Meta SSO (PlatformType.Oculus), now implemented in
|
||||
* apps/auth/src/meta-nonce.ts. What that integration actually sends Meta is the login
|
||||
* nonce plus the user id it is claimed for, and all it gets back is valid/not valid —
|
||||
* so the disclosure's claim that Meta "learns that a sign-in happened" is right, but it
|
||||
* over-discloses on two points that should be squared with the Data Use Checkup filed
|
||||
* for the app: we do NOT retrieve a display name (only the user id is stored, see
|
||||
* accounts-db.ts), and nonce validation does not check app entitlement. Privacy.2 asks
|
||||
* for extra detail about platform features specifically, so keep this exact.
|
||||
*/
|
||||
|
||||
/** Last substantive revision, shown in the header. Bump when the text changes. */
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { SELF } from 'cloudflare:test'
|
||||
import { expect, it } from 'vitest'
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, expect, it } from 'vitest'
|
||||
|
||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||||
import { turnstileKeys } from '../../turnstile'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
|
||||
// Turnstile's documented always-passes test keypair, seeded into the LOCAL Secrets Store
|
||||
// so the bindings resolve — the same way every other worker's tests seed JWT_SECRET. It
|
||||
// stands in for the two account-level secrets a deployed www reads, and it's what OPENS
|
||||
// signup (see src/turnstile.ts): without it every signup test would test the closed door.
|
||||
const TEST_SITE_KEY = '1x00000000000000000000AA'
|
||||
const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
|
||||
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
|
||||
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY)
|
||||
})
|
||||
|
||||
it('rejects unauthenticated account reads', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/me')
|
||||
@@ -10,14 +29,85 @@ it('rejects unauthenticated account reads', async () => {
|
||||
expect(await res.json()).toEqual({ error: 'not signed in' })
|
||||
})
|
||||
|
||||
it('refuses manual signups (disabled)', async () => {
|
||||
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
||||
// the pass path can't be tested here (it would call Cloudflare's siteverify for real).
|
||||
it('advertises signup with the Turnstile site key the widget needs', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/config')
|
||||
expect(res.status).toBe(200)
|
||||
// Read through the Secrets Store binding, from the value seeded above.
|
||||
expect(await res.json()).toEqual({
|
||||
signupEnabled: true,
|
||||
turnstileSiteKey: TEST_SITE_KEY,
|
||||
})
|
||||
})
|
||||
|
||||
// The keypair is the on/off switch for signup, so a www whose keys don't resolve must
|
||||
// report it closed — that's the state a fresh deploy starts in, before the operator
|
||||
// creates the two secrets. Checked directly because the real bindings are seeded for the
|
||||
// fetch tests above.
|
||||
//
|
||||
// A store read that THROWS (secret absent, store unreachable) has to close the door the
|
||||
// same way rather than surface as an error: /api/config is on the homepage's critical
|
||||
// path, and a 500 there costs the whole page, not just the signup form.
|
||||
it('treats an unresolvable or half-configured keypair as signup being off', async () => {
|
||||
const stub = (value: string | null): SecretsStoreSecret =>
|
||||
({ get: async () => value ?? '' }) as SecretsStoreSecret
|
||||
const throws = (): SecretsStoreSecret =>
|
||||
({
|
||||
get: async () => {
|
||||
throw new Error('secret not found')
|
||||
},
|
||||
}) as unknown as SecretsStoreSecret
|
||||
|
||||
const withKeys = (site: SecretsStoreSecret, secret: SecretsStoreSecret) =>
|
||||
({
|
||||
ENVIRONMENT: 'development',
|
||||
TURNSTILE_SITE_KEY: site,
|
||||
TURNSTILE_SECRET_KEY: secret,
|
||||
}) as Env
|
||||
|
||||
await expect(turnstileKeys(withKeys(throws(), throws()))).resolves.toBeNull()
|
||||
await expect(turnstileKeys(withKeys(stub('0xsite'), throws()))).resolves.toBeNull()
|
||||
await expect(turnstileKeys(withKeys(throws(), stub('0xsecret')))).resolves.toBeNull()
|
||||
await expect(turnstileKeys(withKeys(stub(''), stub('0xsecret')))).resolves.toBeNull()
|
||||
await expect(turnstileKeys(withKeys(stub('0xsite'), stub('0xsecret')))).resolves.toEqual({
|
||||
siteKey: '0xsite',
|
||||
secretKey: '0xsecret',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a signup with no Turnstile token', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'whatever' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
expect(await res.json()).toEqual({ error: 'Account creation is currently disabled.' })
|
||||
// Rejected before any upstream call, so a bot can't reach create_account by omitting it.
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'Please complete the bot check.' })
|
||||
})
|
||||
|
||||
// The email is optional, but a malformed one is rejected BEFORE the account is created —
|
||||
// the accounts worker would refuse to store it, and by then the account exists and the
|
||||
// player would be left with an account whose email silently didn't save.
|
||||
it('refuses a signup whose email could not be stored', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'whatever', email: 'not-an-address', turnstileToken: 'x' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'That email address looks wrong.' })
|
||||
})
|
||||
|
||||
it('refuses a signup with no password', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ turnstileToken: 'dummy' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'A password is required.' })
|
||||
})
|
||||
|
||||
it('requires credentials to log in', async () => {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
|
||||
import type { Env } from './context'
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile, the bot check in front of web signup. Two keys make a widget:
|
||||
* the SITE key, which is public (it ships in the page markup so the browser can render
|
||||
* the widget), and the SECRET key, which stays on the worker and is the only thing that
|
||||
* can turn a widget token into a verdict. Both are held in the shared Secrets Store.
|
||||
*
|
||||
* The verdict is fetched server-side, here in the BFF — never from the browser, which
|
||||
* would hand the secret to anyone who viewed source. The browser's only job is to carry
|
||||
* the widget's token to `POST /api/signup`.
|
||||
*
|
||||
* Turnstile is what makes web signup safe to leave open: `auth`'s per-IP cap is the only
|
||||
* other thing standing in front of the password/anonymous account path (it has no
|
||||
* platform identity to count), and that cap is coarse enough that it can't be the whole
|
||||
* defence. So the keypair IS the switch — no keypair, no signup (see `turnstileKeys`).
|
||||
* Nothing is ever inferred from the environment, so a worker can't end up with signup
|
||||
* open and no bot check behind it.
|
||||
*/
|
||||
|
||||
/** Turnstile's verdict endpoint. Called from the worker only; the secret never leaves it. */
|
||||
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||
|
||||
/**
|
||||
* The keypair web signup runs on, or null when there isn't one — which is what closes
|
||||
* signup (`/api/config` reports it, `/api/signup` refuses). Both keys come from the
|
||||
* account-level Secrets Store the whole monorepo shares (see context.ts), so they're
|
||||
* resolved per request rather than read off `env` as strings.
|
||||
*
|
||||
* Both must resolve. Half a configuration (a site key whose secret is missing from the
|
||||
* store) counts as unconfigured rather than as a widget whose token nobody can check, and
|
||||
* says so in the log — it's otherwise indistinguishable from signup being deliberately
|
||||
* off. A `.get()` that throws (secret absent from the store, binding not deployed, store
|
||||
* unreachable) is treated the same way, so a Worker that can't read its keys closes the
|
||||
* door instead of 500ing on the homepage.
|
||||
*
|
||||
* `.get()` caches per isolate, so changing a value in the store needs a `www` redeploy to
|
||||
* take effect on a warm worker — the same caveat the shared JWT_SECRET carries.
|
||||
*
|
||||
* For local dev, seed the two names into the LOCAL store (miniflare's, not your account's)
|
||||
* with Turnstile's documented always-passes test keypair — see apps/www/README.md. That
|
||||
* pair belongs to no account and passes without a human. Deliberately not a built-in
|
||||
* fallback: the same code path then runs everywhere.
|
||||
*/
|
||||
export async function turnstileKeys(
|
||||
env: Env
|
||||
): Promise<{ siteKey: string; secretKey: string } | null> {
|
||||
const [siteKey, secretKey] = await Promise.all([
|
||||
readSecret(env.TURNSTILE_SITE_KEY, 'TURNSTILE_SITE_KEY'),
|
||||
readSecret(env.TURNSTILE_SECRET_KEY, 'TURNSTILE_SECRET_KEY'),
|
||||
])
|
||||
if (siteKey !== '' && secretKey !== '') return { siteKey, secretKey }
|
||||
if (siteKey !== '' || secretKey !== '') {
|
||||
logger.error('turnstile is half-configured, so web signup is closed', {
|
||||
hasSiteKey: siteKey !== '',
|
||||
hasSecretKey: secretKey !== '',
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* One Secrets Store value as a string, or '' when it can't be read. The binding is
|
||||
* declared in wrangler.jsonc, so it's always present on `env`; what varies is whether the
|
||||
* store actually holds the secret — a missing one throws here rather than resolving empty.
|
||||
*/
|
||||
async function readSecret(secret: SecretsStoreSecret, name: string): Promise<string> {
|
||||
try {
|
||||
return (await secret.get()) ?? ''
|
||||
} catch (err) {
|
||||
logger.error('failed to read a turnstile key from the secrets store', {
|
||||
secret: name,
|
||||
error: String(err),
|
||||
})
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Turnstile's siteverify response, narrowed to the fields we act on. */
|
||||
interface SiteVerifyResponse {
|
||||
success?: boolean
|
||||
'error-codes'?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask Turnstile whether a widget token is good. `remoteIp` is the client's real IP per
|
||||
* Cloudflare (`CF-Connecting-IP`), which Turnstile cross-checks against the one that
|
||||
* solved the challenge; it's omitted when absent rather than sent empty.
|
||||
*
|
||||
* A token is single-use, so a failed verdict means the widget has to be reset before the
|
||||
* player can retry — the client does that (see the signup form).
|
||||
*
|
||||
* Any failure to reach Turnstile is a rejection, not a pass: this is the only bot check
|
||||
* in front of signup, so a broken verdict path must not open the door.
|
||||
*/
|
||||
export async function verifyTurnstile(
|
||||
secretKey: string,
|
||||
token: string,
|
||||
remoteIp?: string
|
||||
): Promise<boolean> {
|
||||
const fields: Record<string, string> = { secret: secretKey, response: token }
|
||||
if (remoteIp) fields.remoteip = remoteIp
|
||||
|
||||
try {
|
||||
const res = await fetch(SITEVERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
if (!res.ok) {
|
||||
logger.error('turnstile siteverify failed', { status: res.status })
|
||||
return false
|
||||
}
|
||||
const verdict = (await res.json()) as SiteVerifyResponse
|
||||
if (verdict.success !== true) {
|
||||
// The codes name the reason (`invalid-input-response`, `timeout-or-duplicate`,
|
||||
// `invalid-input-secret`, …) — the last of those is a misconfiguration, not a bot,
|
||||
// and this log line is the only place it shows up.
|
||||
logger.info('turnstile rejected a signup', { codes: verdict['error-codes'] ?? [] })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
logger.error('turnstile siteverify threw', { error: String(err) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
+83
-8
@@ -2,11 +2,12 @@ import { Hono } from 'hono'
|
||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withOnError } from '@repo/hono-helpers'
|
||||
import { logger, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import { docsPage, fetchSpec } from './docs'
|
||||
import { privacyPage } from './privacy'
|
||||
import { turnstileKeys, verifyTurnstile } from './turnstile'
|
||||
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
@@ -87,8 +88,12 @@ async function relay(c: Context<App>, res: Response) {
|
||||
* Exchange an auth `/connect/token` response for a session: persist the returned
|
||||
* access token in the httpOnly cookie, then return the caller's self account
|
||||
* (fetched from the accounts worker with the fresh token).
|
||||
*
|
||||
* `email`, when given, is saved onto the new account before that fetch, so the account
|
||||
* comes back already carrying it. `create_account` takes no email — the accounts worker
|
||||
* owns that field — which is why this is a second call rather than another grant field.
|
||||
*/
|
||||
async function establishSession(c: Context<App>, tokenResponse: Response) {
|
||||
async function establishSession(c: Context<App>, tokenResponse: Response, email?: string) {
|
||||
if (!tokenResponse.ok) return relay(c, tokenResponse)
|
||||
|
||||
const token = (await tokenResponse.json()) as { access_token?: string; expires_in?: number }
|
||||
@@ -103,6 +108,25 @@ async function establishSession(c: Context<App>, tokenResponse: Response) {
|
||||
sessionCookieOptions(c, token.expires_in ?? 3600)
|
||||
)
|
||||
|
||||
// Deliberately not fatal: the account exists and the session is live by now, so failing
|
||||
// the request would leave the player holding an account they think they don't have —
|
||||
// and a retry would burn another slot against auth's per-IP signup cap. They land on
|
||||
// the account page instead, where the email field is the same one call away. The
|
||||
// address is validated before signup starts, so reaching here means something upstream
|
||||
// went wrong, not that the input was bad.
|
||||
if (email) {
|
||||
const res = await postForm(
|
||||
`${accountsBase(c.env)}/account/me/email`,
|
||||
{ email },
|
||||
token.access_token
|
||||
)
|
||||
if (!res.ok) {
|
||||
logger.error('failed to save the signup email; the account was still created', {
|
||||
status: res.status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const me = await fetch(`${accountsBase(c.env)}/account/me`, {
|
||||
headers: { authorization: `Bearer ${token.access_token}` },
|
||||
})
|
||||
@@ -126,12 +150,63 @@ const app = new Hono<App>()
|
||||
|
||||
// ---- BFF API ------------------------------------------------------------
|
||||
|
||||
// Manual web signups are disabled for now — accounts are created via the game /
|
||||
// platform, not the website. Kept as an explicit closed endpoint (rather than
|
||||
// removed) so a direct POST is refused too, not just hidden in the UI. To reopen,
|
||||
// forward a platform-less `grant_type=create_account` to auth and start a session
|
||||
// (see git history), and restore the SignupForm in the client.
|
||||
.post('/api/signup', (c) => c.json({ error: 'Account creation is currently disabled.' }, 403))
|
||||
// What the SPA has to know before it can render the sign-in page: whether web signup
|
||||
// is open, and the Turnstile site key to mount its widget with. The site key is public
|
||||
// (it ships in the widget markup either way); the secret never leaves the worker.
|
||||
// Served rather than baked into the client build so one build works for any operator.
|
||||
.get('/api/config', async (c) => {
|
||||
const keys = await turnstileKeys(c.env)
|
||||
return c.json({ signupEnabled: keys !== null, turnstileSiteKey: keys?.siteKey ?? null })
|
||||
})
|
||||
|
||||
// Create an account from the website, behind a Turnstile bot check. The check is what
|
||||
// makes this safe to leave open: `auth` binds no platform identity to a web account, so
|
||||
// its per-IP cap is the only other thing in front of this path.
|
||||
//
|
||||
// Deliberately passes NO `platform`: create_account treats an asserted platform as one
|
||||
// to verify against Steam and would reject RecNet (see WEB_PLATFORM), so this is the
|
||||
// platform-less password-account path. The username is auto-assigned by auth — players
|
||||
// don't pick one — and the new session is established from the token response.
|
||||
.post('/api/signup', async (c) => {
|
||||
// No usable keypair means signup is closed rather than unprotected (see turnstile.ts).
|
||||
const keys = await turnstileKeys(c.env)
|
||||
if (!keys) return c.json({ error: 'Account creation is currently disabled.' }, 403)
|
||||
|
||||
type SignupBody = { password?: string; email?: string; turnstileToken?: string }
|
||||
const { password, email, turnstileToken } = await c.req
|
||||
.json<SignupBody>()
|
||||
.catch(() => ({}) as SignupBody)
|
||||
if (!password) return c.json({ error: 'A password is required.' }, 400)
|
||||
if (!turnstileToken) return c.json({ error: 'Please complete the bot check.' }, 400)
|
||||
|
||||
// Optional — an account works without one; it's the address a locked-out player
|
||||
// would be reached at. Checked HERE, before anything is created, because the
|
||||
// accounts worker rejects an address with no `@` and by then the account exists:
|
||||
// better to fail the form than to hand back an account whose email silently didn't
|
||||
// save. Same rule the accounts worker applies, deliberately no stricter — this is
|
||||
// a contact address, not an identity, and nothing is sent to it to prove it.
|
||||
const signupEmail = typeof email === 'string' ? email.trim() : ''
|
||||
if (signupEmail !== '' && !signupEmail.includes('@')) {
|
||||
return c.json({ error: 'That email address looks wrong.' }, 400)
|
||||
}
|
||||
|
||||
// The IP Turnstile cross-checks the token against — set by the edge, so the client
|
||||
// can't spoof it (unlike X-Forwarded-For). `auth` records the same header as the
|
||||
// account's signup IP.
|
||||
const verified = await verifyTurnstile(
|
||||
keys.secretKey,
|
||||
turnstileToken,
|
||||
c.req.header('cf-connecting-ip')
|
||||
)
|
||||
// A token is single-use, so the client resets its widget before letting them retry.
|
||||
if (!verified) return c.json({ error: 'Bot check failed. Please try again.' }, 403)
|
||||
|
||||
const res = await postForm(`${authBase(c.env)}/connect/token`, {
|
||||
grant_type: 'create_account',
|
||||
password,
|
||||
})
|
||||
return establishSession(c, res, signupEmail || undefined)
|
||||
})
|
||||
|
||||
// Log in with a username + password, then start a session. The auth password grant
|
||||
// resolves the account by `username` (case-insensitive) — web players sign in with
|
||||
|
||||
@@ -8,6 +8,10 @@ export default defineConfig({
|
||||
miniflare: {
|
||||
bindings: {
|
||||
ENVIRONMENT: 'VITEST',
|
||||
// The Turnstile keypair is NOT bound here: both keys come from the Secrets
|
||||
// Store now, and the tests seed the local store with the test pair (see
|
||||
// src/test/integration/api.test.ts). A plain binding of the same name would
|
||||
// shadow the store binding with a string.
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -28,6 +28,32 @@
|
||||
"not_found_handling": "single-page-application",
|
||||
"run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy"]
|
||||
},
|
||||
// The Turnstile keypair guarding web signup, out of the same account-level Secrets
|
||||
// Store every other worker binds for JWT_SECRET — values live there, never in this
|
||||
// file. The "local" store_id placeholder is replaced with RECFLARE_SECRETS_STORE at
|
||||
// deploy time, exactly as it is for the other workers.
|
||||
//
|
||||
// wrangler secrets-store secret create <store-id> --name TURNSTILE_SITE_KEY \
|
||||
// --scopes workers --remote
|
||||
// wrangler secrets-store secret create <store-id> --name TURNSTILE_SECRET_KEY \
|
||||
// --scopes workers --remote
|
||||
//
|
||||
// Creating both is what OPENS signup; if either can't be resolved it stays closed, so
|
||||
// an operator who skips this gets no signup rather than an unprotected one. The SITE
|
||||
// key is public (it ships to the browser to render the widget) and is kept here beside
|
||||
// its secret so one place configures signup. See src/turnstile.ts.
|
||||
"secrets_store_secrets": [
|
||||
{
|
||||
"binding": "TURNSTILE_SITE_KEY",
|
||||
"store_id": "local",
|
||||
"secret_name": "TURNSTILE_SITE_KEY"
|
||||
},
|
||||
{
|
||||
"binding": "TURNSTILE_SECRET_KEY",
|
||||
"store_id": "local",
|
||||
"secret_name": "TURNSTILE_SECRET_KEY"
|
||||
}
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
|
||||
@@ -11,18 +11,20 @@
|
||||
* it from `@repo/domain` (each uses the subset it needs).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations 0001_accounts + 0002_avatar, sans seed INSERTs). */
|
||||
/**
|
||||
* Schema DDL — the head schema, i.e. what the table looks like after every migration
|
||||
* (0001_accounts + 0002_avatar, sans seed INSERTs; 0004 added a `platform_id` generated
|
||||
* column and 0008 dropped it again, so it appears here in neither form).
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS account (
|
||||
data TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
|
||||
platform_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.platformId')) VIRTUAL
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) 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_accounts_platform_id ON account (platform_id)`,
|
||||
]
|
||||
|
||||
/** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */
|
||||
@@ -31,22 +33,20 @@ export interface Account {
|
||||
username: string
|
||||
displayName: string
|
||||
profileImage: string
|
||||
/** Profile banner image key. No route sets it yet, so it's `""` on every account. */
|
||||
bannerImage: string
|
||||
/** The emoji shown beside the display name. No route sets it yet — always `""`. */
|
||||
displayEmoji: string
|
||||
isJunior: boolean
|
||||
platforms: number
|
||||
personalPronouns: number
|
||||
identityFlags: number
|
||||
createdAt: string
|
||||
/**
|
||||
* The platform-native identity linked to this account (e.g. a SteamID64 for
|
||||
* platform 0). Stored as a STRING on purpose — a SteamID64 exceeds 2^53 and
|
||||
* would lose precision as a JS number. Set at account creation from the login's
|
||||
* `platform_id`. A cached login is authorized ONLY to the account whose stored
|
||||
* `platformId` matches the (platform_auth-ticket-proven) platform id presented,
|
||||
* so no one but that platform user can log into the account.
|
||||
* The account's PRIMARY platform identity — the first one linked (e.g. a SteamID64
|
||||
* for platform 0). Stored as a STRING on purpose: a SteamID64 exceeds 2^53 and
|
||||
* would lose precision as a JS number.
|
||||
*
|
||||
* An account can be reachable from SEVERAL platform identities (a PC and a headset),
|
||||
* and those live in the `auth` worker's `platform_account` table — NOT here. Logins
|
||||
* are authorized against that table alone; this pair is what the account DTO and a
|
||||
* refreshed token's claims report, and it never gains a second value.
|
||||
*/
|
||||
platformId?: string
|
||||
/** PlatformType int (0 = Steam) that `platformId` belongs to. */
|
||||
@@ -164,8 +164,6 @@ export function defaultAccount(id: number, overrides: Partial<Account> = {}): Ac
|
||||
username: `Player${id}`,
|
||||
displayName: `Player${id}`,
|
||||
profileImage: 'DefaultProfileImage.jpg',
|
||||
bannerImage: '',
|
||||
displayEmoji: '',
|
||||
isJunior: false,
|
||||
platforms: 0,
|
||||
personalPronouns: 0,
|
||||
@@ -222,23 +220,6 @@ export async function searchAccounts(
|
||||
return parseAll(results)
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts linked to a platform-native id (e.g. a SteamID64), for the cached-login
|
||||
* account picker. Backed by the indexed `platform_id` generated column. Empty id
|
||||
* yields no matches (avoids matching every account whose `platformId` is null).
|
||||
*/
|
||||
export async function getAccountsByPlatformId(
|
||||
db: D1Database,
|
||||
platformId: string
|
||||
): Promise<Account[]> {
|
||||
if (platformId === '') return []
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM account WHERE platform_id = ?1')
|
||||
.bind(platformId)
|
||||
.all<AccountRow>()
|
||||
return parseAll(results)
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts last seen on a given device (the client-supplied `device_id` auth records
|
||||
* at login). An empty id yields no matches (avoids matching every account with no
|
||||
@@ -246,8 +227,9 @@ export async function getAccountsByPlatformId(
|
||||
*
|
||||
* Reads `deviceId` straight out of the JSON blob, so this is a table scan — no
|
||||
* generated column, no migration. Fine at our account count and for the occasional
|
||||
* linkup lookup this exists for; if it ever gets hot, promote `deviceId` to an
|
||||
* indexed generated column the way `platformId` is (see the 0004 migration).
|
||||
* linkup lookup this exists for; if it ever gets hot, promote `deviceId` to an indexed
|
||||
* generated column (migration 0004 did that for `platformId`, and 0008 undid it once
|
||||
* nothing queried it — that pair is the recipe both ways).
|
||||
*
|
||||
* The device id is unverified client input, so treat a match as a *hint* (these
|
||||
* accounts share a device) and never as proof of identity.
|
||||
@@ -264,7 +246,9 @@ export async function getAccountsByDeviceId(db: D1Database, deviceId: string): P
|
||||
/** Record the account's most recent successful login time (ISO-8601). */
|
||||
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
|
||||
await db
|
||||
.prepare("UPDATE account SET data = json_set(data, '$.lastLoginTime', ?2) WHERE account_id = ?1")
|
||||
.prepare(
|
||||
"UPDATE account SET data = json_set(data, '$.lastLoginTime', ?2) WHERE account_id = ?1"
|
||||
)
|
||||
.bind(id, time)
|
||||
.run()
|
||||
}
|
||||
@@ -301,9 +285,7 @@ export async function setLoginContext(
|
||||
}
|
||||
if (sets.length === 0) return
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`
|
||||
)
|
||||
.prepare(`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`)
|
||||
.bind(id, ...binds)
|
||||
.run()
|
||||
}
|
||||
@@ -330,21 +312,6 @@ export async function countAccountsBySignupIp(db: D1Database, ip: string): Promi
|
||||
return row?.n ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* How many accounts are linked to a platform-native id (e.g. one SteamID64) — the
|
||||
* count a per-platform signup cap is enforced against. Backed by the indexed
|
||||
* `platform_id` generated column. An empty id counts 0 (accounts with no platform
|
||||
* identity aren't attributable to a platform user).
|
||||
*/
|
||||
export async function countAccountsByPlatformId(db: D1Database, platformId: string): Promise<number> {
|
||||
if (platformId === '') return 0
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS n FROM account WHERE platform_id = ?1')
|
||||
.bind(platformId)
|
||||
.first<{ n: number }>()
|
||||
return row?.n ?? 0
|
||||
}
|
||||
|
||||
/** Look up multiple accounts by AccountId (order not guaranteed). */
|
||||
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
|
||||
if (ids.length === 0) return []
|
||||
@@ -417,9 +384,7 @@ export async function getPasswordHash(db: D1Database, id: number): Promise<strin
|
||||
/** Persist the account's password hash. Returns false when no such account exists. */
|
||||
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
|
||||
const { meta } = await db
|
||||
.prepare(
|
||||
"UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1"
|
||||
)
|
||||
.prepare("UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1")
|
||||
.bind(id, hash)
|
||||
.run()
|
||||
return meta.changes > 0
|
||||
|
||||
@@ -7,5 +7,4 @@ export * from './rooms-db'
|
||||
export * from './room-instance-db'
|
||||
export * from './presence-db'
|
||||
export * from './gifts-db'
|
||||
export * from './outfits-db'
|
||||
export * from './relationships-db'
|
||||
|
||||
@@ -152,6 +152,28 @@ export async function countPlayersInInstance(
|
||||
return row?.n ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Live head-count per ROOM, keyed by room id — the players standing in any of a
|
||||
* room's instances right now. One grouped query rather than a count per room, so
|
||||
* feeds that rank by "who's playing" (the hot feed) stay a single read. Counts
|
||||
* only unexpired presence; rooms nobody is in are simply absent from the map, and
|
||||
* lobby (null-instance) presence is excluded.
|
||||
*/
|
||||
export async function countPlayersByRoom(
|
||||
db: D1Database,
|
||||
now = nowSeconds()
|
||||
): Promise<Map<number, number>> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT room_id AS roomId, COUNT(*) AS n FROM presence
|
||||
WHERE expires_at > ?1 AND room_instance_id IS NOT NULL AND room_id IS NOT NULL
|
||||
GROUP BY room_id`
|
||||
)
|
||||
.bind(now)
|
||||
.all<{ roomId: number; n: number }>()
|
||||
return new Map(results.map((r) => [r.roomId, r.n]))
|
||||
}
|
||||
|
||||
/**
|
||||
* The room instances that expired presence rows still point at — the instances a
|
||||
* player was in when they stopped heartbeating (a crash or a hard quit, where no
|
||||
|
||||
+328
-29
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { Accessibility, Role } from './enums'
|
||||
import { countPlayersByRoom } from './presence-db'
|
||||
|
||||
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
|
||||
export const ROOM_SCHEMA_DDL: string[] = [
|
||||
@@ -82,6 +83,26 @@ export const SUBROOM_SCHEMA_DDL: string[] = [
|
||||
data TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`,
|
||||
// Per-subroom permission overrides (migrations/0009_subroom_permissions.sql). The room
|
||||
// owner's permission table for one subroom, keyed by (permission, role) — that pair is
|
||||
// what the client's PUT addresses, and re-sending it overwrites the stored row rather
|
||||
// than appending a second one.
|
||||
//
|
||||
// A row IS an override, which is why the client's `Override` flag is not a column: it's
|
||||
// the checkbox next to the permission, so clearing it deletes the row and the pair falls
|
||||
// back to its default. `value` is the client's string, stored verbatim.
|
||||
//
|
||||
// Deliberately NOT in the subroom's `data` blob: that blob is served to the client
|
||||
// verbatim as part of the room, and these overrides are read on one path only
|
||||
// (`GET /photon_access_token`, where they overwrite the matching default entries).
|
||||
`CREATE TABLE IF NOT EXISTS subroom_permission (
|
||||
sub_room_id INTEGER NOT NULL,
|
||||
permission TEXT NOT NULL,
|
||||
role INTEGER NOT NULL,
|
||||
type INTEGER NOT NULL DEFAULT 0,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (sub_room_id, permission, role)
|
||||
)`,
|
||||
]
|
||||
|
||||
/** A stored room — the parsed JSON blob (full client-facing room response). */
|
||||
@@ -157,6 +178,9 @@ export async function cloneRoom(
|
||||
// client renders a virtual "RRO" tag on the clone.
|
||||
IsRRO: false,
|
||||
Roles: roles,
|
||||
// A fresh room has no engagement of its own — don't inherit the source's counters
|
||||
// (the derived ones are recomputed per read, but the clone is returned as-is here).
|
||||
Stats: storedStats(source.Stats),
|
||||
CreatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
@@ -695,6 +719,9 @@ export async function deleteSubRoom(
|
||||
// The saves go with it — nothing can reference them once the subroom is gone.
|
||||
// The blobs they point at are left in R2, like a deleted room's images.
|
||||
db.prepare('DELETE FROM subroom_save WHERE sub_room_id = ?1').bind(subRoomId),
|
||||
// So do its permission overrides — subroom ids are minted from one global
|
||||
// sequence, but leaving orphans would still be dead rows nothing can reach.
|
||||
db.prepare('DELETE FROM subroom_permission WHERE sub_room_id = ?1').bind(subRoomId),
|
||||
])
|
||||
|
||||
const room = await getRoomById(db, roomId)
|
||||
@@ -760,11 +787,13 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
|
||||
|
||||
/**
|
||||
* Serialize a room for a full-blob write, dropping any hydrated `SubRooms` so it never
|
||||
* gets denormalized back into the room JSON (subrooms are the `subroom` table's job).
|
||||
* gets denormalized back into the room JSON (subrooms are the `subroom` table's job) and
|
||||
* zeroing the derived engagement counters (those are the `interaction` table's job — see
|
||||
* {@link attachStats}), so a write can't bake a snapshot of them into the blob.
|
||||
*/
|
||||
const serializeRoom = (room: Room): string => {
|
||||
const { SubRooms: _subRooms, ...rest } = room
|
||||
return JSON.stringify(rest)
|
||||
const { SubRooms: _subRooms, Stats: stats, ...rest } = room
|
||||
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -831,15 +860,108 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
|
||||
for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? []
|
||||
}
|
||||
|
||||
/** Hydrate a single room's `SubRooms` (no-op for null). */
|
||||
// ---- Room stats -----------------------------------------------------------
|
||||
// A room's cheer/favorite counters are DERIVED from the `interaction` table rather than
|
||||
// stored: they're recomputed on every read, so a cheer shows up immediately and the
|
||||
// counts can't drift from the per-player rows they're made of. The blob keeps them at 0
|
||||
// (see {@link serializeRoom}). `VisitorCount`/`VisitCount` are left as the blob has them
|
||||
// — nothing records a visit yet, and `interaction.last_visited_at` is only stamped by the
|
||||
// cheer/favorite toggles, so counting those rows would report cheerers as visitors.
|
||||
|
||||
/** One room's derived engagement counters (the aggregate maps below key these by RoomId). */
|
||||
export interface RoomStats {
|
||||
CheerCount: number
|
||||
FavoriteCount: number
|
||||
}
|
||||
|
||||
interface RoomStatsRow {
|
||||
room_id: number
|
||||
cheers: number
|
||||
favorites: number
|
||||
}
|
||||
|
||||
/** The counters a room starts life with (and the shape the client expects). */
|
||||
const ZERO_STATS = { CheerCount: 0, FavoriteCount: 0, VisitorCount: 0, VisitCount: 0 }
|
||||
|
||||
/** D1 caps a query at 100 bound parameters, and a feed page can carry more ids than that. */
|
||||
const STATS_ID_LIMIT = 90
|
||||
|
||||
/** A room's RoomId, or 0 for a blob without one. */
|
||||
const roomIdOf = (room: Room): number => (typeof room.RoomId === 'number' ? room.RoomId : 0)
|
||||
|
||||
/**
|
||||
* The `Stats` object to persist: whatever the room carried, with the derived counters
|
||||
* back at 0 so the blob never holds a stale copy of them.
|
||||
*/
|
||||
function storedStats(stats: unknown): Record<string, unknown> {
|
||||
const stored =
|
||||
typeof stats === 'object' && stats !== null ? (stats as Record<string, unknown>) : {}
|
||||
return { ...ZERO_STATS, ...stored, CheerCount: 0, FavoriteCount: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheer/favorite counts per room, aggregated from `interaction` in ONE grouped query.
|
||||
* Restricted to `roomIds` when given (a feed page), otherwise covering every room —
|
||||
* which is also what a page too large to bind gets, since scanning the whole table is
|
||||
* cheaper than splitting the query. Rooms nobody has interacted with are absent.
|
||||
*/
|
||||
export async function getRoomStats(
|
||||
db: D1Database,
|
||||
roomIds?: number[]
|
||||
): Promise<Map<number, RoomStats>> {
|
||||
const byRoom = new Map<number, RoomStats>()
|
||||
if (roomIds && roomIds.length === 0) return byRoom
|
||||
const ids = roomIds && roomIds.length <= STATS_ID_LIMIT ? roomIds : []
|
||||
const where =
|
||||
ids.length > 0 ? `WHERE room_id IN (${ids.map((_, i) => `?${i + 1}`).join(',')})` : ''
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT room_id, SUM(cheered) AS cheers, SUM(favorited) AS favorites
|
||||
FROM interaction ${where} GROUP BY room_id`
|
||||
)
|
||||
.bind(...ids)
|
||||
.all<RoomStatsRow>()
|
||||
for (const r of results) {
|
||||
byRoom.set(r.room_id, { CheerCount: r.cheers ?? 0, FavoriteCount: r.favorites ?? 0 })
|
||||
}
|
||||
return byRoom
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite each room's derived counters from the interaction table, in one query for
|
||||
* the whole batch. Callers that already aggregated (the feeds rank by these counts, so
|
||||
* they need them before paging) pass their map in rather than paying for a second query.
|
||||
*/
|
||||
async function attachStats(
|
||||
db: D1Database,
|
||||
rooms: Room[],
|
||||
stats?: Map<number, RoomStats>
|
||||
): Promise<void> {
|
||||
if (rooms.length === 0) return
|
||||
const byRoom = stats ?? (await getRoomStats(db, [...new Set(rooms.map(roomIdOf))]))
|
||||
for (const room of rooms) {
|
||||
const counts = byRoom.get(roomIdOf(room))
|
||||
room.Stats = {
|
||||
...storedStats(room.Stats),
|
||||
CheerCount: counts?.CheerCount ?? 0,
|
||||
FavoriteCount: counts?.FavoriteCount ?? 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hydrate a single room's `SubRooms` and derived `Stats` (no-op for null). */
|
||||
async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | null> {
|
||||
if (room) await attachSubRooms(db, [room])
|
||||
if (room) await hydrateRooms(db, [room])
|
||||
return room
|
||||
}
|
||||
|
||||
/** Hydrate many rooms' `SubRooms` in one batched query. */
|
||||
async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
|
||||
await attachSubRooms(db, rooms)
|
||||
/** Hydrate many rooms' `SubRooms` and derived `Stats` (one batched query each). */
|
||||
async function hydrateRooms(
|
||||
db: D1Database,
|
||||
rooms: Room[],
|
||||
stats?: Map<number, RoomStats>
|
||||
): Promise<Room[]> {
|
||||
await Promise.all([attachSubRooms(db, rooms), attachStats(db, rooms, stats)])
|
||||
return rooms
|
||||
}
|
||||
|
||||
@@ -942,6 +1064,119 @@ export async function getSubRoomSaveById(
|
||||
return row ? parseSubRoomSaveRow(row) : null
|
||||
}
|
||||
|
||||
// ---- Subroom permissions --------------------------------------------------
|
||||
|
||||
/**
|
||||
* One entry of a subroom's permission table, in the client's own shape. `Value` is a
|
||||
* STRING, not a boolean — usually `"True"`/`"False"`, but a permission whose UI isn't a
|
||||
* True/False picker carries something else, so it is stored and served verbatim. `Role`
|
||||
* is the tier the entry applies to (0 = everyone, 30 = co-owner, …). `Permission` + `Role`
|
||||
* identify an entry: the client PUTs the pair it wants changed, and the same pair
|
||||
* overwrites the matching default in the photon access token's table.
|
||||
*
|
||||
* `Override` is the row's own existence, not data: the client's UI is a checkbox ("is
|
||||
* this permission overridden in this subroom?") plus a True/False picker for the value.
|
||||
* Unchecking it means "fall back to the default", so an entry arriving with
|
||||
* `Override: false` DELETES the stored row rather than storing anything. Every stored
|
||||
* entry is therefore an override, and reads always serve `Override: true`.
|
||||
*/
|
||||
export interface RoomPermission {
|
||||
Permission: string
|
||||
Role: number
|
||||
Override: boolean
|
||||
Type: number
|
||||
Value: string
|
||||
}
|
||||
|
||||
interface RoomPermissionRow {
|
||||
permission: string
|
||||
role: number
|
||||
type: number
|
||||
value: string
|
||||
}
|
||||
|
||||
const toRoomPermission = (row: RoomPermissionRow): RoomPermission => ({
|
||||
// A stored row IS the override — the table holds nothing else (see RoomPermission).
|
||||
Override: true,
|
||||
Permission: row.permission,
|
||||
Role: row.role,
|
||||
Type: row.type,
|
||||
Value: row.value,
|
||||
})
|
||||
|
||||
/** The permission columns, in the order the read/copy statements use. */
|
||||
const PERMISSION_COLUMNS = 'permission, role, type, value'
|
||||
|
||||
/**
|
||||
* A subroom's stored permission overrides, in the order they were first set. Empty for a
|
||||
* subroom whose owner has never overridden a permission — the photon access token then
|
||||
* serves its defaults untouched.
|
||||
*/
|
||||
export async function getSubRoomPermissions(
|
||||
db: D1Database,
|
||||
subRoomId: number
|
||||
): Promise<RoomPermission[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1 ORDER BY rowid`
|
||||
)
|
||||
.bind(subRoomId)
|
||||
.all<RoomPermissionRow>()
|
||||
return results.map(toRoomPermission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply permission changes to a subroom, keyed by (`Permission`, `Role`). Only the pairs
|
||||
* supplied are touched; every other stored entry is left alone.
|
||||
*
|
||||
* `Override` decides which way an entry goes, mirroring the checkbox the client draws
|
||||
* next to each permission: true STORES the `Value` for that pair (overwriting whatever
|
||||
* was there), false CLEARS it, so the pair falls back to the photon access token's
|
||||
* default. Clearing a pair that was never overridden is a no-op.
|
||||
*/
|
||||
export async function setSubRoomPermissions(
|
||||
db: D1Database,
|
||||
subRoomId: number,
|
||||
permissions: RoomPermission[]
|
||||
): Promise<void> {
|
||||
if (permissions.length === 0) return
|
||||
const upsert = db.prepare(
|
||||
`INSERT INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS})
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT (sub_room_id, permission, role)
|
||||
DO UPDATE SET type = excluded.type, value = excluded.value`
|
||||
)
|
||||
const clear = db.prepare(
|
||||
'DELETE FROM subroom_permission WHERE sub_room_id = ?1 AND permission = ?2 AND role = ?3'
|
||||
)
|
||||
await db.batch(
|
||||
permissions.map((p) =>
|
||||
p.Override
|
||||
? upsert.bind(subRoomId, p.Permission, p.Role, p.Type, p.Value)
|
||||
: clear.bind(subRoomId, p.Permission, p.Role)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a subroom's permission overrides onto another subroom — a clone inherits the
|
||||
* source's permission table along with its scene and settings. Replaces any entry the
|
||||
* destination already holds for the same (permission, role).
|
||||
*/
|
||||
async function copySubRoomPermissions(
|
||||
db: D1Database,
|
||||
fromSubRoomId: number,
|
||||
toSubRoomId: number
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS})
|
||||
SELECT ?2, ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1`
|
||||
)
|
||||
.bind(fromSubRoomId, toSubRoomId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a subroom for a room, minting a fresh globally-unique SubRoomId from the
|
||||
* table's autoincrement sequence. Returns the created subroom (with its new id).
|
||||
@@ -963,6 +1198,12 @@ export async function insertSubRoom(
|
||||
CurrentSave: null,
|
||||
StagedSubRoomDataSaveId: null,
|
||||
}
|
||||
// The permission overrides follow the copy too — they live in their own table (keyed by
|
||||
// the id the caller is cloning FROM), so unlike the rest of the settings they aren't
|
||||
// carried by the blob. A fresh subroom (`createSubRoom`) passes no id and copies nothing.
|
||||
if (typeof sub.SubRoomId === 'number') {
|
||||
await copySubRoomPermissions(db, sub.SubRoomId, subRoomId)
|
||||
}
|
||||
// A copied subroom (room clone, subroom clone) carries the source's save. It gets its
|
||||
// OWN row — a save belongs to exactly one subroom, so sharing the source's id would
|
||||
// make the copy's content follow the source's future saves.
|
||||
@@ -1037,12 +1278,18 @@ export async function deleteRoom(db: D1Database, roomId: number): Promise<void>
|
||||
await db.batch([
|
||||
db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId),
|
||||
db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId),
|
||||
// Saves first — they're keyed by subroom, so they'd be unreachable afterwards.
|
||||
// Saves and permission overrides first — both are keyed by subroom, so they'd be
|
||||
// unreachable once the subrooms themselves are gone.
|
||||
db
|
||||
.prepare(
|
||||
'DELETE FROM subroom_save WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
|
||||
)
|
||||
.bind(roomId),
|
||||
db
|
||||
.prepare(
|
||||
'DELETE FROM subroom_permission WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)'
|
||||
)
|
||||
.bind(roomId),
|
||||
db.prepare('DELETE FROM subroom WHERE room_id = ?1').bind(roomId),
|
||||
])
|
||||
}
|
||||
@@ -1324,20 +1571,51 @@ export async function searchRooms(
|
||||
}
|
||||
}
|
||||
|
||||
/** Engagement score used to order the hot feed (cheers weigh most, then favorites). */
|
||||
function hotScore(room: Room): number {
|
||||
const stats = room.Stats as Record<string, unknown> | null | undefined
|
||||
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
||||
return n(stats?.CheerCount) * 3 + n(stats?.FavoriteCount) * 2 + n(stats?.VisitorCount)
|
||||
/**
|
||||
* Engagement score used to order the hot feed (cheers weigh most, then favorites).
|
||||
* Cheers/favorites come from the caller's aggregated {@link getRoomStats} map — ranking
|
||||
* happens before hydration, so the room blob's copies are still zero at this point.
|
||||
*/
|
||||
function hotScore(room: Room, stats: Map<number, RoomStats>): number {
|
||||
const counts = stats.get(roomIdOf(room))
|
||||
const stored = room.Stats as Record<string, unknown> | null | undefined
|
||||
const visitors = typeof stored?.VisitorCount === 'number' ? stored.VisitorCount : 0
|
||||
return (counts?.CheerCount ?? 0) * 3 + (counts?.FavoriteCount ?? 0) * 2 + visitors
|
||||
}
|
||||
|
||||
/**
|
||||
* The browse screen's "New" chip posts `tag=new` to the hot feed, but `new` is a
|
||||
* PSEUDO-tag: no room carries it. It means "recently created by a player", so it
|
||||
* selects the non-RRO rooms and orders them newest-first instead of by population.
|
||||
*/
|
||||
const NEW_TAG = 'new'
|
||||
|
||||
/**
|
||||
* True if the room is a Rec Room Original. `IsRRO` is the flag the client renders a
|
||||
* virtual "RRO" tag from; the auto-derived `rro` tag is checked too so a room that only
|
||||
* carries the tag isn't mistaken for player-made.
|
||||
*/
|
||||
function isRRO(room: Room): boolean {
|
||||
return room.IsRRO === true || roomHasAnyTag(room, new Set(['rro']))
|
||||
}
|
||||
|
||||
/** A room's CreatedAt as epoch millis; 0 (i.e. oldest) when it's missing or unparseable. */
|
||||
function createdAt(room: Room): number {
|
||||
const ts = typeof room.CreatedAt === 'string' ? Date.parse(room.CreatedAt) : NaN
|
||||
return Number.isNaN(ts) ? 0 : ts
|
||||
}
|
||||
|
||||
/**
|
||||
* The "hot" rooms feed: public, non-dorm rooms not excluded from lists, ordered
|
||||
* by engagement and optionally filtered to a single `tag` (with the same aliases
|
||||
* as search). Paginated via skip/take; returns `{ Results, TotalResults }` like
|
||||
* search. Ties (and the all-zero seed data) fall back to RoomId order so paging
|
||||
* is stable. The dataset is small, so this filters/sorts in memory rather than
|
||||
* in SQL.
|
||||
* by how many players are in them RIGHT NOW (live presence summed across the
|
||||
* room's instances), and optionally filtered to a single `tag` (with the same
|
||||
* aliases as search). "Hot" is a live-population feed, so current players lead;
|
||||
* rooms nobody is in — and the all-zero seed data — fall back to the stored
|
||||
* engagement score, then to RoomId order so paging stays stable. Paginated via
|
||||
* skip/take; returns `{ Results, TotalResults }` like search. The dataset is
|
||||
* small, so this filters/sorts in memory rather than in SQL.
|
||||
*
|
||||
* `tag=new` is the one filter that isn't a tag lookup — see {@link NEW_TAG}.
|
||||
*/
|
||||
export async function getHotRooms(
|
||||
db: D1Database,
|
||||
@@ -1351,15 +1629,34 @@ export async function getHotRooms(
|
||||
)
|
||||
|
||||
const t = tag.trim().toLowerCase()
|
||||
if (t === NEW_TAG) {
|
||||
// Newest player-made rooms first; RoomId (which is minted in creation order)
|
||||
// breaks ties so rooms created in the same instant still page stably.
|
||||
const fresh = rooms
|
||||
.filter((r) => !isRRO(r))
|
||||
.sort((a, b) => createdAt(b) - createdAt(a) || roomIdOf(b) - roomIdOf(a))
|
||||
return {
|
||||
Results: await hydrateRooms(db, fresh.slice(skip, skip + take)),
|
||||
TotalResults: fresh.length,
|
||||
}
|
||||
}
|
||||
|
||||
if (t !== '') {
|
||||
const accepted = new Set([t, ...(TAG_ALIASES[t] ?? [])])
|
||||
rooms = rooms.filter((r) => roomHasAnyTag(r, accepted))
|
||||
}
|
||||
|
||||
const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
||||
rooms.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b))
|
||||
const players = await countPlayersByRoom(db)
|
||||
const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
|
||||
const stats = await getRoomStats(db)
|
||||
rooms.sort(
|
||||
(a, b) =>
|
||||
playerCount(b) - playerCount(a) ||
|
||||
hotScore(b, stats) - hotScore(a, stats) ||
|
||||
roomIdOf(a) - roomIdOf(b)
|
||||
)
|
||||
return {
|
||||
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
|
||||
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
|
||||
TotalResults: rooms.length,
|
||||
}
|
||||
}
|
||||
@@ -1378,13 +1675,14 @@ export async function getRecommendedRooms(
|
||||
take: number
|
||||
): Promise<Room[]> {
|
||||
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
||||
const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
||||
const stats = await getRoomStats(db)
|
||||
return hydrateRooms(
|
||||
db,
|
||||
parseAll(results)
|
||||
.filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true)
|
||||
.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b))
|
||||
.slice(skip, skip + take)
|
||||
.sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b))
|
||||
.slice(skip, skip + take),
|
||||
stats
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1463,7 +1761,7 @@ export async function getSimilarRooms(
|
||||
|
||||
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
||||
const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
|
||||
const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
||||
const stats = await getRoomStats(db)
|
||||
|
||||
const scored = parseAll(results)
|
||||
.filter(
|
||||
@@ -1479,12 +1777,12 @@ export async function getSimilarRooms(
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
b.shared - a.shared ||
|
||||
hotScore(b.room) - hotScore(a.room) ||
|
||||
hotScore(b.room, stats) - hotScore(a.room, stats) ||
|
||||
roomIdOf(a.room) - roomIdOf(b.room)
|
||||
)
|
||||
const rooms = scored.map((x) => x.room)
|
||||
return {
|
||||
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
|
||||
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
|
||||
TotalResults: rooms.length,
|
||||
}
|
||||
}
|
||||
@@ -1499,7 +1797,6 @@ export async function getSimilarRooms(
|
||||
export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise<Room[]> {
|
||||
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
||||
const base = new Set(['base'])
|
||||
const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
||||
return hydrateRooms(
|
||||
db,
|
||||
parseAll(results)
|
||||
@@ -1574,6 +1871,8 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
|
||||
Roles: [
|
||||
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
],
|
||||
// Counters start at zero rather than inheriting the template dorm's (see cloneRoom).
|
||||
Stats: storedStats(template?.Stats),
|
||||
CreatedAt: new Date().toISOString(),
|
||||
}
|
||||
// serializeRoom drops any SubRooms carried over from the template; the dorm's own
|
||||
|
||||
@@ -2,7 +2,5 @@ export {
|
||||
validateAndGetAccountId,
|
||||
validateAndGetRoles,
|
||||
generateToken,
|
||||
generatePhotonAuthToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
} from './jwt'
|
||||
export type { PhotonAuthClaims } from './jwt'
|
||||
|
||||
@@ -105,55 +105,6 @@ const TOKEN_SCOPES = [
|
||||
*/
|
||||
const BASE_ROLES = ['gameClient']
|
||||
|
||||
/**
|
||||
* The claims the Photon auth token carries beyond `sub`/`exp`/`aud`, describing who
|
||||
* (and on what) is connecting. All of them go on the wire as STRINGS, including the
|
||||
* numeric ones — that's how the real token encodes them.
|
||||
*/
|
||||
export interface PhotonAuthClaims {
|
||||
/** The platform-native id (e.g. a SteamID64) — `rn.platid`. */
|
||||
platformId: string
|
||||
/** PlatformType int (0 = Steam) — `rn.plat`. */
|
||||
platform: number
|
||||
/** DeviceClass int (2 = PC/standalone) — `rn.deviceclass`. */
|
||||
deviceClass: number
|
||||
/** The Photon application the token is for — the `aud` claim. */
|
||||
audience: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the short-lived HS256 token the client hands to Photon as its custom auth
|
||||
* credential (`photonAuthToken` on `GET /player/connection-info`). The claim set
|
||||
* mirrors the real one — `sub`, `rn.platid`, `rn.plat`, `rn.deviceclass`, `rn.env`,
|
||||
* `exp`, `aud` — rather than being a second copy of the login token: it identifies
|
||||
* the connecting player to the realtime server and nothing else, so none of the
|
||||
* scopes or roles from {@link generateToken} belong on it.
|
||||
*
|
||||
* Signed with the same shared `JWT_SECRET` as every other token here. A real Photon
|
||||
* Cloud application would verify this against a secret configured in its dashboard;
|
||||
* self-hosted, nothing verifies it yet — so treat it as identifying, not authorizing.
|
||||
* `rn.env` is `prod` because that's what the client is built against, regardless of
|
||||
* which environment this worker is running in.
|
||||
*/
|
||||
export async function generatePhotonAuthToken(
|
||||
accountId: number,
|
||||
claims: PhotonAuthClaims,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
return sign(
|
||||
{
|
||||
sub: String(accountId),
|
||||
'rn.platid': claims.platformId,
|
||||
'rn.plat': String(claims.platform),
|
||||
'rn.deviceclass': String(claims.deviceClass),
|
||||
'rn.env': 'prod',
|
||||
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
|
||||
aud: claims.audience,
|
||||
},
|
||||
secret
|
||||
)
|
||||
}
|
||||
|
||||
export async function generateToken(
|
||||
accountId: string,
|
||||
platformId: string,
|
||||
|
||||
@@ -16,14 +16,7 @@ recflare_load_env
|
||||
# custom domain via `--domain`. This keeps the real domain out of versioned files
|
||||
# — committed wrangler.jsonc has no routes, and the base domain is passed as the
|
||||
# DOMAIN var at runtime. Per-app subdomain overrides come from RECFLARE_SUBDOMAINS
|
||||
# (a JSON object keyed by default subdomain, e.g. {"playersettings":"settings"}).
|
||||
#
|
||||
# The whole object also rides along as the SUBDOMAINS var, because the `ns` worker
|
||||
# has to advertise the same hosts to the client that we deploy onto here. Keying it
|
||||
# by default subdomain is what lets one .env entry do both: a worker's directory
|
||||
# name IS its default subdomain, so the lookup below and the one in ns/endpoints.ts
|
||||
# read the same key. Entries for services with no worker (e.g. "moderation") are
|
||||
# client-side redirects only — nothing here matches them.
|
||||
# (a JSON object, e.g. {"playersettings":"settings"}).
|
||||
if [ -z "${RECFLARE_DOMAIN:-}" ]; then
|
||||
echo "error: RECFLARE_DOMAIN is not set — export it or add it to .env (see .env.example)" >&2
|
||||
exit 1
|
||||
@@ -55,16 +48,61 @@ CONFIG="wrangler.jsonc"
|
||||
# (main: index.js), the resolved assets.directory, and no_bundle. The committed
|
||||
# wrangler.jsonc leaves assets.directory out on purpose — the plugin fills it in —
|
||||
# so deploying the source config fails with "assets ... missing the required
|
||||
# directory property". Prefer the generated config when it exists. These workers
|
||||
# have no D1/KV/Secrets bindings, so the id-splicing below is skipped.
|
||||
# directory property". Prefer the generated config when it exists.
|
||||
#
|
||||
# The plugin copies the bindings across verbatim, placeholders and all, so these
|
||||
# configs need the same id-splicing as the rest — www binds the Secrets Store for its
|
||||
# Turnstile keys. It gets its own branch below because the emitted file is minified
|
||||
# single-line JSON, which the line-oriented sed/awk passes can't edit correctly.
|
||||
VITE_CONFIG="dist/$DIR/wrangler.json"
|
||||
IS_VITE=""
|
||||
if [ -f "$VITE_CONFIG" ]; then
|
||||
CONFIG="$VITE_CONFIG"
|
||||
IS_VITE=1
|
||||
fi
|
||||
|
||||
NEEDS_D1=$(grep -q '"database_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
|
||||
NEEDS_KV=$(grep -q '"id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
|
||||
NEEDS_STORE=$(grep -q '"store_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
|
||||
NEEDS_D1=$(grep -q '"database_id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true)
|
||||
NEEDS_KV=$(grep -q '"id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true)
|
||||
NEEDS_STORE=$(grep -q '"store_id": *"local"' "$CONFIG" 2>/dev/null && echo 1 || true)
|
||||
|
||||
# Vite-built config: plain JSON, so jq does the splicing structurally (by binding
|
||||
# name for KV) rather than by line. Written beside the original so its relative
|
||||
# paths (main, assets.directory) still resolve. Gitignored; removed on exit.
|
||||
if [ "$CONFIG" = "$VITE_CONFIG" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then
|
||||
GENERATED="dist/$DIR/wrangler.generated.json"
|
||||
trap 'rm -f "$GENERATED"' EXIT
|
||||
|
||||
if [ -n "$NEEDS_D1" ] && [ -z "${RECFLARE_D1:-}" ]; then
|
||||
echo "error: RECFLARE_D1 is not set — add the recflare D1 id to .env (see .env.example)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$NEEDS_STORE" ] && [ -z "${RECFLARE_SECRETS_STORE:-}" ]; then
|
||||
echo "error: RECFLARE_SECRETS_STORE is not set — add the secrets store id to .env (see .env.example)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KV_JSON=${RECFLARE_KV:-}
|
||||
[ -n "$KV_JSON" ] || KV_JSON='{}'
|
||||
|
||||
jq \
|
||||
--arg db "${RECFLARE_D1:-}" \
|
||||
--arg store "${RECFLARE_SECRETS_STORE:-}" \
|
||||
--argjson kv "$KV_JSON" '
|
||||
(.d1_databases // []) |= map(
|
||||
if .database_id == "local" then .database_id = $db else . end
|
||||
)
|
||||
| (.kv_namespaces // []) |= map(
|
||||
if .id == "local" then
|
||||
.id = ($kv[.binding] //
|
||||
error("no KV id for binding [" + .binding + "] in RECFLARE_KV — add it to .env (see .env.example)"))
|
||||
else . end
|
||||
)
|
||||
| (.secrets_store_secrets // []) |= map(
|
||||
if .store_id == "local" then .store_id = $store else . end
|
||||
)
|
||||
' "$VITE_CONFIG" >"$GENERATED" || exit 1
|
||||
CONFIG="$GENERATED"
|
||||
fi
|
||||
|
||||
if [ "$CONFIG" = "wrangler.jsonc" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then
|
||||
CONFIG="wrangler.generated.jsonc"
|
||||
@@ -138,8 +176,10 @@ EXTRA_VARS=$(recflare_vars)
|
||||
|
||||
# Vite-built configs set no_bundle (vite already bundled and minified), which is
|
||||
# incompatible with --minify. Only pass --minify when wrangler does the bundling.
|
||||
# Keyed on IS_VITE, not on $CONFIG: the splicing above may have swapped $CONFIG for
|
||||
# the generated copy, which is just as no_bundle as the file it came from.
|
||||
MINIFY="--minify"
|
||||
[ "$CONFIG" = "$VITE_CONFIG" ] && MINIFY=""
|
||||
[ -n "$IS_VITE" ] && MINIFY=""
|
||||
|
||||
# Deploy with wrangler using the extracted values as binding variables
|
||||
echo "Deploying worker $NAME version $VERSION to $HOST"
|
||||
@@ -150,7 +190,6 @@ wrangler deploy \
|
||||
--var NAME:"$NAME" \
|
||||
--var SENTRY_RELEASE:"$VERSION" \
|
||||
--var DOMAIN:"$DOMAIN" \
|
||||
--var SUBDOMAINS:"$SUBDOMAINS_JSON" \
|
||||
$EXTRA_VARS \
|
||||
--domain "$HOST" \
|
||||
$MINIFY \
|
||||
|
||||
Reference in New Issue
Block a user