diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 8f1a275..7efda26 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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(), diff --git a/apps/api/src/routes/config.ts b/apps/api/src/routes/config.ts index 1aef822..12b2140 100644 --- a/apps/api/src/routes/config.ts +++ b/apps/api/src/routes/config.ts @@ -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({ strict: false }) .get( @@ -100,13 +107,13 @@ export const configRoutes = new Hono({ 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, diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 1756c80..31ab5f2 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -14,21 +14,24 @@ import type { App } from '../context' // ---- Player reporting ------------------------------------------------------ export const moderationRoutes = new Hono({ 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({ strict: false }) IsBan: false, IsHostKick: false, IsVoiceModAutoban: false, - Message: null, + Message: '', PlayerIdReporter: null, TimeoutStartedAt: null, }) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 28ecb07..fede1bc 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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', diff --git a/apps/cdn/static/loading-screen-tip-data.json b/apps/cdn/static/loading-screen-tip-data.json index f06050c..7fbc09c 100644 --- a/apps/cdn/static/loading-screen-tip-data.json +++ b/apps/cdn/static/loading-screen-tip-data.json @@ -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" }, diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 0070cb8..23fc778 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -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() } ) + // 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(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', diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index c0c51a5..1449a3b 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -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`, diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 1ae365a..b9828aa 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -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', diff --git a/packages/jwt/src/index.ts b/packages/jwt/src/index.ts index a1fc3dd..09cfac6 100644 --- a/packages/jwt/src/index.ts +++ b/packages/jwt/src/index.ts @@ -2,5 +2,7 @@ export { validateAndGetAccountId, validateAndGetRoles, generateToken, + generatePhotonAuthToken, TOKEN_TTL_SECONDS, } from './jwt' +export type { PhotonAuthClaims } from './jwt' diff --git a/packages/jwt/src/jwt.ts b/packages/jwt/src/jwt.ts index 6e5e2af..b63fd28 100644 --- a/packages/jwt/src/jwt.ts +++ b/packages/jwt/src/jwt.ts @@ -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 { + 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,