mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
[econ] rrplus for developer, for now
This commit is contained in:
+1
-1
@@ -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 |
|
| 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/mine` | | The player's room keys (stub `[]`) |
|
||||||
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (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) |
|
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||||||
|
|
||||||
The app runs with `strict: false`, so trailing-slash variants match (the client posts
|
The app runs with `strict: false`, so trailing-slash variants match (the client posts
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
ownsInvention,
|
ownsInvention,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
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).
|
// 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
|
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
|
||||||
@@ -120,6 +120,16 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
|||||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
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<App>): Promise<string[] | null> {
|
||||||
|
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
|
}
|
||||||
|
|
||||||
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||||||
function unauthorized(c: Context<App>) {
|
function unauthorized(c: Context<App>) {
|
||||||
return c.body(null, 401)
|
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
|
* 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
|
* `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar
|
||||||
@@ -2272,16 +2336,42 @@ const app = new Hono<App>({ strict: false })
|
|||||||
c.json([])
|
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(
|
.post(
|
||||||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Econ'],
|
tags: ['Econ'],
|
||||||
summary: 'Subscription lookup',
|
summary: 'Subscription lookup',
|
||||||
description: 'No subscriptions yet — both fields null. No auth.',
|
description: [
|
||||||
responses: { 200: json(SubscriptionResponse, 'Both fields null') },
|
'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
|
// The generated spec. Documentation only — no request is validated against it (see
|
||||||
|
|||||||
@@ -105,12 +105,46 @@ export const CustomAvatarItemsResponse = z.object({
|
|||||||
TotalResults: z.int(),
|
TotalResults: z.int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both fields null (no subs yet). */
|
/**
|
||||||
export const SubscriptionResponse = z.object({
|
* A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one,
|
||||||
subscription: z.null(),
|
* so this is the complimentary subscription a `developer` account reports — see
|
||||||
platformAccountSubscribedPlayerId: z.null(),
|
* `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. */
|
/** `POST /api/challenge/v2/updateProgress` — the identifying fields echoed back. */
|
||||||
export const ChallengeProgressResponse = z.object({
|
export const ChallengeProgressResponse = z.object({
|
||||||
ChallengeMapId: z.int(),
|
ChallengeMapId: z.int(),
|
||||||
|
|||||||
@@ -162,10 +162,17 @@ function b64url(input: ArrayBuffer | string): string {
|
|||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bearer(sub = '42'): Promise<Record<string, string>> {
|
/**
|
||||||
|
* 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<Record<string, string>> {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
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(
|
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(
|
const key = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -1923,18 +1930,53 @@ describe('econ endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription returns null fields', async () => {
|
const getSubscription = async (headers: Record<string, string> = {}) =>
|
||||||
const res = await exports.default.fetch(
|
exports.default.fetch(`${ORIGIN}/api/CampusCard/v1/UpdateAndGetSubscription`, {
|
||||||
`${ORIGIN}/api/CampusCard/v1/UpdateAndGetSubscription`,
|
method: 'POST',
|
||||||
{
|
headers,
|
||||||
method: 'POST',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
expect(await res.json()).toEqual({
|
|
||||||
subscription: null,
|
|
||||||
platformAccountSubscribedPlayerId: null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
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 () => {
|
test('unknown path returns 404', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user