nonworking photon stuff

This commit is contained in:
Devin Zuczek
2026-07-28 14:19:49 -04:00
parent d5e3d3946e
commit 3c18d827a8
10 changed files with 418 additions and 26 deletions
+7 -5
View File
@@ -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(),
+10 -3
View File
@@ -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 clients `?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,
+13 -10
View File
@@ -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 servers 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,
})
+13 -5
View File
@@ -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',
+2 -2
View File
@@ -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
View File
@@ -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',
'clients 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 Rooms 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',
+59
View File
@@ -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 instances `photonRegion`'),
photonRoomId: z.string().describe('The callers current instance; empty when theyre 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`,
+114
View File
@@ -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 callers 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
View File
@@ -2,5 +2,7 @@ export {
validateAndGetAccountId,
validateAndGetRoles,
generateToken,
generatePhotonAuthToken,
TOKEN_TTL_SECONDS,
} from './jwt'
export type { PhotonAuthClaims } from './jwt'
+49
View File
@@ -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,