From ec52985ef23050e8d3278f6867505bac6b103fbf Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 13 Aug 2026 01:05:55 -0400 Subject: [PATCH] [2025] unstable --- .env.example | 19 +- DEPLOYING.md | 13 +- SERVICES.md | 8 +- apps/accounts/src/accounts.app.ts | 4 + apps/accounts/src/openapi.ts | 2 + .../accounts/src/test/integration/api.test.ts | 4 + apps/api/src/openapi.ts | 93 ++++++++- apps/api/src/routes/avatar.ts | 141 +++++++++++++ apps/api/src/routes/moderation.ts | 64 +++++- apps/api/src/test/integration/api.test.ts | 187 ++++++++++++++++-- apps/cdn/static/loading-screen-tip-data.json | 4 +- apps/econ/src/econ.app.ts | 108 ++++++++-- apps/econ/src/inventory-db.ts | 37 ++++ apps/econ/src/openapi.ts | 50 +++++ apps/econ/src/test/integration/api.test.ts | 127 +++++++++--- .../static/default-base-avatar-items.json | 28 +++ apps/match/src/match.app.ts | 185 ++++++++++++++++- apps/match/src/openapi.ts | 59 ++++++ apps/match/src/test/integration/api.test.ts | 158 +++++++++++++++ apps/ns/README.md | 15 +- apps/ns/src/context.ts | 8 + apps/ns/src/endpoints.ts | 51 ++++- apps/ns/src/ns.app.ts | 2 +- apps/ns/src/test/integration/api.test.ts | 13 ++ apps/ns/wrangler.jsonc | 3 +- packages/domain/src/accounts-db.ts | 6 + packages/domain/src/index.ts | 1 + .../domain/src/outfits-db.ts | 36 +++- packages/domain/src/presence-db.ts | 18 +- packages/jwt/src/index.ts | 2 + packages/jwt/src/jwt.ts | 49 +++++ packages/tools/bin/run-wrangler-deploy | 10 +- 32 files changed, 1396 insertions(+), 109 deletions(-) create mode 100644 apps/econ/static/default-base-avatar-items.json rename apps/econ/src/outfit-db.ts => packages/domain/src/outfits-db.ts (57%) diff --git a/.env.example b/.env.example index d154577..3692f01 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,22 @@ # Base domain all service hosts are derived from, e.g. accounts.. RECFLARE_DOMAIN=rec.example.com -# Optional per-app subdomain overrides, as a compact JSON object keyed by the -# worker's directory name. Defaults to the directory name when unset. -# RECFLARE_SUBDOMAINS='{"playersettings":"settings"}' +# Optional per-service subdomain overrides, as a compact JSON object keyed by the +# service's default subdomain (which, for a service backed by a worker, is that +# worker's directory name). Unlisted services keep their default. +# +# One entry moves both sides: it decides which host `just deploy` puts the worker on +# AND which host the `ns` discovery document advertises to the client, so the two can't +# drift apart. Redeploy `ns` (`just deploy -F ns`) after changing this. +# +# {"playersettings":"settings"} the playersettings worker moves to settings. +# {"moderation":"api"} Moderation has no worker of its own, so this is a pure +# client-side redirect: it points the client's Moderation +# calls at the api worker, which is where the +# /api/PlayerReporting/… routes actually live +# +# Keep it compact — no spaces. Services are listed in SERVICES.md. +# RECFLARE_SUBDOMAINS='{"moderation":"api"}' # Id of the shared `recflare` D1 database (create it manually with # `wrangler d1 create recflare`). All D1-backed workers bind this one database. diff --git a/DEPLOYING.md b/DEPLOYING.md index 907476d..73a5f7d 100644 --- a/DEPLOYING.md +++ b/DEPLOYING.md @@ -79,9 +79,16 @@ cp .env.example .env Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export RECFLARE_DOMAIN=rec.example.com`) -(Optional) - per-app subdomain overrides come from -`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used -if you wanted to merge two services together e.g. send `datacollection` calls to `api`. +(Optional) - per-service subdomain overrides come from `RECFLARE_SUBDOMAINS`, a JSON +object keyed by each service's default subdomain (see `SERVICES.md`), e.g. +`'{"playersettings":"settings"}'`. A single entry both decides which host `just deploy` +puts that worker on and which host the `ns` discovery document advertises to the client, +so the two can't drift apart. + +This is also how you merge two services together: `'{"moderation":"api"}'` points the +client's Moderation calls at the `api` worker (which is where the `/api/PlayerReporting/…` +routes already live) without deploying anything on `moderation.`. Redeploy `ns` +after changing it — `just deploy -F ns`. **Create the storage resources:** diff --git a/SERVICES.md b/SERVICES.md index 5005c1a..0f9acad 100644 --- a/SERVICES.md +++ b/SERVICES.md @@ -6,6 +6,12 @@ Each is reached at `https://.`. Services with a worker i `apps/` are implemented here; the rest are advertised in the endpoints document but not yet backed by a Worker. Not all services are fully implemented. +The subdomains below are the defaults. Any of them can be redirected from `.env` via +`RECFLARE_SUBDOMAINS`, keyed by the subdomain in this table — which both moves where the +worker deploys and what `ns` advertises. Pointing a service with no worker at one that has +one merges them, e.g. `'{"moderation":"api"}'` sends the client's Moderation calls to the +`api` worker, where the `/api/PlayerReporting/…` routes already live. See `DEPLOYING.md`. + A small `ns` worker itself serves this discovery document at the apex/`ns` host and isn't listed within it. Each implemented worker has its own `README.md` under `apps//` documenting its routes. @@ -34,7 +40,7 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own | Link | `link` | — | Not yet implemented | | Lists | `lists` | — | Not yet implemented | | Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) | -| Moderation | `moderation` | — | Not yet implemented | +| Moderation | `moderation` | — | No worker; point it at `api` to serve `/api/PlayerReporting/…` | | Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) | | PlatformNotifications | `platformnotifications` | — | Not yet implemented | | PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) | diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index d010dec..f24ccf7 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -108,6 +108,10 @@ function toAccountDto(account: Account) { username: account.username, displayName: account.displayName, profileImage: account.profileImage, + // Nothing writes these yet, and rows stored before they existed have neither + // key — always emit them as "" rather than letting them go missing. + bannerImage: account.bannerImage ?? '', + displayEmoji: account.displayEmoji ?? '', isJunior: account.isJunior, platforms: account.platforms, personalPronouns: account.personalPronouns, diff --git a/apps/accounts/src/openapi.ts b/apps/accounts/src/openapi.ts index d4a2ac0..5b66d64 100644 --- a/apps/accounts/src/openapi.ts +++ b/apps/accounts/src/openapi.ts @@ -67,6 +67,8 @@ export const AccountDto = z.object({ username: z.string(), displayName: z.string(), profileImage: z.string().describe('Avatar object key'), + bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'), + displayEmoji: z.string().describe('Emoji beside the display name — always "" (nothing sets it yet)'), isJunior: z.boolean(), platforms: z.int().describe('PlatformType bitmask of linked platforms'), personalPronouns: z.int().describe('Pronoun flags bitmask'), diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index 686f301..d6f64a8 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -164,6 +164,10 @@ describe('auth-gated endpoints', () => { // An unset email is "", not null — the client reads it as a string, and the // hub frame this DTO also rides drops null values outright. email: '', + // Nothing sets these yet, but the key has to be present — the client reads + // both off the account DTO. + bannerImage: '', + displayEmoji: '', }) // juniorState + parentAccountId must be omitted when null, not emitted as // null, or the client's enum parser throws on `juniorState`. `phone` isn't diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 5a93563..5c8d39c 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -423,6 +423,86 @@ export const CustomAvatarItemsPage = z.object({ TotalResults: z.int(), }) +/** + * One custom-item save — the rebuilt version of a legacy avatar item. This is the + * official shape, recorded for documentation: nothing stores custom items yet, so we + * never actually emit one of these. + */ +export const CustomAvatarItemSave = z.object({ + customAvatarItemSaveId: z.int().describe('The save’s id'), + customAvatarItemId: z.string().describe('Guid of the custom item this save belongs to'), + unityAssetId: z.string().describe('Guid of the built Unity asset'), + createdAt: z.string().describe('ISO 8601 timestamp'), + thumbnailFileName: z.string(), + additionalConfiguration: z.string(), + unityAsset: z.string(), + unityAssetHash: z.string(), +}) + +/** + * The custom-item saves that replace a set of legacy avatar items, keyed by the legacy + * item's `AvatarItemDesc`. Nothing stores custom items yet, so the map is always empty — + * the value shape is documented rather than served. + */ +export const LegacyAvatarItemSaves = z.object({ + customAvatarItemSavesByAvatarItemDesc: z.record(z.string(), CustomAvatarItemSave), +}) + +/** + * `GET /outfits/me` — the outfit envelope. Either the outfit stored in slot 0, served + * back exactly as it was saved, or (for a player who has never saved) the brand-new- + * account form, where every field that would carry an outfit is null/empty and + * `DataVersion` is 9. + */ +export const OutfitsMeResponse = z.object({ + LegacyData: z.object({ + SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'), + SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'), + FaceFeatures: z.string().nullable().describe('JSON-in-a-string'), + SkinColor: z.string().nullable(), + HairColor: z.string().nullable(), + }), + Selections: JsonArray, + DataVersion: z.int().describe('9 in the new-account envelope; whatever was saved otherwise'), + CustomizationSettings: z + .string() + .nullable() + .describe('JSON-in-a-string: the same outfit in the newer structured form'), + ThumbnailFileName: z.string().nullable(), + Name: z.string().nullable(), + Accessibility: z.int(), + Slot: z.int().describe('0 — the outfit being worn'), +}) + +/** + * `PUT /outfits/me` JSON body — the outfit the client is saving, in the newer envelope. + * The heavy fields are JSON-in-a-string, exactly as the client serialises them: + * `SelectionsV2` and `CustomizationSettings` are whole documents encoded as strings, and + * `FaceFeatures` likewise. Note the two formats overlap: `LegacyData` carries the old + * flat descriptors while `CustomizationSettings` carries the same outfit in the new + * structured form, and the client sends both. `Selections` arrives empty — the actual + * selections are inside those strings. + */ +export const OutfitsMeRequest = z.object({ + DataVersion: z.int().describe('The client’s outfit format version (2 in observed saves)'), + LegacyData: z.object({ + SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'), + SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'), + FaceFeatures: z.string().nullable().describe('JSON-in-a-string'), + SkinColor: z.string().nullable(), + HairColor: z.string().nullable(), + }), + CustomizationSettings: z + .string() + .nullable() + .describe('JSON-in-a-string: the same outfit in the newer structured form'), + Selections: JsonArray.describe('Empty in observed saves'), + Slot: z.int(), + Name: z.string().nullable(), + Accessibility: z.int(), + ThumbnailFileName: z.string().nullable(), +}) + /** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */ export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() }) @@ -588,13 +668,16 @@ export const PlayerEventsPage = z.object({ // ---- Moderation ------------------------------------------------------------ /** - * `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. + * `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked" + * answer (no ban storage yet), mirroring the reference server's stub + * `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0, + * which is a real category, and `Message` is null — the client distinguishes "no + * message" from a blank one, so we send null where the reference sends an empty string. + * `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so + * they carry their C# defaults (false / null). */ 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/avatar.ts b/apps/api/src/routes/avatar.ts index a5f77cd..5c56eea 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -2,9 +2,12 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' import { + CURRENT_OUTFIT_SLOT, + getOutfit, inventionDescriptionRejection, inventionNameRejection, inventionTagRejection, + setOutfit, } from '@repo/domain' import { authedId, unauthorized } from '../http' @@ -46,6 +49,9 @@ import { json, JsonArray, jsonBody, + LegacyAvatarItemSaves, + OutfitsMeRequest, + OutfitsMeResponse, pageParams, SaveInventionRequest, SetTagsRequest, @@ -234,6 +240,141 @@ export const avatarRoutes = new Hono({ strict: false }) (c) => c.json({ Results: [], TotalResults: 0 }) ) + // The client asks which legacy avatar items have been rebuilt as custom items, so it + // can render the custom version instead. Nothing stores custom items yet, so nothing + // has a save — an empty list means "use the legacy items as-is". + .post( + '/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems', + describeRoute({ + tags: ['Avatar'], + summary: 'Custom-item saves for legacy avatar items', + description: + 'Given a set of legacy avatar items, the custom-item saves that replace them, keyed ' + + 'by the legacy item’s `AvatarItemDesc`. Nothing stores custom items yet, so the map ' + + 'is always empty — which the client reads as “render the legacy items as-is”. The ' + + 'request body is ignored.\n\n' + + 'The value shape is the official one, recorded here for documentation; we never ' + + 'emit one until custom items are stored.', + responses: { 200: json(LegacyAvatarItemSaves, 'An empty map') }, + }), + (c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} }) + ) + + // The newer outfit read, on a bare (un-prefixed) path. Auth-gated. The outfit the + // player is wearing is slot 0 of the shared `outfit` table (the same table the `econ` + // worker's saved-outfit slots live in); a player who has never saved gets the + // brand-new-account envelope instead. + .get( + '/outfits/me', + describeRoute({ + tags: ['Avatar'], + summary: 'The caller’s outfit', + description: + 'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' + + '`outfit` table — the newer client treats slot 0 as the outfit currently worn — and ' + + 'handed back exactly as it was saved, since the payload’s heavy fields are the ' + + 'client’s own JSON-in-a-string documents.\n\n' + + 'A player who has never saved gets the brand-new-account envelope: all-null ' + + '`LegacyData`, no `Selections`, `DataVersion` 9.', + security: AUTHED, + responses: { + 200: json(OutfitsMeResponse, 'The stored outfit, or the empty envelope'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const outfit = await getOutfit(c.env.DB, id, CURRENT_OUTFIT_SLOT) + if (outfit !== null) return c.json(outfit) + + return c.json({ + LegacyData: { + SelectionsV1: null, + SelectionsV2: null, + FaceFeatures: null, + SkinColor: null, + HairColor: null, + }, + Selections: [], + DataVersion: 9, + CustomizationSettings: null, + ThumbnailFileName: null, + Name: null, + Accessibility: 0, + Slot: 0, + }) + } + ) + + // Saving an outfit through the same bare path — into the slot the body names, which + // is slot 0 for the outfit being worn. Stored verbatim: the heavy fields are the + // client's own JSON-in-a-string documents, and re-encoding risks changing a payload + // it has to parse back. Answers the saved outfit, which is what the client re-renders + // from. + .put( + '/outfits/me', + describeRoute({ + tags: ['Avatar'], + summary: 'Save the caller’s outfit', + description: + 'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' + + 'outfit worn, which is what the GET reads. Re-saving a slot overwrites it.\n\n' + + 'The payload is stored verbatim and answered back: its heavy fields (`SelectionsV2`, ' + + '`FaceFeatures`, `CustomizationSettings`) are whole JSON documents encoded as ' + + 'strings by the client’s own serializer, so nothing here parses or re-encodes them.', + security: AUTHED, + requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'), + responses: { + 200: json(OutfitsMeRequest, 'The outfit as stored'), + 400: json(ErrorResponse, 'Unparseable body'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = (await c.req.json().catch(() => null)) as Record | null + if (body === null) return c.json({ error: 'Invalid request body' }, 400) + + // The client sends `Slot`; a body without one saves the worn outfit. + const outfit = { + ...body, + Slot: typeof body.Slot === 'number' ? body.Slot : CURRENT_OUTFIT_SLOT, + } + await setOutfit(c.env.DB, id, outfit) + return c.json(outfit) + } + ) + + // The caller's outfit wardrobe. An empty list for now — the outfits saved through + // `PUT /outfits/me` are in the shared `outfit` table already, but which of them + // belong in this list (and in what shape) has not been pinned down, so it answers [] + // rather than guessing. + .get( + '/outfits/me/saved', + describeRoute({ + tags: ['Avatar'], + summary: 'The caller’s saved outfits', + description: + 'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' + + 'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' + + 'list should carry, and in what shape, is not pinned down yet.', + security: AUTHED, + responses: { + 200: json(JsonArray, 'An empty list'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json([]) + } + ) + // A single invention by id (`?inventionId=…`). Returns the stored RRInvention, // or 404 when there's no such invention. .get( diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 187b48c..2998fdf 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -61,11 +61,16 @@ const asFloat = (v: string | undefined): number | null => { export const moderationRoutes = new Hono({ strict: false }) // Whether the caller is currently blocked (banned / timed out / host-kicked). Bans // are stored (a report row with `banned` set) and enforced at matchmake and at login, - // but this endpoint is not wired to them, so it's 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( + // but this endpoint is not wired to them, so it's always the "not blocked" answer — + // the reference server's stub `ReturnModerationBlockDetails()`. + // `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category; + // `Message` is null, not the empty string that stub sends — the client distinguishes + // "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the + // DTO but left unset there, so they go out with their C# defaults. + // The newer client POSTs this with no body despite it being a pure read; it answers + // GET too, so the path is reachable from either build. + .on( + ['GET', 'POST'], '/api/PlayerReporting/v1/moderationBlockDetails', describeRoute({ tags: ['Moderation'], @@ -73,11 +78,13 @@ export const moderationRoutes = new Hono({ strict: false }) description: 'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' + 'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' + - 'not wired to them, so it 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.', + 'not wired to them, so it is always the “not blocked” answer, following the ' + + 'reference server’s stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' + + 'is a real category, and `Message` is null rather than the empty string that stub ' + + 'sends — the client distinguishes “no message” from a blank one. ' + + '`IsVoiceModAutoban` and `TimeoutStartedAt` are on the DTO but unset by that ' + + 'stub, so they carry their defaults. Answers GET or POST: the newer client POSTs ' + + 'it with no body.', responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') }, }), (c) => @@ -105,6 +112,43 @@ export const moderationRoutes = new Hono({ strict: false }) }), (c) => c.json([]) ) // TODO: hydrate from JSON/vtkreasons.json + // The client asking whether IT should run its referee moderation — the in-client + // review flow a player with referee standing gets shown. Deliberately `false` for + // everyone: this is an archival server, and the referee program is one of the live + // moderation systems it does not run. Answering true would put the client into a flow + // with no cases behind it. A POST despite being a pure read, which is how the client + // asks. + .post( + '/api/PlayerReporting/v1/referee', + describeRoute({ + tags: ['Moderation'], + summary: 'Whether the caller is a referee', + description: + 'A bare JSON `false` — no envelope. The game client asks this to decide whether to ' + + 'run its referee moderation flow. Always false: the referee program is switched ' + + 'off here rather than unimplemented, since this server is archival.', + responses: { 200: json(BareBoolean, 'Always `false` — the program is off') }, + }), + (c) => c.json(false) + ) + // The referee's own case files — the reviews assigned to them. Empty for the same + // reason the flag above is false: the program is off, so no case is ever assigned. A + // caller reaching this at all has gone past that flag, so the empty list is a second + // line of defence rather than the normal path. A GET, unlike its POSTing neighbours + // in this flow. + .get( + '/api/referee/files', + describeRoute({ + tags: ['Moderation'], + summary: 'Referee case files', + description: + 'The moderation cases assigned to the caller as a referee. Always empty — the ' + + 'referee program is switched off here (see `/api/PlayerReporting/v1/referee`), so ' + + 'nothing is ever assigned.', + responses: { 200: json(JsonArray, 'An empty list — no cases are ever assigned') }, + }), + (c) => c.json([]) + ) .post( '/api/PlayerReporting/v1/hile', describeRoute({ diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 595080b..809e042 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -14,6 +14,7 @@ import { LEVEL_REQUIRED_XP, LEVEL_REWARDS, MAX_LEVEL, + OUTFIT_SCHEMA_DDL, PROGRESSION_SCHEMA_DDL, RELATIONSHIP_SCHEMA_DDL, ROOM_SCHEMA_DDL, @@ -108,6 +109,9 @@ beforeAll(async () => { // Relationships table (owned by the api worker) — friendship endpoints use it. for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0. + for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Inventions table (owned by the api worker) — invention save/mine use it. for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run() @@ -269,23 +273,45 @@ describe('public endpoints', () => { expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' }) }) - test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => { - const res = await exports.default.fetch( - `${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails` - ) - expect(res.status).toBe(200) - // ReportCategory -1 = no category (0 is a real one), and Message is null. - expect(await res.json()).toEqual({ - ReportCategory: -1, - Duration: 0, - GameSessionId: 0, - IsBan: false, - IsHostKick: false, - IsVoiceModAutoban: false, - Message: null, - PlayerIdReporter: null, - TimeoutStartedAt: null, + // The client POSTs this with no body, despite it being a pure read; the route answers + // GET as well, and both methods serve the same body. + test.each(['GET', 'POST'])( + '%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', + async (method) => { + const res = await exports.default.fetch( + `${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`, + { method } + ) + expect(res.status).toBe(200) + // ReportCategory -1 = Unknown (0 is a real category). Message is null, not the + // reference stub's empty string — the client tells "no message" from a blank one. + expect(await res.json()).toEqual({ + ReportCategory: -1, + Duration: 0, + GameSessionId: 0, + IsBan: false, + IsHostKick: false, + IsVoiceModAutoban: false, + Message: null, + PlayerIdReporter: null, + TimeoutStartedAt: null, + }) + } + ) + + test('POST /api/PlayerReporting/v1/referee says the caller is not one', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/referee`, { + method: 'POST', }) + expect(res.status).toBe(200) + // A bare boolean, not an envelope or a list. + expect(await res.json()).toBe(false) + }) + + test('GET /api/referee/files has no cases', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/referee/files`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) }) // Unauthenticated by design — the client posts this before it has an account, so @@ -462,6 +488,128 @@ describe('public endpoints', () => { expect(await res.json()).toEqual({ Results: [], TotalResults: 0 }) }) + test('POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems returns an empty map', async () => { + const res = await exports.default.fetch( + `${ORIGIN}/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ AvatarItemIds: [1, 2, 3] }), + } + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ customAvatarItemSavesByAvatarItemDesc: {} }) + }) + + test('GET /outfits/me 401s without a token, serves the empty envelope for a new player', async () => { + const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`) + expect(anon.status).toBe(401) + // Account 77 never saves an outfit, so it keeps getting the new-account envelope. + const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer('77') }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + LegacyData: { + SelectionsV1: null, + SelectionsV2: null, + FaceFeatures: null, + SkinColor: null, + HairColor: null, + }, + Selections: [], + DataVersion: 9, + CustomizationSettings: null, + ThumbnailFileName: null, + Name: null, + Accessibility: 0, + Slot: 0, + }) + }) + + test('PUT /outfits/me saves into slot 0; GET reads it back verbatim', async () => { + // The client's own payload, trimmed to one selection: the point is that the heavy + // JSON-in-a-string fields survive the round trip as strings, unparsed. + const outfit = { + DataVersion: 2, + LegacyData: { + SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0', + SelectionsV2: + '{"selections":[{"PrefabGuid":"193a3bf9-abc0-4d78-8d63-92046908b1c5","CombinationGuid":"","BodyPart":0}]}', + FaceFeatures: '{"ver":7,"eyeId":"Aeu0yxJXG0qCOLZW5Tcu7A","hideEars":false}', + SkinColor: 'Dc6StLFk60u5iUTrb3_C3w', + HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg', + }, + CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}', + Selections: [], + Slot: 0, + Name: null, + Accessibility: 1, + ThumbnailFileName: null, + } + + const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(outfit), + }) + expect(anon.status).toBe(401) + + const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { + method: 'PUT', + headers: { ...(await bearer()), 'content-type': 'application/json' }, + body: JSON.stringify(outfit), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(outfit) + + // The read serves it back byte-for-byte — the JSON-in-a-string fields are still + // strings, not re-encoded objects. + const read = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() }) + expect(await read.json()).toEqual(outfit) + + // Re-saving overwrites slot 0 rather than adding a second row. + const changed = { ...outfit, LegacyData: { ...outfit.LegacyData, SkinColor: 'changed' } } + await exports.default.fetch(`${ORIGIN}/outfits/me`, { + method: 'PUT', + headers: { ...(await bearer()), 'content-type': 'application/json' }, + body: JSON.stringify(changed), + }) + const reread = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() }) + expect(await reread.json()).toEqual(changed) + const rows = await env.DB.prepare( + 'SELECT COUNT(*) AS n FROM outfit WHERE account_id = 42' + ).first<{ n: number }>() + expect(rows?.n).toBe(1) + + // A save naming another slot does not touch what the caller is wearing. + await exports.default.fetch(`${ORIGIN}/outfits/me`, { + method: 'PUT', + headers: { ...(await bearer()), 'content-type': 'application/json' }, + body: JSON.stringify({ ...changed, Slot: 3, Name: 'slot three' }), + }) + const worn = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() }) + expect(((await worn.json()) as { Name: string | null }).Name).toBe(null) + }) + + test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => { + const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`) + expect(anon.status).toBe(401) + // Empty even for account 42, which saved an outfit through PUT /outfits/me above. + const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, { + headers: await bearer(), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + test('PUT /outfits/me 400s on an unparseable body', async () => { + const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { + method: 'PUT', + headers: { ...(await bearer()), 'content-type': 'application/json' }, + body: 'not json', + }) + expect(res.status).toBe(400) + }) + test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`) expect(res.status).toBe(200) @@ -3650,6 +3798,7 @@ describe('openapi', () => { 'GET /api/players/v1/progression/{id}', 'GET /api/players/v2/progression/bulk', 'GET /api/quickPlay/v1/getandclear', + 'GET /api/referee/files', 'GET /api/relationships/mutualfriends', 'GET /api/relationships/v1/favorite', 'GET /api/relationships/v1/ignore', @@ -3667,11 +3816,16 @@ describe('openapi', () => { 'GET /api/rooms/v1/filters', 'GET /api/versioncheck/islandedversions', 'GET /api/versioncheck/v4', + 'GET /outfits/me', + 'GET /outfits/me/saved', 'GET /voice/config', 'POST /api/PlayerReporting/v1/deviceId', 'POST /api/PlayerReporting/v1/hile', + 'POST /api/PlayerReporting/v1/moderationBlockDetails', + 'POST /api/PlayerReporting/v1/referee', 'POST /api/PlayerReporting/v3/create', 'POST /api/avatar/v2/gifts/generate', + 'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems', 'POST /api/gamesight/event', 'POST /api/images/v1/cheer', 'POST /api/images/v4/uploadsaved', @@ -3705,6 +3859,7 @@ describe('openapi', () => { 'POST /api/sanitize/v1', 'POST /api/sanitize/v1/isPure', 'POST /api/v1/progression/bulk', + 'PUT /outfits/me', ]) // Every operation carries a summary — an undescribed one renders as a bare path. 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/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 3c09628..afdbf72 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -7,11 +7,13 @@ import { consumeGift, createGift, getGift, + getOutfits, getPendingGifts, grantInvention, levelReward, levelsReached, ownsInvention, + setOutfit, } from '@repo/domain' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' @@ -29,6 +31,7 @@ import { NotificationType } from '../../notify/src/notification-types' import adCarouselItems from '../static/ad-carousel-items.json' import defaultAvatarItems from '../static/default-avatar-items.json' import defaultAvatar from '../static/default-avatar.json' +import defaultBaseAvatarItems from '../static/default-base-avatar-items.json' import myProgress from '../static/my-progress.json' import weeklyChallenge from '../static/weekly-challenge.json' import { getAvatar, setAvatar } from './avatar-db' @@ -54,9 +57,10 @@ import { grantConsumable, } from './consumables-db' import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db' -import { getInventory, grantItem } from './inventory-db' +import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db' import { AUTHED, + AvatarItemV4Dto, AvatarV2Dto, BalanceEntry, BuyInventionResponse, @@ -64,6 +68,9 @@ import { BuyItemResponse, ChallengeProgressRequest, ChallengeProgressResponse, + ChecklistCompleteResponse, + ChecklistEntry, + CompleteChecklistRequest, ConsumeConsumableRequest, ConsumeEnvelope, ConsumeGiftRequest, @@ -85,11 +92,10 @@ import { UpdateObjectiveRequest, UpdateObjectiveResponse, } from './openapi' -import { getOutfits, setOutfit } from './outfit-db' import { claimReward } from './reward-db' import type { Context } from 'hono' -import type { GiftContent, Progression, StoredGift, XpGrant } from '@repo/domain' +import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain' import type { BalanceResponsePayload, PurchaseBalanceModificationPayload, @@ -99,7 +105,6 @@ import type { ConsumeResult } from './consumables-db' import type { App } from './context' import type { Equipment } from './equipment-db' import type { AvatarItem } from './inventory-db' -import type { Outfit } from './outfit-db' /** * Economy Worker. Hosts the avatar/economy endpoints the game client calls on @@ -1160,6 +1165,22 @@ async function awardChallengeGift(c: Context, accountId: number): Promise({ strict: false }) (c) => c.json(defaultAvatarItems) ) - // Default base avatar items — empty stub for now. No auth. + // The base items UGC clothing is built on top of — served from bundled static JSON, + // separate from the `defaultunlocked` catalog. No auth. .get( '/api/avatar/v1/defaultbaseavataritems', - listRoute('Default base avatar items', 'Empty stub for now'), - (c) => c.json([]) + listRoute('Default base avatar items', 'The bundled base items UGC clothing builds on'), + (c) => c.json(defaultBaseAvatarItems) ) // The player's avatar items — the items they've bought (from `buyItem`, stored in @@ -1219,10 +1241,12 @@ const app = new Hono({ strict: false }) description: [ 'The items the player has bought (from buyItem, in the inventory table) prepended', 'to the default catalog. A player who has bought nothing gets just the catalog.', + 'Both sources are projected into the camelCase v4 DTO — the sibling item endpoints', + '(`defaultunlocked`, `defaultbaseavataritems`) serve their records raw instead.', ].join(' '), security: AUTHED, responses: { - 200: json(JsonArray, 'Owned items followed by the default catalog'), + 200: json(AvatarItemV4Dto.array(), 'Owned items followed by the default catalog'), 401: UNAUTHORIZED_RESPONSE, }, }), @@ -1230,7 +1254,7 @@ const app = new Hono({ strict: false }) const id = await authedId(c) if (id === null) return unauthorized(c) const owned = await getInventory(c.env.DB, id) - return c.json([...owned, ...defaultAvatarItems]) + return c.json([...owned, ...defaultAvatarItems].map(toAvatarItemV4)) } ) @@ -1376,15 +1400,69 @@ const app = new Hono({ strict: false }) } ) - // NUX checklist — the client fetches this on the econ host during load. [] - // with no DB. A 404 here can abort the load orchestration before matchmake. - .get( - '/api/checklist/v1/current', - listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true), + // NUX checklist — the client fetches this on the econ host during load, on either + // version path. A 404 here can abort the load orchestration before matchmake. We + // serve the default brand-new-account list to everyone: nothing records per-player + // checklist progress yet, so it never shrinks as steps are done. + .on( + 'GET', + ['/api/checklist/v1/current', '/api/checklist/v2/current'], + describeRoute({ + tags: ['Econ'], + summary: 'NUX checklist', + description: + 'The new-user checklist, as the default brand-new-account list — nothing records ' + + 'per-player progress yet, so the same rows come back however much the player has ' + + 'done. `Objective` is an `ObjectiveType` ordinal the client matches its own ' + + 'progress events against. v1 and v2 serve the same list.', + security: AUTHED, + responses: { + 200: json(ChecklistEntry.array(), 'The checklist rows, in `Order`'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - return c.json([]) + return c.json(DEFAULT_CHECKLIST) + } + ) + + // Mark a checklist row done. [Authorize]. Stubbed: there is no objective-progress + // table to record the completion in, and no reward ledger to make the 25-token grant + // once-only — without one, re-posting the same row would mint tokens indefinitely, so + // we grant nothing and report a change of 0. The envelope is still the balance-update + // shape the client parses, so the flow completes instead of erroring. + .on( + 'POST', + ['/api/checklist/v1/complete', '/api/checklist/v2/complete'], + describeRoute({ + tags: ['Econ'], + summary: 'Complete a checklist row (stub)', + description: + 'Marks a NUX checklist row done. Stubbed: nothing records the completion (no ' + + 'objective-progress table) and nothing is granted — a reward is worth 25 XP and 25 ' + + 'tokens, but making that once-only needs a ledger we do not have, and without one ' + + 're-posting the same row would mint tokens indefinitely. The response is still the ' + + 'balance-update envelope, with `Balance` (the change) 0. v1 and v2 behave alike.', + security: AUTHED, + requestBody: jsonBody(CompleteChecklistRequest, 'Which row was completed — `{ ItemIndex }`'), + responses: { + 200: json(ChecklistCompleteResponse, 'The balance-update envelope, granting nothing'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + // The body names the row (`{ ItemIndex: 1 }`, or `Id` as a fallback) — read only + // once there is somewhere to record it. + return c.json({ + BalanceUpdates: [{ UpdateResponse: CHECKLIST_REWARD_CONTEXT, Data: [] }], + Balance: 0, + CurrencyType: CurrencyType.RecCenterTokens, + BalanceType: -2, + }) } ) diff --git a/apps/econ/src/inventory-db.ts b/apps/econ/src/inventory-db.ts index 3ae594b..09a84db 100644 --- a/apps/econ/src/inventory-db.ts +++ b/apps/econ/src/inventory-db.ts @@ -40,6 +40,43 @@ export interface AvatarItem extends Record { Rarity: number } +/** + * The camelCase DTO `GET /api/avatar/v4/items` serves. Distinct from the PascalCase + * `AvatarItem` we store and from what the sibling item endpoints (`defaultunlocked`, + * `defaultbaseavataritems`) serve — those hand back their stored/bundled records raw. + */ +export interface AvatarItemV4 { + avatarItemId: number + avatarItemDesc: string + friendlyName: string + tooltip: string + tagList: string + avatarItemType: number + rarity: number + isBaseAvatarItem: boolean +} + +/** + * Project a stored or bundled avatar item into the v4 DTO. Neither source carries an + * `AvatarItemId`, a `TagList` or an `IsBaseAvatarItem` flag — the storefront gift-drops + * we grant from have none and the default catalog has none either — so those default to + * 0 / "" / false rather than being invented. + */ +export function toAvatarItemV4(item: Record): AvatarItemV4 { + const str = (v: unknown): string => (typeof v === 'string' ? v : '') + const num = (v: unknown): number => (typeof v === 'number' ? v : 0) + return { + avatarItemId: num(item.AvatarItemId), + avatarItemDesc: str(item.AvatarItemDesc), + friendlyName: str(item.FriendlyName), + tooltip: str(item.Tooltip), + tagList: str(item.TagList), + avatarItemType: num(item.AvatarItemType), + rarity: num(item.Rarity), + isBaseAvatarItem: item.IsBaseAvatarItem === true, + } +} + /** * Grant an item into a player's inventory. Upserts on (account_id, avatar_item_desc): * owning an item is boolean, so re-buying it refreshes the stored DTO rather than diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index fb300b7..26a0f94 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -130,6 +130,56 @@ export const SubscriptionDto = z.object({ ModifiedAt: z.string(), }) +/** + * One item as `GET /api/avatar/v4/items` serves it — camelCase, unlike the PascalCase + * records the sibling item endpoints hand back. `avatarItemId` is 0 and `tagList` empty + * for every item we have: neither the default catalog nor a storefront gift-drop carries + * them. + */ +export const AvatarItemV4Dto = z.object({ + avatarItemId: z.int(), + avatarItemDesc: z.string().describe('The comma-delimited item descriptor, commas and all'), + friendlyName: z.string(), + tooltip: z.string(), + tagList: z.string(), + avatarItemType: z.int(), + rarity: z.int(), + isBaseAvatarItem: z.boolean(), +}) + +/** + * `POST /api/checklist/v1|v2/complete` JSON body — which checklist row was finished. + * The client posts just `{ "ItemIndex": 1 }`; `Id` is the fallback key read when + * `ItemIndex` is absent or 0. + */ +export const CompleteChecklistRequest = z.object({ + ItemIndex: z.int().describe('The row’s index — what the client actually sends'), + Id: z.int().optional().describe('Fallback row id, read when ItemIndex is absent or 0'), +}) + +/** + * `POST /api/checklist/v1|v2/complete` — the balance-update envelope, the same shape + * buyItem answers with. `Balance` is the CHANGE applied, so a stubbed (ungranted) + * completion reports 0. `UpdateResponse` 303 is the checklist-reward context. + */ +export const ChecklistCompleteResponse = z.object({ + BalanceUpdates: z.array(z.object({ UpdateResponse: z.int(), Data: z.array(JsonObject) })), + Balance: z.int().describe('The change applied — 0 while completion is stubbed'), + CurrencyType: z.int(), + BalanceType: z.int().describe('-2 = account-wide'), +}) + +/** + * One row of the new-user checklist (`GET /api/checklist/v1|v2/current`). `Objective` is + * an `ObjectiveType` ordinal the client matches its own progress events against. + */ +export const ChecklistEntry = z.object({ + Order: z.int().describe('Position in the list, from 0'), + Objective: z.int().describe('ObjectiveType ordinal, e.g. 38 = SaveOutfitSlot'), + Count: z.int().describe('How many times the objective must happen'), + CreditAmount: z.int().describe('Tokens awarded on completion'), +}) + /** * `POST /api/CampusCard/v1/UpdateAndGetSubscription` — the caller's subscription, or `{}` * when they have none (which is everyone without the `developer` role). `{}` rather than a diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index a9d3536..d69749f 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -8,6 +8,7 @@ import { getOwnedInventionIds, getProgression, INVENTORY_INVENTION_SCHEMA_DDL, + OUTFIT_SCHEMA_DDL, PROGRESSION_SCHEMA_DDL, RECEIVED_GIFT_SCHEMA_DDL, } from '@repo/domain' @@ -33,7 +34,6 @@ import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../ch import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' -import { OUTFIT_SCHEMA_DDL } from '../../outfit-db' import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db' import type { Env } from '../../context' @@ -195,10 +195,15 @@ describe('econ endpoints', () => { expect(body[0]).toHaveProperty('AvatarItemDesc') }) - test('GET /api/avatar/v1/defaultbaseavataritems is an empty stub (no auth)', async () => { + test('GET /api/avatar/v1/defaultbaseavataritems returns the base items (no auth)', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/defaultbaseavataritems`) expect(res.status).toBe(200) - expect(await res.json()).toEqual([]) + const body = (await res.json()) as Array> + expect(body.map((i) => i.AvatarItemId)).toEqual([2184, 2918]) + // The client keys these off IsBaseAvatarItem, and the trailing comma in the desc + // is part of the item descriptor — both are served verbatim. + expect(body.every((i) => i.IsBaseAvatarItem === true)).toBe(true) + expect(body[0]?.AvatarItemDesc).toBe('c5d70cb4-71dd-4fe4-b719-34fe2073c611,') }) test('GET /api/avatar/v4/items 401s without a token', async () => { @@ -206,16 +211,34 @@ describe('econ endpoints', () => { expect(res.status).toBe(401) }) - test('GET /api/avatar/v4/items returns the item catalog with a valid token', async () => { + test('GET /api/avatar/v4/items serves the catalog in the camelCase v4 shape', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { headers: await bearer(), }) expect(res.status).toBe(200) - const body = (await res.json()) as unknown[] - expect(Array.isArray(body)).toBe(true) + const body = (await res.json()) as Array> expect(body.length).toBeGreaterThan(0) - expect(body[0]).toHaveProperty('AvatarItemDesc') - expect(body[0]).toHaveProperty('FriendlyName') + // Every key of the DTO is present on every item, and nothing PascalCase leaks + // through from the stored/bundled records. + for (const item of body) { + expect(Object.keys(item).sort()).toEqual([ + 'avatarItemDesc', + 'avatarItemId', + 'avatarItemType', + 'friendlyName', + 'isBaseAvatarItem', + 'rarity', + 'tagList', + 'tooltip', + ]) + } + expect(typeof body[0]?.avatarItemDesc).toBe('string') + expect(typeof body[0]?.friendlyName).toBe('string') + // The catalog carries no ids, tags or base flag — those default rather than + // being invented. + expect(body[0]?.avatarItemId).toBe(0) + expect(body[0]?.tagList).toBe('') + expect(body[0]?.isBaseAvatarItem).toBe(false) }) test('GET /api/avatar/v2 401s without a token', async () => { @@ -397,14 +420,53 @@ describe('econ endpoints', () => { expect(body.isCompleted).toBe(false) }) - test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => { - const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`) - expect(anon.status).toBe(401) - const res = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`, { - headers: await bearer(), + test('GET /api/checklist/v1|v2/current 401s without a token, serves the NUX list with one', async () => { + const expected = [ + { Order: 0, Objective: 38, Count: 1, CreditAmount: 25 }, + { Order: 1, Objective: 32, Count: 1, CreditAmount: 25 }, + { Order: 2, Objective: 2, Count: 1, CreditAmount: 25 }, + { Order: 3, Objective: 30, Count: 1, CreditAmount: 25 }, + { Order: 4, Objective: 6, Count: 1, CreditAmount: 25 }, + ] + // Both version paths are live and serve the same list. + for (const path of ['/api/checklist/v1/current', '/api/checklist/v2/current']) { + const anon = await exports.default.fetch(`${ORIGIN}${path}`) + expect(anon.status).toBe(401) + const res = await exports.default.fetch(`${ORIGIN}${path}`, { headers: await bearer() }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual(expected) + } + }) + + test('POST /api/checklist/v1|v2/complete 401s without a token, grants nothing with one', async () => { + for (const path of ['/api/checklist/v1/complete', '/api/checklist/v2/complete']) { + const anon = await exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ItemIndex: 1 }), + }) + expect(anon.status).toBe(401) + + const res = await exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { ...(await bearer('33')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ ItemIndex: 1 }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + BalanceUpdates: [{ UpdateResponse: 303, Data: [] }], + Balance: 0, + CurrencyType: 2, + BalanceType: -2, + }) + } + + // Stubbed, so completing rows does not move the balance — re-posting cannot farm + // tokens, and the checklist still lists every row. + const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, { + headers: await bearer('33'), }) - expect(res.status).toBe(200) - expect(await res.json()).toEqual([]) + expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }]) }) test('GET /api/itemWishlists/v1/wishlist/me 401s without a token, returns [] with one', async () => { @@ -816,9 +878,9 @@ describe('econ endpoints', () => { const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { headers: await bearer('20'), }) - const list = (await items.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }> - expect(list[0].FriendlyName).toBe('Bowtie (White)') - expect(list[0].AvatarItemDesc).toBe(gift.AvatarItemDesc) + const list = (await items.json()) as Array<{ avatarItemDesc: string; friendlyName: string }> + expect(list[0].friendlyName).toBe('Bowtie (White)') + expect(list[0].avatarItemDesc).toBe(gift.AvatarItemDesc) // And a pending gift box is waiting to be opened. const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { @@ -899,8 +961,8 @@ describe('econ endpoints', () => { const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { headers: await bearer('25'), }) - const list = (await items.json()) as Array<{ FriendlyName: string }> - expect(list.every((i) => i.FriendlyName !== 'Supreme Pizza')).toBe(true) + const list = (await items.json()) as Array<{ friendlyName: string }> + expect(list.every((i) => i.friendlyName !== 'Supreme Pizza')).toBe(true) // Buying it again stacks: a second instance, count summed to 2. expect((await buy()).status).toBe(200) @@ -966,8 +1028,8 @@ describe('econ endpoints', () => { const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { headers: await bearer('31'), }) - const list = (await items.json()) as Array<{ FriendlyName: string }> - expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true) + const list = (await items.json()) as Array<{ friendlyName: string }> + expect(list.every((i) => i.friendlyName !== 'Disc Skin (Coop)')).toBe(true) expect(first[0].Favorited).toBe(false) @@ -1066,8 +1128,8 @@ describe('econ endpoints', () => { const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { headers: await bearer('23'), }) - const list = (await items.json()) as Array<{ FriendlyName: string }> - expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true) + const list = (await items.json()) as Array<{ friendlyName: string }> + expect(list.every((i) => i.friendlyName !== 'Bowtie (White)')).toBe(true) }) /** @@ -1263,8 +1325,8 @@ describe('econ endpoints', () => { const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { headers: await bearer('24'), }) - const list = (await items.json()) as Array<{ FriendlyName: string }> - expect(list.some((i) => i.FriendlyName === 'Bowtie (White)')).toBe(true) + const list = (await items.json()) as Array<{ friendlyName: string }> + expect(list.some((i) => i.friendlyName === 'Bowtie (White)')).toBe(true) // Opening it again is a harmless no-op — still 200. const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, { @@ -1670,9 +1732,10 @@ describe('econ endpoints', () => { const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { headers: await bearer('76'), }) - const owned = (await items.json()) as Array<{ AvatarItemDesc: string }> + // v4 serves the camelCase DTO, unlike the PascalCase records on the gift box. + const owned = (await items.json()) as Array<{ avatarItemDesc: string }> if ((boxes[0]?.AvatarItemDesc ?? '') !== '') { - expect(owned.map((i) => i.AvatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc) + expect(owned.map((i) => i.avatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc) } // A second box can't roll the same prize: "an item that you don't have" excludes what @@ -1882,8 +1945,9 @@ describe('econ endpoints', () => { const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { headers: await bearer('82'), }) - const owned = (await items.json()) as Array<{ AvatarItemDesc: string }> - expect(owned.map((i) => i.AvatarItemDesc)).toContain(clothingBox?.AvatarItemDesc) + // v4 serves the camelCase DTO, unlike the PascalCase records on the gift box. + const owned = (await items.json()) as Array<{ avatarItemDesc: string }> + expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc) expect((await drainFrames()).map((f) => f.notificationType)).toEqual([ NotificationType.GiftPackageReceivedImmediate, NotificationType.PlayerProgressionLevelUpdate, @@ -2028,6 +2092,7 @@ describe('econ endpoints', () => { 'GET /api/avatar/v4/items', 'GET /api/challenge/v2/getCurrent', 'GET /api/checklist/v1/current', + 'GET /api/checklist/v2/current', 'GET /api/consumables/v2/getUnlocked', 'GET /api/equipment/v2/getUnlocked', 'GET /api/gamerewards/v1/pending', @@ -2051,6 +2116,8 @@ describe('econ endpoints', () => { 'POST /api/avatar/v3/saved/set', 'POST /api/avatar/v4/saved/set', 'POST /api/challenge/v2/updateProgress', + 'POST /api/checklist/v1/complete', + 'POST /api/checklist/v2/complete', 'POST /api/consumables/v1/consume', 'POST /api/gamerewards/v1/request', 'POST /api/objectives/v1/cleargroup', diff --git a/apps/econ/static/default-base-avatar-items.json b/apps/econ/static/default-base-avatar-items.json new file mode 100644 index 0000000..8538617 --- /dev/null +++ b/apps/econ/static/default-base-avatar-items.json @@ -0,0 +1,28 @@ +[ + { + "AvatarItemDesc": "c5d70cb4-71dd-4fe4-b719-34fe2073c611,", + "AvatarItemType": 0, + "PlatformMask": -1, + "FriendlyName": "(UGCTee_Shirt) ", + "Tooltip": "", + "Rarity": -1, + "TagList": "", + "AvatarItemId": 2184, + "IsBaseAvatarItem": true, + "CreatedAt": "2022-04-19T23:40:30.807Z", + "ThumbnailImage": "KXfytDhXzES2yco-rwqDSA.png" + }, + { + "AvatarItemDesc": "95a519de-f2cb-429c-b014-508477f20d42,", + "AvatarItemType": 0, + "PlatformMask": -1, + "FriendlyName": "(UGCPulloverHoodie_Shirt) ", + "Tooltip": "", + "Rarity": -1, + "TagList": "", + "AvatarItemId": 2918, + "IsBaseAvatarItem": true, + "CreatedAt": "2023-04-07T17:07:07.04Z", + "ThumbnailImage": "m4UIuZjNzEWsCP1gpZBgjg.png" + } +] diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 471325e..a941b9e 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -37,7 +37,7 @@ import { subRoomDataBlob, } from '@repo/domain' import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId } from '@repo/jwt' +import { generatePhotonAuthToken, validateAndGetAccountId } from '@repo/jwt' // The account-wide ban lives on a `report` row, whose table the api worker owns; its // db module is plain D1 queries with no runtime deps, so it imports cleanly here (the @@ -53,6 +53,7 @@ import { AUTHED, AvoidJuniorsRequest, AvoidJuniorsResponse, + ConnectionInfoResponse, EMPTY_OK, ExclusiveLoginResponse, form, @@ -65,6 +66,7 @@ import { MatchmakeRoomRequest, NotifyDisconnectRequest, PlayerDto, + QosRegion, RoomInstanceDto, RoomInstanceSummaryDto, StatusVisibilityRequest, @@ -102,6 +104,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 @@ -1586,6 +1636,41 @@ const app = new Hono() return c.json({ errorCode: 0, roomInstance: instance }) } ) + // Matchmake with no target. The client posts this when it needs an instance but isn't + // going anywhere in particular — at startup, and while sitting in Orientation. It + // answers the instance the player is ALREADY in, so it never warps anyone out of the + // room they're standing in; only a player with no live presence falls back to their + // dorm. Either way presence is re-committed, which refreshes its TTL. + .post( + '/matchmake/none', + describeRoute({ + tags: ['Navigation'], + summary: 'Matchmake with no target', + description: [ + 'Answers the instance the caller is already in, rather than sending them anywhere —', + 'this is what the client posts at startup and while in Orientation, so forcing a', + 'destination here would warp the player out of the room they are standing in. A', + 'caller with no live presence (their TTL lapsed, or they have never entered a room)', + 'falls back to their personal dorm. Re-commits presence either way, refreshing its', + 'TTL.', + ].join(' '), + security: AUTHED, + responses: { + 200: json(MatchmakeResponse, 'The caller’s current instance, or their dorm'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const presence = await getPresence(c.env.DB, id) + const current = presence?.roomInstance ?? (await playerDormInstance(c, id)) + await enterRoom(c, id, current) + return c.json({ errorCode: 0, roomInstance: current }) + } + ) + .post( '/matchmake/dorm', describeRoute({ @@ -1613,6 +1698,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 2690191..f592621 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -170,6 +170,65 @@ export const AvoidJuniorsRequest = 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 5955ca6..8991829 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -858,6 +858,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) @@ -899,6 +918,103 @@ 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; photonRealtimeAppId: 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 the same response hands out — a placeholder empty + // string until PHOTON_APPS moves to wrangler vars, so assert the two agree rather + // than pinning the placeholder itself. + expect(claims.aud).toBe(body.value.photonRealtimeAppId) + 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 @@ -952,6 +1068,45 @@ describe('auth-gated endpoints', () => { }) }) + test('POST /matchmake/none 401s without a token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' }) + expect(res.status).toBe(401) + }) + + test('POST /matchmake/none keeps the caller where they are, else falls back to the dorm', async () => { + const none = async (sub: string) => + (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/none`, { + method: 'POST', + headers: await bearer(sub), + }) + ).json()) as { errorCode: number; roomInstance: { roomId: number; roomInstanceId: number } } + + // Account 44 has never entered a room → their personal dorm, and a second call is + // idempotent now that presence holds it. + const fresh = await none('44') + expect(fresh.errorCode).toBe(0) + expect(fresh.roomInstance.roomId).toBeGreaterThan(2) + expect((await none('44')).roomInstance).toMatchObject({ + roomId: fresh.roomInstance.roomId, + roomInstanceId: fresh.roomInstance.roomInstanceId, + }) + + // Once in a real room, `none` must NOT warp them out of it — that is the whole + // point of the endpoint, since the client posts it while sitting in Orientation. + const entered = (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { + method: 'POST', + headers: await bearer('44'), + }) + ).json()) as { roomInstance: { roomId: number; roomInstanceId: number } } + expect(entered.roomInstance.roomId).toBe(2) + expect((await none('44')).roomInstance).toMatchObject({ + roomId: 2, + roomInstanceId: entered.roomInstance.roomInstanceId, + }) + }) + test('each player’s dorm gets a distinct global subroom id', async () => { // Dorms used to copy the template subroom verbatim, so every dorm carried SubRoomId 1. // With subrooms minted from the global sequence, each dorm gets its own unique id. @@ -2019,6 +2174,8 @@ describe('auth-gated endpoints', () => { expect([...documented].sort()).toEqual([ 'GET /player', 'GET /player/avoidjuniors', + 'GET /player/connection-info', + 'GET /player/qos', 'GET /room/{roomId}/instances', 'GET /rooms/requiring/developer', 'GET /rooms/requiring/rrplus', @@ -2027,6 +2184,7 @@ describe('auth-gated endpoints', () => { 'POST /matchmake/dorm', 'POST /matchmake/event/{eventId}', 'POST /matchmake/instance/{instanceId}', + 'POST /matchmake/none', 'POST /matchmake/player/{playerId}', 'POST /matchmake/room/{roomId}', 'POST /matchmake/room/{roomId}/{subRoomId}', diff --git a/apps/ns/README.md b/apps/ns/README.md index 6aa2f8e..48a7046 100644 --- a/apps/ns/README.md +++ b/apps/ns/README.md @@ -7,11 +7,18 @@ discover every service host (Accounts, API, Auth, Econ, Matchmaking, Notifications, …). Each host is built at runtime from the `DOMAIN` var (the base domain) plus the -service → subdomain map in `src/endpoints.ts`. `DOMAIN` is injected at deploy -time from `RECFLARE_DOMAIN` (see `run-wrangler-deploy`) and defaults to -`rec.example.com` in `wrangler.jsonc` for local dev. +service → subdomain map in `src/endpoints.ts`, with the `SUBDOMAINS` var applied +on top. Both vars are injected at deploy time from `RECFLARE_DOMAIN` and +`RECFLARE_SUBDOMAINS` (see `run-wrangler-deploy`) and default to +`rec.example.com` / `{}` in `wrangler.jsonc` for local dev. ## Updating endpoints - To change the base domain, set `RECFLARE_DOMAIN` (in `.env`) and redeploy. -- To add or rename a service host, edit the map in `src/endpoints.ts`. +- To point one service at a different host, add it to `RECFLARE_SUBDOMAINS` (in + `.env`) and redeploy. It's keyed by the service's _default_ subdomain — the + same object `run-wrangler-deploy` reads to pick a worker's host, so an entry + moves the deployed worker and the advertised host together. An entry for a + service with no worker (e.g. `{"moderation":"api"}`) is a pure client-side + redirect onto a host another worker already serves. +- To add or rename a service, edit the map in `src/endpoints.ts`. diff --git a/apps/ns/src/context.ts b/apps/ns/src/context.ts index 3174ff3..3c9e487 100644 --- a/apps/ns/src/context.ts +++ b/apps/ns/src/context.ts @@ -8,6 +8,14 @@ export type Env = SharedHonoEnv & { * for local dev and tests. */ DOMAIN: string + + /** + * Per-service subdomain overrides as a raw JSON object keyed by default subdomain, + * e.g. `{"moderation":"api"}`. The operator's `RECFLARE_SUBDOMAINS`, injected at deploy + * time via `--var SUBDOMAINS`; defaults to `{}` in `wrangler.jsonc`. See + * `parseOverrides` in `endpoints.ts`. + */ + SUBDOMAINS: string } /** Variables can be extended */ diff --git a/apps/ns/src/endpoints.ts b/apps/ns/src/endpoints.ts index 5d66976..7008a0a 100644 --- a/apps/ns/src/endpoints.ts +++ b/apps/ns/src/endpoints.ts @@ -1,9 +1,11 @@ /** - * Service-discovery map: service label → subdomain. The game client fetches the + * Service-discovery map: service label → default subdomain. The game client fetches the * generated `{ label: "https://." }` document from `/`. * * The base domain is injected at deploy time via the `DOMAIN` var (see - * `run-wrangler-deploy`), so the real domain never lives in a versioned file. + * `run-wrangler-deploy`), so the real domain never lives in a versioned file. The + * subdomains here are defaults — an operator redirects any of them from `.env`, see + * `applyOverrides` below. */ const SERVICE_SUBDOMAINS = { Accounts: 'accounts', @@ -44,9 +46,48 @@ const SERVICE_SUBDOMAINS = { WWW: 'www', } as const -/** Builds the endpoints document for `domain`, e.g. `rec.example.com`. */ -export function buildEndpoints(domain: string): Record { +/** + * Parses the `SUBDOMAINS` var — the operator's `RECFLARE_SUBDOMAINS` object, injected at + * deploy time by `run-wrangler-deploy`. + * + * It is keyed by the DEFAULT subdomain above, not by the service label, because the deploy + * script reads the very same object keyed by a worker's directory name — and every worker's + * directory name is its default subdomain. So one `.env` entry moves both sides at once: + * `{"playersettings":"settings"}` both deploys the `playersettings` worker onto + * `settings.` and advertises that host to the client. Entries naming a service with + * no worker of its own are pure client-side redirects — `{"moderation":"api"}` points the + * client's Moderation calls at the `api` worker, which is where the + * `/api/PlayerReporting/…` routes actually live. + * + * A malformed value is ignored rather than thrown: this document is the first thing the + * client fetches, so a typo in `.env` should cost one redirect, not every service host. + */ +function parseOverrides(subdomains: string | undefined): Record { + if (!subdomains) return {} + let parsed: unknown + try { + parsed = JSON.parse(subdomains) + } catch { + return {} + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {} return Object.fromEntries( - Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [label, `https://${sub}.${domain}`]) + Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '' + ) + ) +} + +/** + * Builds the endpoints document for `domain`, e.g. `rec.example.com`, applying any + * subdomain overrides from `subdomains` (the raw `SUBDOMAINS` var JSON). + */ +export function buildEndpoints(domain: string, subdomains?: string): Record { + const overrides = parseOverrides(subdomains) + return Object.fromEntries( + Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [ + label, + `https://${overrides[sub] ?? sub}.${domain}`, + ]) ) } diff --git a/apps/ns/src/ns.app.ts b/apps/ns/src/ns.app.ts index 259c069..bd5bb24 100644 --- a/apps/ns/src/ns.app.ts +++ b/apps/ns/src/ns.app.ts @@ -29,6 +29,6 @@ const app = new Hono() .notFound(withNotFound()) // Endpoints document, derived from the deploy-time base domain. - .get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN))) + .get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN, c.env.SUBDOMAINS))) export default app diff --git a/apps/ns/src/test/integration/api.test.ts b/apps/ns/src/test/integration/api.test.ts index b1cb750..a9abe8b 100644 --- a/apps/ns/src/test/integration/api.test.ts +++ b/apps/ns/src/test/integration/api.test.ts @@ -18,6 +18,19 @@ describe('ns endpoints', () => { expect(body).toEqual(buildEndpoints(TEST_DOMAIN)) }) + test('a subdomain override redirects that service only', () => { + const endpoints = buildEndpoints(TEST_DOMAIN, '{"moderation":"api"}') + expect(endpoints.Moderation).toBe(`https://api.${TEST_DOMAIN}`) + expect(endpoints.API).toBe(`https://api.${TEST_DOMAIN}`) + expect(endpoints.Accounts).toBe(`https://accounts.${TEST_DOMAIN}`) + }) + + test('a malformed override object is ignored', () => { + for (const bad of ['', '{', 'null', '[]', '{"moderation":42}', '{"moderation":""}']) { + expect(buildEndpoints(TEST_DOMAIN, bad)).toEqual(buildEndpoints(TEST_DOMAIN)) + } + }) + test('unknown path returns 404', async () => { const res = await exports.default.fetch(`${ORIGIN}/nope`) expect(res.status).toBe(404) diff --git a/apps/ns/wrangler.jsonc b/apps/ns/wrangler.jsonc index 92605e6..1f12a2d 100644 --- a/apps/ns/wrangler.jsonc +++ b/apps/ns/wrangler.jsonc @@ -15,6 +15,7 @@ "vars": { "ENVIRONMENT": "development", // overridden during deployment "SENTRY_RELEASE": "unknown", // overridden during deployment - "DOMAIN": "rec.example.com" // base domain; overridden during deployment + "DOMAIN": "rec.example.com", // base domain; overridden during deployment + "SUBDOMAINS": "{}" // per-service subdomain overrides; overridden during deployment } } diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 664405b..f02b983 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -33,6 +33,10 @@ export interface Account { username: string displayName: string profileImage: string + /** Profile banner image key. No route sets it yet, so it's `""` on every account. */ + bannerImage: string + /** The emoji shown beside the display name. No route sets it yet — always `""`. */ + displayEmoji: string isJunior: boolean platforms: number personalPronouns: number @@ -164,6 +168,8 @@ export function defaultAccount(id: number, overrides: Partial = {}): Ac username: `Player${id}`, displayName: `Player${id}`, profileImage: 'DefaultProfileImage.jpg', + bannerImage: '', + displayEmoji: '', isJunior: false, platforms: 0, personalPronouns: 0, diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 2139759..ff2db19 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -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' diff --git a/apps/econ/src/outfit-db.ts b/packages/domain/src/outfits-db.ts similarity index 57% rename from apps/econ/src/outfit-db.ts rename to packages/domain/src/outfits-db.ts index 66f95c9..3c22aec 100644 --- a/apps/econ/src/outfit-db.ts +++ b/packages/domain/src/outfits-db.ts @@ -1,7 +1,6 @@ /** * Saved outfits on the shared `recflare` D1 database — the outfit slots a player - * saves from the avatar screen (`POST /api/avatar/v3/saved/set`) and loads back from - * `GET /api/avatar/v3/saved`. + * 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, @@ -9,11 +8,19 @@ * 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 this table and its migration (apps/econ/migrations/ - * 0002_outfit.sql). + * 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 migrations 0002_outfit.sql) — also used to build the table in tests. */ +/** 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, @@ -27,13 +34,15 @@ export const OUTFIT_SCHEMA_DDL: string[] = [ * A saved outfit, as the client posts it. `Slot` is the outfit slot it occupies (the * `set_id` column) — saving to a slot the player already used overwrites it, which is * exactly what the avatar screen's "save over this outfit" does. The rest of the - * payload (PreviewImageName, OutfitSelections, FaceFeatures, SkinColor, HairColor, - * CustomAvatarItems, …) is stored and served back untouched. + * payload is stored and served back untouched. */ export interface Outfit extends Record { 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 { const { results } = await db @@ -43,6 +52,19 @@ export async function getOutfits(db: D1Database, accountId: number): Promise 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 { + 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 diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index 3ff1a3b..90ae0a0 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -33,11 +33,21 @@ export const PRESENCE_TTL_SECONDS = 900 export const GAME_VERSION = '20230414' /** - * Client builds this server treats as current. `GAME_VERSION` is the one we report for - * ourselves; the rest are additional builds `/api/versioncheck/v4` answers "current" - * for, so a player on one of them isn't pushed into an update loop. + * 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, '20250424.01'] +export const SUPPORTED_GAME_VERSIONS: string[] = [ + GAME_VERSION, + '20230616', + '20231207', + '20250424.01', +] /** Whether a client-supplied build (the version check's `?v=`) is one we serve. */ export function isSupportedGameVersion(version: string | null | undefined): boolean { 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 bbae3b8..ae782a1 100644 --- a/packages/jwt/src/jwt.ts +++ b/packages/jwt/src/jwt.ts @@ -108,6 +108,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, diff --git a/packages/tools/bin/run-wrangler-deploy b/packages/tools/bin/run-wrangler-deploy index 1315f03..5d4dd5f 100755 --- a/packages/tools/bin/run-wrangler-deploy +++ b/packages/tools/bin/run-wrangler-deploy @@ -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 \