support for 202507 endpoints (#37)

* [auth][api] accept the 20250424.01 client

* [2025] unstable

* 20250718.0

* correct one this time

* stubs

* more stubs

* more stubs

* [lists] add worker

* [ai] route stubs

* [api] player photo setting

* [econ] add roomEconConfig route

* [infra] update worker generators

* [worker] add cards/moderation/platformnotification workers

* [lists] updates to some endpoints

* [clubs] stub out announcement endpoint, for now

* [econ] stub out season endpoints for now

* [chat] apps/chat stub out party endpoint not sure the shape yet

* [api] stub out statsig and lockeditems

* [doc] new services

* [lists] stub the bulk endpoint

* [datacollection] add placeholder service until we can kill it

* [api] set gifting to lvl5

* update lock

* [cdn] enable cache

* [match] matchmake v2

* [lists] stub some lists

* [ai] stubs

* [rooms] new subroom save endpoint

* [econ] add bulk purchase endpoint

* [discovery] update featured creator to 1 for fun

* [api] add photo settings flag

* [chat] fixup chat permissions (sorta)

* [auth] restrictions endpoint

* [rooms] contributed endpoint

* [api] fix outfit endpoint

* [discovery] attempt to fix store

* [chat] privacy endpoints

* [api] cheered images

* [rooms] add xp endpoint (disbaled)

* [rooms] add xp endpoint (disabled)

* update images-db for cheers

* [rooms] add autocomplete endpoint

* [cdn/img] increase cache ttl for statics

* [api] bulk route for images

* [accounts] add banner image

* [api] add misc missing endpoints

* [discovery] remove AI tab

* [platformnotifications] stub some endpoints

* [lists] add some more lists

* [rooms] additional endpoints

* [chat] stub a few privacy endpoints

* [econ] stub some endpoints

* misc db fixes

* [api] tweak shape for images v6

* [rooms] dont show trending RROs
This commit is contained in:
devin
2026-08-18 23:07:24 -04:00
committed by Devin Zuczek
parent 66c09806f9
commit 178d3b5b0e
162 changed files with 114930 additions and 469 deletions
+6
View File
@@ -33,6 +33,10 @@ export interface Account {
username: string
displayName: string
profileImage: string
/** Profile banner image key, set by `accounts` `PUT /account/me/bannerimage`. `""` until then. */
bannerImage: string
/** The emoji shown beside the display name. No route sets it yet — always `""`. */
displayEmoji: string
isJunior: boolean
platforms: number
personalPronouns: number
@@ -164,6 +168,8 @@ 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,
+102 -9
View File
@@ -163,6 +163,9 @@ export async function setImageCheer(
await syncImageCheerCount(db, savedImageId)
}
/** How many image ids one cheer lookup may bind: D1's 100-parameter cap, less the player id. */
const CHEER_ID_LIMIT = 99
/**
* Which of the given saved-image ids the player has cheered — the set of cheered
* ids (a subset of `ids`). Backs the bulk `cheered` lookup. Empty input → empty set.
@@ -172,16 +175,23 @@ export async function getCheeredImageIds(
playerId: number,
ids: number[]
): Promise<Set<number>> {
if (ids.length === 0) return new Set()
const inList = ids.map((_, i) => `?${i + 2}`).join(',')
const { results } = await db
.prepare(
`SELECT saved_image_id AS id FROM image_interaction
const cheered = new Set<number>()
// D1 caps a query at 100 bound parameters and the player id takes one of them, so a
// photo grid asking about more images than that is split across queries rather than
// failing the whole read. The client really does send a page of ~100 at a time.
for (let i = 0; i < ids.length; i += CHEER_ID_LIMIT) {
const page = ids.slice(i, i + CHEER_ID_LIMIT)
const inList = page.map((_, n) => `?${n + 2}`).join(',')
const { results } = await db
.prepare(
`SELECT saved_image_id AS id FROM image_interaction
WHERE player_id = ?1 AND cheered = 1 AND saved_image_id IN (${inList})`
)
.bind(playerId, ...ids)
.all<{ id: number }>()
return new Set(results.map((r) => r.id))
)
.bind(playerId, ...page)
.all<{ id: number }>()
for (const row of results) cheered.add(row.id)
}
return cheered
}
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
@@ -214,6 +224,40 @@ export async function getSavedImagesByNames(
)
}
/** How many image ids one bulk lookup may bind: D1 caps a query at 100 parameters. */
const IMAGE_ID_LIMIT = 100
/**
* Look up image records by id — the bulk lookup behind `GET /api/images/v5/bulk`.
* Returned in REQUEST order, so the caller can line the answers up with what it asked
* for; an id with no record, or one that isn't public, is simply absent rather than a
* hole in the list.
*
* Public-only, like every other image read here ({@link getImagesByPlayer},
* {@link getImagesByRoom}). Image ids are sequential, so serving whatever an id names
* would make a private photo readable by anyone who counts.
*/
export async function getImagesByIds(db: D1Database, ids: number[]): Promise<SavedImage[]> {
if (ids.length === 0) return []
const found = new Map<number, SavedImage>()
// D1 caps a query at 100 bound parameters, and the client asks about a whole photo
// grid at once, so a large request is split rather than failing outright.
for (let i = 0; i < ids.length; i += IMAGE_ID_LIMIT) {
const page = ids.slice(i, i + IMAGE_ID_LIMIT)
const { results } = await db
.prepare(`SELECT data FROM image WHERE id IN (${placeholders(page.length)})`)
.bind(...page)
.all<ImageRow>()
for (const row of results) {
const image = JSON.parse(row.data) as SavedImage
if (image.Accessibility === 1) found.set(image.Id, image)
}
}
return ids.map((id) => found.get(id)).filter((image): image is SavedImage => image !== undefined)
}
/**
* A minimal SavedImage for an image name with no metadata row — enough for the client
* to render the picture. Uploads normally write a row first, so this only covers a
@@ -311,6 +355,55 @@ export async function getImagesByPlayer(
.slice(skip, skip + take)
}
/**
* An image's metadata as `GET /api/images/v6` serves it — the by-name lookup's shape.
*
* A THIRD projection of the same row, and deliberately not either of the other two: it
* renames like `ImagesPlayer` (`Id` → `SavedImageId`, `Type` → `SavedImageType`, no
* `TaggedPlayerIds`) but adds `ClubId`, and its numbers and strings are never null —
* `RoomId`, `PlayerEventId` and `ClubId` come out as 0 and `Description` as `""` where the
* row holds null. The reference's DTO declares them non-nullable, so a null is a decode
* failure rather than "none".
*
* `ClubId` is always 0: nothing here associates an image with a club.
*/
export interface ImageMetadata {
SavedImageId: number
ImageName: string
PlayerId: number
RoomId: number
PlayerEventId: number
ClubId: number
Description: string
Accessibility: number
AccessibilityLocked: boolean
SavedImageType: number
CreatedAt: string
CheerCount: number
CommentCount: number
}
/** Project a stored image into the {@link ImageMetadata} shape `/api/images/v6` answers. */
export function toImageMetadata(img: SavedImage): ImageMetadata {
return {
SavedImageId: img.Id,
ImageName: img.ImageName,
PlayerId: img.PlayerId,
// Null means "not taken in a room" / "no event"; the client's DTO has no null to put
// there, and 0 is the id it treats as none.
RoomId: img.RoomId ?? 0,
PlayerEventId: img.PlayerEventId ?? 0,
ClubId: 0,
Description: img.Description ?? '',
Accessibility: img.Accessibility,
AccessibilityLocked: img.AccessibilityLocked,
SavedImageType: img.Type,
CreatedAt: img.CreatedAt,
CheerCount: img.CheerCount,
CommentCount: img.CommentCount,
}
}
/**
* The client-facing projection of a saved image for the player photo lists (the
* reference's `ImagesPlayer`). Same data as the stored record, but the id and type
+1
View File
@@ -8,6 +8,7 @@ export * from './room-instance-db'
export * from './presence-db'
export * from './gifts-db'
export * from './inventory-invention-db'
export * from './outfits-db'
export * from './progression-db'
export * from './relationships-db'
export * from './validation'
+81
View File
@@ -0,0 +1,81 @@
/**
* Saved outfits on the shared `recflare` D1 database — the outfit slots a player
* saves from the avatar screen.
*
* 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,
* FaceFeatures, …) are themselves JSON-in-a-string produced by the client's own
* 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.
*/
/** Schema DDL (mirror of apps/econ/migrations/0002_outfit.sql) — also builds the table in tests. */
export const OUTFIT_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS outfit (
account_id INTEGER NOT NULL,
set_id INTEGER NOT NULL,
avatar TEXT NOT NULL,
PRIMARY KEY (account_id, set_id)
)`,
]
/**
* 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.
*/
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
.prepare('SELECT avatar FROM outfit WHERE account_id = ?1 ORDER BY set_id')
.bind(accountId)
.all<{ avatar: string }>()
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
* accumulating duplicate rows for it.
*/
export async function setOutfit(db: D1Database, accountId: number, outfit: Outfit): Promise<void> {
await db
.prepare(
`INSERT INTO outfit (account_id, set_id, avatar) VALUES (?1, ?2, ?3)
ON CONFLICT (account_id, set_id) DO UPDATE SET avatar = ?3`
)
.bind(accountId, outfit.Slot, JSON.stringify(outfit))
.run()
}
+23
View File
@@ -32,6 +32,29 @@ export const PRESENCE_TTL_SECONDS = 900
*/
export const GAME_VERSION = '20230414'
/**
* Client builds `/api/versioncheck/v4` answers "current" for. `GAME_VERSION` is the one
* the rest of the stack targets and reports for itself; the others are later clients
* that talk close enough to the same protocol to get past the update prompt.
*
* DEBUGGING ONLY beyond `GAME_VERSION`: this is not a supported-version list. Nothing
* else in the stack targets those builds, so a client waved through here can still hit
* protocol differences the version check would otherwise have caught. Trim it back to
* `GAME_VERSION` alone before anyone but us is playing.
*/
export const SUPPORTED_GAME_VERSIONS: string[] = [
GAME_VERSION,
'20230616',
'20231207',
'20250424.01',
'20250718.01',
]
/** Whether a client-supplied build (the version check's `?v=`) is one we serve. */
export function isSupportedGameVersion(version: string | null | undefined): boolean {
return version != null && SUPPORTED_GAME_VERSIONS.includes(version)
}
/** Schema DDL (mirror of migrations/0006_presence.sql). */
export const PRESENCE_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS presence (
+30
View File
@@ -199,6 +199,36 @@ export async function areFriends(
return row !== null
}
/**
* How many of a player's friends are online right now — the friend graph joined to live
* `presence`, which is the count the client's friends panel shows. `Friend` rows only,
* from either side of the pair, and only unexpired presence (rows outlive the player by
* up to the TTL, exactly as every other presence read treats them). Lobby (null-instance)
* presence counts: those friends are signed in, just not in a room.
*
* A friend's `statusVisibility` is deliberately NOT consulted — nothing else in the stack
* filters presence on it (the batch `/player` lookup reports it and lets the client
* decide), so hiding people here would disagree with the list the panel then renders.
*/
export async function countOnlineFriends(
db: D1Database,
playerId: number,
now = Math.floor(Date.now() / 1000)
): Promise<number> {
const row = await db
.prepare(
`SELECT COUNT(*) AS n FROM relationship r
JOIN presence p
ON p.account_id = CASE WHEN r.requester_id = ?1 THEN r.target_id ELSE r.requester_id END
WHERE r.relationship_type = ?2
AND (r.requester_id = ?1 OR r.target_id = ?1)
AND p.expires_at > ?3`
)
.bind(playerId, RelationshipType.Friend, now)
.first<{ n: number }>()
return row?.n ?? 0
}
/** How many mutual friends the mutual-friends lookup will return at most. */
export const MUTUAL_FRIENDS_LIMIT = 100
+123 -1
View File
@@ -312,6 +312,9 @@ export async function cloneRoom(
clonedSubRooms.push(await insertSubRoom(db, newRoomId, { ...sub, CreatorAccountId: accountId }))
}
cloned.SubRooms = clonedSubRooms
// Inherited from the (parsed) source in practice; defaulted here too so a clone is
// never the one room shape missing them.
attachRoomDtoDefaults(cloned)
return cloned
}
@@ -869,6 +872,24 @@ interface RoomRow {
*/
const ROOM_COLUMNS = 'data, visits'
/**
* Two keys on the client's room DTO that nothing here stores, defaulted on every read so
* the key is PRESENT rather than absent — the seed blobs and every room written since
* predate them, so they can't come from the data:
*
* - `BoostCount` — how many boosts the room is carrying. No boost feature exists here, so
* it is 0 for every room.
* - `CurrentSnapshotId` — the room's published snapshot. Nothing takes snapshots, so it is
* null, which is also what the reference serves for a room that has none.
*
* Defaulted rather than assigned, so a stored value wins if either is ever really written
* (a blob keeps whatever `serializeRoom` last put in it).
*/
function attachRoomDtoDefaults(room: Room): void {
room.BoostCount ??= 0
room.CurrentSnapshotId ??= null
}
/**
* Parse a room row: the stored blob with the counters the columns own folded back in.
* `visits` is a real column, so a room read straight from the DB carries the live count.
@@ -876,6 +897,7 @@ const ROOM_COLUMNS = 'data, visits'
const parseRow = (row: RoomRow): Room => {
const room = JSON.parse(row.data) as Room
room.Stats = { ...storedStats(room.Stats), VisitCount: row.visits ?? 0 }
attachRoomDtoDefaults(room)
return room
}
@@ -1476,7 +1498,14 @@ export async function getRoomByName(db: D1Database, name: string): Promise<Room
)
}
/** Look up multiple rooms by RoomId. */
/**
* Look up multiple rooms by RoomId.
*
* Every id is bound into one query, so the CALLER must keep the list within D1's cap of 100
* bound parameters — `/rooms/bulk` rejects a longer request with a 400 rather than have this
* split it, since a client asking about more than a hundred rooms at once is asking the
* wrong question.
*/
export async function getRoomsByIds(db: D1Database, ids: number[]): Promise<Room[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
@@ -1513,6 +1542,36 @@ export async function countRoomsByCreator(db: D1Database, accountId: number): Pr
return row?.n ?? 0
}
/**
* Rooms an account CONTRIBUTES to: the ones whose `Roles` name them (Host, Moderator or
* CoOwner), minus the ones they created themselves.
*
* The creator is excluded deliberately. A room's `Roles` carries its creator too, so
* without that filter this list would repeat everything `getRoomsByCreator` already
* serves — and the client shows "rooms you own" and "rooms you contribute to" as two
* separate lists. Every role tier counts here, unlike {@link canManageRoom}'s
* owner-or-co-owner gate: this is "somebody gave you a job in their room", not "you may
* administer it".
*
* Roles live inside the room blob rather than in a table of their own, so the match is a
* `json_each` over `$.Roles`. A room with no `Roles` key (or a null one) simply yields no
* rows there rather than erroring, so it drops out of the list.
*/
export async function getContributedRooms(db: D1Database, accountId: number): Promise<Room[]> {
const { results } = await db
.prepare(
`SELECT ${ROOM_COLUMNS} FROM room
WHERE creator_account_id IS NOT ?1
AND EXISTS (
SELECT 1 FROM json_each(room.data, '$.Roles') AS role
WHERE json_extract(role.value, '$.AccountId') = ?1
)`
)
.bind(accountId)
.all<RoomRow>()
return hydrateRooms(db, parseAll(results))
}
/**
* An account's public, non-dorm rooms — the publicly viewable "rooms owned by
* <player>" list (excludes private rooms, dorms, and list-excluded rooms).
@@ -1740,6 +1799,67 @@ export async function searchRooms(
}
}
/**
* Search suggestions for the box the player is typing in
* (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms.
*
* Everything suggested is something the follow-up `/rooms/search` will actually find,
* which is the whole point of the endpoint: a suggestion that returns nothing is worse
* than no suggestion. So the candidates are drawn from the two things that search matches
* — room NAMES for a plain term, and TAGS for a `#tag` term — over the same public,
* non-dorm rooms search itself considers. A tag comes back with its `#` so submitting the
* suggestion verbatim searches by tag rather than for a room called "horror".
*
* A query starting with `#` is asking for tags, so only tags are suggested. Otherwise
* names come first (the likelier intent), then tags, and within each, matches that START
* with the query come before ones that merely contain it. Ties break alphabetically, so
* the same query always suggests the same things in the same order.
*
* Matching is case-insensitive and suggestions are de-duplicated case-insensitively, but
* each is returned in its stored casing — search doesn't care, and the player reads these.
* The dataset is small, so this filters in memory like {@link searchRooms}.
*/
export async function autocompleteRoomSearch(
db: D1Database,
query: string,
take: number
): Promise<string[]> {
const q = query.trim().toLowerCase()
if (q === '' || take <= 0) return []
const { results } = await db.prepare(`SELECT ${ROOM_COLUMNS} FROM room`).all<RoomRow>()
const rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1)
const tagQuery = q.startsWith('#')
const term = tagQuery ? q.slice(1) : q
if (term === '') return []
// Lowercased suggestion → [rank, the casing to serve it in]. Lower rank sorts first.
const found = new Map<string, [number, string]>()
const offer = (value: string, rank: number) => {
const key = value.toLowerCase()
const existing = found.get(key)
if (existing === undefined || existing[0] > rank) found.set(key, [rank, value])
}
for (const room of rooms) {
if (!tagQuery && typeof room.Name === 'string') {
const name = room.Name.toLowerCase()
if (name.startsWith(term)) offer(room.Name, 0)
else if (name.includes(term)) offer(room.Name, 1)
}
for (const tag of roomTags(room)) {
if (tag.startsWith(term)) offer(`#${tag}`, tagQuery ? 0 : 2)
else if (tag.includes(term)) offer(`#${tag}`, tagQuery ? 1 : 3)
}
}
return [...found.entries()]
.sort(([aKey, [aRank]], [bKey, [bRank]]) => aRank - bRank || aKey.localeCompare(bKey))
.slice(0, take)
.map(([, [, value]]) => value)
}
/**
* Engagement score used to order the hot feed (cheers weigh most, then favorites).
* Cheers/favorites come from the caller's aggregated {@link getRoomStats} map — ranking
@@ -2066,5 +2186,7 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run()
const subRoom = await insertSubRoom(db, roomId, { ...templateSub, CreatorAccountId: accountId })
room.SubRooms = [subRoom]
// The template carries these (it was parsed), but a dorm minted without one wouldn't.
attachRoomDtoDefaults(room)
return room
}
+3
View File
@@ -1,6 +1,9 @@
export {
validateAndGetAccountId,
validateAndGetRoles,
validateAndGetVersion,
generateToken,
generatePhotonAuthToken,
TOKEN_TTL_SECONDS,
} from './jwt'
export type { PhotonAuthClaims } from './jwt'
+81 -2
View File
@@ -82,6 +82,31 @@ export async function validateAndGetRoles(
}
}
/**
* Validate a request's bearer token and return its `rn.ver` claim — the game build the
* caller posted to `/connect/token`, stamped by {@link generateToken}. `null` when the
* request carries no valid token, and `null` too when a valid token has no `rn.ver` (an
* older token, issued before the claim carried the client's own value): callers fall back
* to what they stored or to GAME_VERSION rather than writing an empty version, which
* breaks the client's presence handling.
*/
export async function validateAndGetVersion(
request: Request,
secret: string
): Promise<string | null> {
const authHeader = request.headers.get('Authorization')
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('bearer '.length)
try {
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
const version = payload['rn.ver']
return typeof version === 'string' && version !== '' ? version : null
} catch {
return null
}
}
/** Scopes stamped onto every token (as a claim array). */
const TOKEN_SCOPES = [
'profile',
@@ -108,13 +133,63 @@ 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,
platform: number,
secret: string,
extraRoles: string[] = [],
privileges: string[] = []
privileges: string[] = [],
version: string = GAME_VERSION
): Promise<string> {
const now = Math.floor(Date.now() / 1000)
// The client reads `role`/`scope` (and expects a well-formed iss/aud) to
@@ -133,7 +208,11 @@ export async function generateToken(
idp: 'local',
platform,
platform_id: platformId,
'rn.ver': GAME_VERSION,
// The CLIENT's build, as it posted it to /connect/token (`ver`) — not this
// server's GAME_VERSION, which is only the fallback for a grant that names none
// (a refresh, or a caller that isn't the game). Presence reads it back off the
// token, so a player's reported version is the build they are actually running.
'rn.ver': version,
'rn.plat': platform,
role: [...BASE_ROLES, ...extraRoles],
// `rn.privilege` LOOKS like a scope but is a claim: the client reads it out of
+9 -1
View File
@@ -16,7 +16,14 @@ 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, e.g. {"playersettings":"settings"}).
# (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.
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
@@ -190,6 +197,7 @@ wrangler deploy \
--var NAME:"$NAME" \
--var SENTRY_RELEASE:"$VERSION" \
--var DOMAIN:"$DOMAIN" \
--var SUBDOMAINS:"$SUBDOMAINS_JSON" \
$EXTRA_VARS \
--domain "$HOST" \
$MINIFY \