From c5ea04b39d9d01a8015e2ba6c344ef12e944395f Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 11 Aug 2026 15:47:35 -0400 Subject: [PATCH] [econ] rrplus for developer, for now --- apps/econ/README.md | 2 +- apps/econ/src/econ.app.ts | 100 +++++++++++++++++++-- apps/econ/src/openapi.ts | 42 ++++++++- apps/econ/src/test/integration/api.test.ts | 68 +++++++++++--- 4 files changed, 189 insertions(+), 23 deletions(-) diff --git a/apps/econ/README.md b/apps/econ/README.md index f2ee6e8..d604203 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -49,7 +49,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali | POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box | | GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) | | GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) | -| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) | +| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` | | GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) | The app runs with `strict: false`, so trailing-slash variants match (the client posts diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index f7d4b35..1d9416c 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -14,7 +14,7 @@ import { ownsInvention, } from '@repo/domain' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId } from '@repo/jwt' +import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' // Invention storage (owned by the `api` worker, on this same `recflare` database). // Imported directly rather than copied: these are plain D1 helpers with no bindings of @@ -120,6 +120,16 @@ async function authedId(c: Context): Promise { return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } +/** + * The `role` claim from a Bearer token — the operator-granted roles the auth worker stamps + * from the account's flags, so a plain player's token is just `['gameClient']`. `null` when + * the request carries no valid token; an empty array means a valid token with no roles. + * Shaped to mirror {@link authedId}. + */ +async function authedRoles(c: Context): Promise { + return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get()) +} + /** Results.Unauthorized() equivalent — 401 with empty body. */ function unauthorized(c: Context) { return c.body(null, 401) @@ -335,6 +345,60 @@ async function pushBalancePurchase( } } +/** The operator-granted role that comes with a complimentary subscription. */ +const DEVELOPER_ROLE = 'developer' + +/** `SubscriptionLevel.Gold`. 1 is Platinum. */ +const SUBSCRIPTION_LEVEL_GOLD = 0 + +/** `SubscriptionPeriod.Year`. 0 is Month, 2 ThreeMonth, 3 SixMonth. */ +const SUBSCRIPTION_PERIOD_YEAR = 1 + +/** + * `PlatformType.All` (-1) — the subscription belongs to no single store, which is the honest + * answer when no store sold it. The rest of the enum: 0 Steam, 1 Oculus, 2 PlayStation, + * 3 Xbox, 4 RecNet, 5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico. + */ +const SUBSCRIPTION_PLATFORM_ALL = -1 + +/** The id every reported subscription carries — a placeholder, since none is stored. */ +const STUB_SUBSCRIPTION_ID = 1 + +/** + * The complimentary subscription a `developer` account reports — Rec Room Plus, which the + * client's API calls a `CampusCard`. + * + * Nothing here sells subscriptions, so holding the role IS the subscription: it's how the + * paid-tier surfaces get exercised without a store. Every field is computed per call and + * none of it is persisted, so this is not a record of anything — revoking the role revokes + * the subscription, and no expiry sweep or renewal exists. + * + * `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one + * lapses on a day nobody is expecting, and the client would start showing an expired + * subscription with no way to renew it. `IsAutoRenewing` tells the client the same thing. + * The dates are milliseconds-precision ISO like the rest of this worker's timestamps. + */ +function developerSubscription(accountId: number) { + const now = new Date() + // Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next + // year whether or not a leap day falls in between. + const expires = new Date(now) + expires.setUTCFullYear(expires.getUTCFullYear() + 1) + return { + SubscriptionId: STUB_SUBSCRIPTION_ID, + RecNetPlayerId: accountId, + PlatformType: SUBSCRIPTION_PLATFORM_ALL, + PlatformId: '', + PlatformPurchaseId: '', + Level: SUBSCRIPTION_LEVEL_GOLD, + Period: SUBSCRIPTION_PERIOD_YEAR, + ExpirationDate: expires.toISOString(), + IsAutoRenewing: true, + CreatedAt: now.toISOString(), + ModifiedAt: now.toISOString(), + } +} + /** * Project a stored avatar into the public render subset returned by * `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar @@ -2272,16 +2336,42 @@ const app = new Hono({ strict: false }) c.json([]) ) - // Subscription lookup. Returns both fields null with no auth. + // Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to + // buy one from, so the `developer` role stands in for a paid subscription: a developer + // reports an active Gold year, everyone else reports none. Nothing is stored — see + // `developerSubscription`. + // + // Auth is OPTIONAL, and a missing or invalid token answers "no subscription" rather than + // 401: the client posts this while loading, so an error here can stall its load + // orchestration, and "you aren't subscribed" is the truthful answer for an anonymous + // caller anyway. The role is read from the token's `role` claim, never from the body. .post( '/api/CampusCard/v1/UpdateAndGetSubscription', describeRoute({ tags: ['Econ'], summary: 'Subscription lookup', - description: 'No subscriptions yet — both fields null. No auth.', - responses: { 200: json(SubscriptionResponse, 'Both fields null') }, + description: [ + 'The caller’s Rec Room Plus subscription. Nothing sells subscriptions here, so the', + 'operator-granted `developer` role stands in for one: a developer’s token reports an', + 'active Gold (`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All),', + 'expiring a year from the call, and every other caller gets `{}`. Auth is optional —', + 'a missing or invalid token reads as “not subscribed”, not 401. Nothing is persisted:', + 'the role IS the subscription, so revoking it revokes this.', + ].join(' '), + responses: { + 200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'), + }, }), - (c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null }) + async (c) => { + const roles = await authedRoles(c) + if (!roles?.includes(DEVELOPER_ROLE)) return c.json({}) + const id = await authedId(c) + if (id === null) return c.json({}) + return c.json({ + Subscription: developerSubscription(id), + PlatformAccountSubscribedPlayerId: null, + }) + } ) // The generated spec. Documentation only — no request is validated against it (see diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 3968ac9..a60f491 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -105,12 +105,46 @@ export const CustomAvatarItemsResponse = z.object({ TotalResults: z.int(), }) -/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both fields null (no subs yet). */ -export const SubscriptionResponse = z.object({ - subscription: z.null(), - platformAccountSubscribedPlayerId: z.null(), +/** + * A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one, + * so this is the complimentary subscription a `developer` account reports — see + * `developerSubscription` in econ.app.ts for why each field reads the way it does. + */ +export const SubscriptionDto = z.object({ + SubscriptionId: z.int().describe('Placeholder — no subscription is stored'), + RecNetPlayerId: z.int().describe('The subscribed player: the caller'), + PlatformType: z + .int() + .nullable() + .describe( + 'Which store sold it: -1 All, 0 Steam, 1 Oculus, 2 PlayStation, 3 Xbox, 4 RecNet, ' + + '5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico. -1 here — no store did' + ), + PlatformId: z.string().describe('Empty — no store account behind it'), + PlatformPurchaseId: z.string().describe('Empty — nothing was purchased'), + Level: z.int().describe('0 Gold, 1 Platinum'), + Period: z.int().describe('0 Month, 1 Year, 2 ThreeMonth, 3 SixMonth'), + ExpirationDate: z.string().describe('ISO 8601 UTC; a year out, recomputed per call'), + IsAutoRenewing: z.boolean(), + CreatedAt: z.string(), + ModifiedAt: z.string(), }) +/** + * `POST /api/CampusCard/v1/UpdateAndGetSubscription` — the caller's subscription, or `{}` + * when they have none (which is everyone without the `developer` role). `{}` rather than a + * `Subscription: null` envelope: an absent key is how the client reads "not subscribed". + */ +export const SubscriptionResponse = z.union([ + z.object({ + Subscription: SubscriptionDto, + PlatformAccountSubscribedPlayerId: z + .null() + .describe('The platform account holding the sub, when it is shared. Never set here'), + }), + z.object({}).describe('`{}` — no subscription'), +]) + /** `POST /api/challenge/v2/updateProgress` — the identifying fields echoed back. */ export const ChallengeProgressResponse = z.object({ ChallengeMapId: z.int(), diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 4b21d80..da15e09 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -162,10 +162,17 @@ function b64url(input: ArrayBuffer | string): string { return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } -async function bearer(sub = '42'): Promise> { +/** + * A bearer token for `sub`. `roles` becomes the `role` claim the auth worker stamps from an + * account's flags — pass `['gameClient', 'developer']` for an elevated account; the default + * is no claim at all, which reads as no roles. + */ +async function bearer(sub = '42', roles?: string[]): Promise> { const now = Math.floor(Date.now() / 1000) + const claims = + roles === undefined ? { sub, exp: now + 3600 } : { sub, exp: now + 3600, role: roles } const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( - JSON.stringify({ sub, exp: now + 3600 }) + JSON.stringify(claims) )}` const key = await crypto.subtle.importKey( 'raw', @@ -1923,18 +1930,53 @@ describe('econ endpoints', () => { expect(await res.json()).toEqual([]) }) - test('POST /api/CampusCard/v1/UpdateAndGetSubscription returns null fields', async () => { - const res = await exports.default.fetch( - `${ORIGIN}/api/CampusCard/v1/UpdateAndGetSubscription`, - { - method: 'POST', - } - ) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ - subscription: null, - platformAccountSubscribedPlayerId: null, + const getSubscription = async (headers: Record = {}) => + exports.default.fetch(`${ORIGIN}/api/CampusCard/v1/UpdateAndGetSubscription`, { + method: 'POST', + headers, }) + + test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => { + const res = await getSubscription(await bearer('205', ['gameClient', 'developer'])) + expect(res.status).toBe(200) + const body = (await res.json()) as { + Subscription: Record + PlatformAccountSubscribedPlayerId: null + } + expect(body.PlatformAccountSubscribedPlayerId).toBeNull() + expect(body.Subscription).toMatchObject({ + SubscriptionId: 1, + // The subscribed player is the caller, not a fixed id. + RecNetPlayerId: 205, + // -1 All: no store sold this. 0 = Gold (1 is Platinum), 1 = Year. + PlatformType: -1, + PlatformId: '', + PlatformPurchaseId: '', + Level: 0, + Period: 1, + IsAutoRenewing: true, + }) + + // The subscription runs a year from the call rather than to a hard-coded date, so it + // cannot lapse on a day nobody is expecting. + const created = new Date(body.Subscription.CreatedAt as string) + const expires = new Date(body.Subscription.ExpirationDate as string) + expect(body.Subscription.ModifiedAt).toBe(body.Subscription.CreatedAt) + expect(expires.getTime()).toBeGreaterThan(Date.now()) + expect(expires.getUTCFullYear()).toBe(created.getUTCFullYear() + 1) + expect(expires.getUTCMonth()).toBe(created.getUTCMonth()) + expect(expires.getUTCDate()).toBe(created.getUTCDate()) + }) + + test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => { + // A plain player's token: valid, but no elevated role. + expect(await (await getSubscription(await bearer('206', ['gameClient']))).json()).toEqual({}) + // A token with no `role` claim at all. + expect(await (await getSubscription(await bearer('206'))).json()).toEqual({}) + // No token: "not subscribed" rather than 401, so a loading client isn't stalled. + const anon = await getSubscription() + expect(anon.status).toBe(200) + expect(await anon.json()).toEqual({}) }) test('unknown path returns 404', async () => {