mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
nonworking photon stuff
This commit is contained in:
@@ -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 matches GAME_VERSION. */
|
||||
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build is one we accept. */
|
||||
export const VersionCheck = z.object({
|
||||
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
|
||||
UpdateNotificationStage: z.int(),
|
||||
@@ -392,12 +392,14 @@ export const SubscriptionResponse = z.object({
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* 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 the empty string the reference sends.
|
||||
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
|
||||
* they carry their C# defaults (false / null).
|
||||
*/
|
||||
export const ModerationBlockDetails = z.object({
|
||||
ReportCategory: z.int().describe('-1 = no category (0 is a real one)'),
|
||||
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
|
||||
Duration: z.int(),
|
||||
GameSessionId: z.int(),
|
||||
IsBan: z.boolean(),
|
||||
|
||||
@@ -17,6 +17,13 @@ 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` is a later client that talks the same protocol,
|
||||
* so we let it through rather than telling it to update.
|
||||
*/
|
||||
const ACCEPTED_GAME_VERSIONS = new Set([GAME_VERSION, '20230616'])
|
||||
|
||||
// ---- Config / version ------------------------------------------------------
|
||||
export const configRoutes = new Hono<App>({ strict: false })
|
||||
.get(
|
||||
@@ -100,13 +107,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 ' +
|
||||
'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
|
||||
'client is on a different build.',
|
||||
'the builds we accept — our target `GAME_VERSION` plus `20230616`: ' +
|
||||
'`VersionStatus` is 0 for either, 1 for any other build.',
|
||||
responses: { 200: json(VersionCheck, 'Version status') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
|
||||
VersionStatus: ACCEPTED_GAME_VERSIONS.has(c.req.query('v') ?? '') ? 0 : 1,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
|
||||
@@ -14,21 +14,24 @@ 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. `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(
|
||||
// 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()` verbatim. `ReportCategory` is `Unknown` (-1),
|
||||
// not 0, which is a real category, and `Message` is the empty string that stub sends.
|
||||
// `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but left unset there, so they
|
||||
// go out with their C# defaults.
|
||||
// POST with no body — the client's actual call, despite this being a pure read.
|
||||
.post(
|
||||
'/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. 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.',
|
||||
'this is always the “not blocked” answer, matching the reference server’s stub: ' +
|
||||
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and ' +
|
||||
'`Message` is an empty string. `IsVoiceModAutoban` and `TimeoutStartedAt` are on ' +
|
||||
'the DTO but unset by that stub, so they carry their defaults.',
|
||||
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
|
||||
}),
|
||||
(c) =>
|
||||
@@ -39,7 +42,7 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
Message: '',
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
|
||||
@@ -144,6 +144,11 @@ 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 })
|
||||
@@ -228,12 +233,15 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
||||
test('POST /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
||||
// The client POSTs this with no body, despite it being a pure read.
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// ReportCategory -1 = no category (0 is a real one), and Message is null.
|
||||
// The reference server's stub verbatim: ReportCategory -1 = Unknown (0 is a real
|
||||
// category) and an empty-string Message.
|
||||
expect(await res.json()).toEqual({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
@@ -241,7 +249,7 @@ describe('public endpoints', () => {
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
Message: '',
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
@@ -1891,7 +1899,6 @@ describe('openapi', () => {
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /api/images/v1/deletesaved',
|
||||
'GET /api/PlayerReporting/v1/moderationBlockDetails',
|
||||
'GET /api/PlayerReporting/v1/voteToKickReasons',
|
||||
'GET /api/activities/charades/v1/words/{activity}',
|
||||
'GET /api/announcement/v1/get',
|
||||
@@ -1964,6 +1971,7 @@ describe('openapi', () => {
|
||||
'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/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "gay",
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 175,
|
||||
"CreatedAt": "2019-02-28T18:27:25Z"
|
||||
},
|
||||
@@ -23,7 +23,7 @@
|
||||
"Visibility": 0,
|
||||
"AllowCycling": true,
|
||||
"RestrictToNewUsers": false,
|
||||
"ImageName": "gay",
|
||||
"ImageName": "tip.jpg",
|
||||
"PlatformMask": 167,
|
||||
"CreatedAt": "2019-02-28T18:15:33Z"
|
||||
},
|
||||
|
||||
+149
-1
@@ -29,7 +29,7 @@ import {
|
||||
setRoomInstanceInProgress,
|
||||
} from '@repo/domain'
|
||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import { generatePhotonAuthToken, 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.
|
||||
@@ -37,6 +37,7 @@ import { NotificationType } from '../../notify/src/notification-types'
|
||||
|
||||
import {
|
||||
AUTHED,
|
||||
ConnectionInfoResponse,
|
||||
EMPTY_OK,
|
||||
ExclusiveLoginResponse,
|
||||
form,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
MatchmakeRoomRequest,
|
||||
NotifyDisconnectRequest,
|
||||
PlayerDto,
|
||||
QosRegion,
|
||||
RoomInstanceDto,
|
||||
StatusVisibilityRequest,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
@@ -85,6 +87,54 @@ 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
|
||||
@@ -1068,6 +1118,104 @@ 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,6 +135,65 @@ 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`,
|
||||
|
||||
@@ -417,6 +417,25 @@ 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)
|
||||
@@ -458,6 +477,99 @@ 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
|
||||
@@ -1280,6 +1392,8 @@ 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',
|
||||
|
||||
@@ -2,5 +2,7 @@ export {
|
||||
validateAndGetAccountId,
|
||||
validateAndGetRoles,
|
||||
generateToken,
|
||||
generatePhotonAuthToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
} from './jwt'
|
||||
export type { PhotonAuthClaims } from './jwt'
|
||||
|
||||
@@ -105,6 +105,55 @@ 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,
|
||||
|
||||
Reference in New Issue
Block a user