mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5010f62371 | |||
| b3f1d04823 | |||
| 55cb769de9 |
+3
-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.
|
||||
|
||||
+3
-10
@@ -75,16 +75,9 @@ 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.
|
||||
|
||||
**Create the storage resources:**
|
||||
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -2,12 +2,7 @@ import { adminSecretsStore, env } from 'cloudflare:test'
|
||||
import { exports } from 'cloudflare:workers'
|
||||
import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
GAME_VERSION,
|
||||
OUTFIT_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
import { GAME_VERSION, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL } from '@repo/domain'
|
||||
|
||||
import '../../api.app'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+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"
|
||||
}
|
||||
]
|
||||
+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`,
|
||||
|
||||
@@ -470,25 +470,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 +511,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 +564,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 +1333,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}',
|
||||
|
||||
+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)
|
||||
);
|
||||
@@ -479,6 +479,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 +572,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 +587,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),
|
||||
|
||||
+163
-29
@@ -25,6 +25,7 @@ import {
|
||||
getRoomsByCreator,
|
||||
getRoomsByIds,
|
||||
getSimilarRooms,
|
||||
getSubRoomPermissions,
|
||||
getSubRoomSaves,
|
||||
getVisitedRooms,
|
||||
modifySubRoom,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
setRoomImage,
|
||||
setRoomName,
|
||||
setRoomRole,
|
||||
setSubRoomPermissions,
|
||||
toggleCheer,
|
||||
toggleFavorite,
|
||||
toggleRoomTag,
|
||||
@@ -81,6 +83,7 @@ import {
|
||||
stringQuery,
|
||||
SubRoomAccessibilityRequest,
|
||||
subRoomIdParam,
|
||||
SubRoomPermissionsRequest,
|
||||
SubRoomSavesPage,
|
||||
TagRequest,
|
||||
UNAUTHORIZED_EMPTY,
|
||||
@@ -90,6 +93,7 @@ import {
|
||||
} from './openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { RoomPermission } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
@@ -131,9 +135,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 +152,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 +184,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 +208,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 +252,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'
|
||||
|
||||
@@ -1820,6 +1911,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 +2193,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 +2216,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.
|
||||
|
||||
@@ -1433,13 +1433,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 +1447,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 () => {
|
||||
@@ -1676,6 +1670,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`, {
|
||||
@@ -1944,7 +2186,6 @@ describe('rooms endpoints', () => {
|
||||
'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 +2204,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 })
|
||||
}
|
||||
|
||||
@@ -31,10 +31,6 @@ 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
|
||||
@@ -164,8 +160,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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -150,7 +143,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