[econ] influencer endpoints for fun

This commit is contained in:
Devin Zuczek
2026-10-08 14:46:08 -04:00
parent cebeafeb40
commit 3916e40c2e
4 changed files with 312 additions and 60 deletions
+123 -30
View File
@@ -18,6 +18,7 @@ import {
listInfluencerIds,
ownsInvention,
setOutfit,
updateAccount,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt'
@@ -113,7 +114,6 @@ import {
form,
GameRewardRequest,
InfluencerIdsResponse,
InfluencerTierResponse,
IsOnWishlistBulkRequest,
IsOnWishlistBulkResponse,
ItemPurchaseInfoList,
@@ -133,6 +133,7 @@ import {
PurchaseRoomKeyWithCurrencyRequest,
PurchaseRoomKeyWithCurrencyResponse,
ReferralProgressResponse,
RemoveInfluencerBody,
RoomConsumableDto,
RoomConsumableEnvelope,
RoomCurrencyDto,
@@ -148,6 +149,9 @@ import {
SaveOutfitRequest,
SaveOutfitV4Response,
SubscriptionResponse,
SupportedInfluencerResponse,
SupportInfluencerBody,
SupportInfluencerResponse,
UgcPurchasableBulkRequest,
UgcPurchasableItemList,
UNAUTHORIZED_RESPONSE,
@@ -732,11 +736,17 @@ async function pushBalancePurchase(
}
/**
* The influencer partner tier every account has here — the "not an influencer" one. It is
* the whole body of both `/api/influencerpartnerprogram/influencer` and `…/myinfluencer`,
* served as a bare number rather than wrapped in anything.
* The influencer an account supports in the partner program, as its reads serve it — the
* `supportedInfluencerId` on the account, or 0 for nobody (and for an account that does not
* exist, which supports nobody too). The whole body of `/api/influencerpartnerprogram/influencer`,
* `…/myinfluencer`, served as a bare number rather than wrapped in anything; the `…/support`
* and `…/remove` writes answer a status envelope instead. The stored id is served as-is: it was an influencer when picked, and a later
* revoke of their flag drops them from `…/influencers` but not from their supporters.
*/
const NOT_AN_INFLUENCER = 0
async function supportedInfluencer(db: D1Database, accountId: number): Promise<number> {
const account = await getAccount(db, accountId)
return account?.supportedInfluencerId ?? 0
}
/**
* Whether the caller holds a Rec Room Plus subscription — the ONE definition, shared by
@@ -6746,74 +6756,157 @@ const app = new Hono<App>({ strict: false })
}
)
// One account's standing in the influencer partner program. NOBODY here has one: this
// server runs no such program, so the answer is the literal `0` — the "not an influencer"
// tier — for every account.
// The influencer one account SUPPORTS in the partner program, by account id — every
// player picks one (`…/support` below), and this is how anyone reads anyone's pick.
//
// A BARE NUMBER is the whole body, like `…/makerai/checkfreetrialeligibility`'s bare
// `false`, not a number wrapped in an object. This used to answer 404 with an empty body;
// the tier is what the client actually reads.
//
// `accountId` names the account being asked about. It makes no difference to the answer
// while nobody is an influencer, but it is read rather than ignored so this stays the
// question it looks like — the caller's own standing is `…/myinfluencer` below.
// `false`, not a number wrapped in an object; 0 is "supports nobody", which is also what
// an account that does not exist answers. The caller's own pick is `…/myinfluencer`.
.get(
'/api/influencerpartnerprogram/influencer',
describeRoute({
tags: ['Econ'],
summary: 'An account’s influencer partner program tier',
summary: 'The influencer an account supports',
description: [
'The partner tier of the account named by `accountId`, as a BARE NUMBER — the whole',
'body is `0`, not an object around it. Always 0: this server runs no partner program,',
'so no account is an influencer. Auth-gated; a missing or invalid token is a 401.',
'The account id of the influencer the account named by `accountId` supports, as a',
'BARE NUMBER — the whole body is the id, not an object around it; `0` when they',
'support nobody (or no such account exists). Auth-gated; a missing or invalid token',
'is a 401. A missing or non-numeric `accountId` is a 400.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'accountId',
in: 'query',
required: false,
description: 'The account being asked about. Every account answers 0.',
required: true,
description: 'The account being asked about.',
schema: { type: 'integer' },
},
],
responses: {
200: json(InfluencerTierResponse, 'The account’s tier — always 0'),
200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'),
400: { description: '`accountId` missing or not an integer (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(NOT_AN_INFLUENCER)
const accountId = Number.parseInt(c.req.query('accountId') ?? '', 10)
if (!Number.isInteger(accountId)) return c.body(null, 400)
return c.json(await supportedInfluencer(c.env.DB, accountId))
}
)
// The same question about the CALLER — the `my` form, which names no account because the
// token already does. Same bare `0`, for the same reason: nobody here is an influencer.
// token already does. Same bare number, same 0 for nobody.
//
// Its own route rather than an alias of the one above, because the two differ in who they
// are about; they agree today only because the answer is currently the same for everyone.
// are about.
.get(
'/api/influencerpartnerprogram/myinfluencer',
describeRoute({
tags: ['Econ'],
summary: 'The caller’s influencer partner program tier',
summary: 'The influencer the caller supports',
description: [
'The caller’s own partner tier — the `my` form of the route above, taking the account',
'from the token rather than a query parameter. A BARE NUMBER, always `0`: this server',
'runs no partner program. Auth-gated; a missing or invalid token is a 401.',
'The account id of the influencer the caller supports — the `my` form of the route',
'above, taking the account from the token rather than a query parameter. A BARE',
'NUMBER; `0` when they support nobody. Auth-gated; a missing or invalid token is a 401.',
].join(' '),
security: AUTHED,
responses: {
200: json(InfluencerTierResponse, 'The caller’s tier — always 0'),
200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(NOT_AN_INFLUENCER)
return c.json(await supportedInfluencer(c.env.DB, id))
}
)
// Pick the influencer the caller supports: `influencerAccountId=<id>`, form-encoded. ONE
// per player, stored on the account as `supportedInfluencerId`; posting again replaces
// the pick. The id has to name an account currently flagged `isInfluencer` — anything
// else, the caller's own id included, is a 400 and changes nothing. The reply is a bare
// `{ Success, Error, error_id }` status envelope — no `Value`: the client reads the pick
// back from `…/myinfluencer` rather than from this reply.
.post(
'/api/influencerpartnerprogram/support',
describeRoute({
tags: ['Econ'],
summary: 'Pick the influencer the caller supports',
description: [
'Set the ONE influencer the caller supports in the partner program, replacing any',
'earlier pick. `influencerAccountId` must name an account that is currently an',
'influencer (listed by `…/influencers`); the caller cannot support themselves. The',
'reply is a `{ Success, Error, error_id }` status envelope with no `Value`; read the',
'new pick back from `…/myinfluencer`.',
].join(' '),
security: AUTHED,
requestBody: form(SupportInfluencerBody, 'The influencer to support'),
responses: {
200: json(SupportInfluencerResponse, 'The pick was recorded'),
400: {
description:
'`influencerAccountId` missing, not an integer, the caller’s own id, or not an influencer (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const raw = body.influencerAccountId
const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10)
if (!Number.isInteger(influencerId) || influencerId === id) return c.body(null, 400)
const influencer = await getAccount(c.env.DB, influencerId)
if (influencer?.isInfluencer !== true) return c.body(null, 400)
await updateAccount(c.env.DB, id, { supportedInfluencerId: influencerId })
return c.json({ Success: true, Error: null, error_id: null })
}
)
// Stop supporting an influencer: `influencerAccountId=<id>`, form-encoded, the same body
// as `…/support`. The field is UNSET on the account (dropped from the JSON, not written
// as 0), so `…/myinfluencer` goes back to 0. The id has to be an integer (else 400), but
// it only has to NAME THE CURRENT PICK to do anything: naming anyone else — a stale pick
// from before a `…/support` that replaced it, or nobody at all — is a no-op that still
// answers success, like a wishlist remove of something not listed. Guarding on the match
// keeps a stale client from clearing a pick it never saw.
.post(
'/api/influencerpartnerprogram/remove',
describeRoute({
tags: ['Econ'],
summary: 'Stop supporting an influencer',
description: [
'Clear the influencer the caller supports in the partner program. `influencerAccountId`',
'names the current pick; the field is unset and `…/myinfluencer` answers 0 again.',
'An id that is not the current pick changes nothing and still answers success. The',
'reply is the same `{ Success, Error, error_id }` status envelope as `…/support`.',
].join(' '),
security: AUTHED,
requestBody: form(RemoveInfluencerBody, 'The influencer to stop supporting'),
responses: {
200: json(SupportInfluencerResponse, 'The pick was cleared (or was not this one)'),
400: { description: '`influencerAccountId` missing or not an integer (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const raw = body.influencerAccountId
const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10)
if (!Number.isInteger(influencerId)) return c.body(null, 400)
if ((await supportedInfluencer(c.env.DB, id)) === influencerId) {
// `undefined` wins the spread in updateAccount and JSON.stringify drops the key.
await updateAccount(c.env.DB, id, { supportedInfluencerId: undefined })
}
return c.json({ Success: true, Error: null, error_id: null })
}
)
+34 -8
View File
@@ -235,16 +235,42 @@ export const InfluencerIdsResponse = z.object({
})
/**
* `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` — one account's
* standing in the partner program.
* `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` — the account id of
* the influencer an account supports.
*
* A BARE NUMBER, not an object: the body is the literal `0`, which is the "not an
* influencer" tier. Nobody on this server is one, so 0 is the answer for every account, the
* caller's own included.
* A BARE NUMBER, not an object: the body is the literal id, or `0` when the account
* supports nobody. The write (`POST …/support`) does NOT answer in this shape; see
* `SupportInfluencerResponse`.
*/
export const InfluencerTierResponse = z
.literal(0)
.describe('The account’s partner tier. Always 0 — nobody here is an influencer')
export const SupportedInfluencerResponse = z
.int()
.describe('The account id of the influencer this account supports; 0 for nobody')
/** `POST /api/influencerpartnerprogram/support` — the influencer the caller picks. */
export const SupportInfluencerBody = z.object({
influencerAccountId: z
.int()
.describe('The account id of the influencer to support. Must be flagged `isInfluencer`'),
})
/** `POST /api/influencerpartnerprogram/remove` — the influencer the caller stops supporting. */
export const RemoveInfluencerBody = z.object({
influencerAccountId: z
.int()
.describe('The account id of the influencer to stop supporting — the caller’s current pick'),
})
/**
* The reply to `POST /api/influencerpartnerprogram/support` and `…/remove` — a bare
* `{ Success, Error, error_id }` status envelope with NO `Value`: the pick is read back from
* `…/myinfluencer`, not from this reply. The same mixed casing as `RoomCurrencyEnvelope`
* (PascalCase `Success`/`Error` beside a lowercase `error_id`), minus the payload.
*/
export const SupportInfluencerResponse = z.object({
Success: z.boolean().describe('Always true — every refusal is an empty 400 instead'),
Error: z.null().describe('Always null'),
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
})
/**
* `GET /api/incentivizedreferrals/progress` — how far the caller has got with the
+146 -22
View File
@@ -9,6 +9,7 @@ import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import {
getAccount,
getOwnedInventionIds,
getPendingGifts,
getProgression,
@@ -6944,43 +6945,164 @@ describe('econ endpoints', () => {
expect(res.status).toBe(401)
})
test('GET /api/influencerpartnerprogram/influencer answers a bare 0', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=220`,
{ headers: await bearer('206') }
)
test('POST /api/influencerpartnerprogram/support picks the ONE influencer the caller supports', async () => {
const seed = async (accountId: number, isInfluencer: boolean) => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId, username: `Support${accountId}`, isInfluencer }))
.run()
}
await seed(8201, true)
await seed(8202, true)
await seed(8203, false) // a player, not an influencer
await seed(8204, false) // the supporter
const support = async (body: string, sub = '8204') =>
exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/support`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'content-type': 'application/x-www-form-urlencoded' },
body,
})
const my = async (sub = '8204') => {
const res = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/myinfluencer`,
{
headers: await bearer(sub),
}
)
expect(res.status).toBe(200)
return res.text()
}
// Nobody picked yet: a bare 0, the whole body.
expect(await my()).toBe('0')
// The raw form body the client posts. The reply is a bare status envelope — no `Value`;
// the pick is read back from `…/myinfluencer`.
const res = await support('influencerAccountId=8201')
expect(res.status).toBe(200)
// The tier is the WHOLE body — a bare number, not `{ Tier: 0 }` or a string. 0 is
// "not an influencer", which every account is here.
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.text()).toBe('0')
expect(await res.json()).toEqual({ Success: true, Error: null, error_id: null })
expect(await my()).toBe('8201')
expect((await getAccount(env.DB, 8204))?.supportedInfluencerId).toBe(8201)
// Any account, the caller's own included, gets the same answer.
const self = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=206`,
// One pick per player: posting again REPLACES it.
expect((await support('influencerAccountId=8202')).status).toBe(200)
expect(await my()).toBe('8202')
// Only an influencer can be supported, and not oneself. Each refusal is a 400 that
// leaves the pick alone.
expect((await support('influencerAccountId=8203')).status).toBe(400) // not an influencer
expect((await support('influencerAccountId=99999999')).status).toBe(400) // no such account
expect((await support('influencerAccountId=8204')).status).toBe(400) // oneself
expect((await support('influencerAccountId=8201', '8201')).status).toBe(400) // an influencer, oneself
expect((await support('influencerAccountId=nope')).status).toBe(400)
expect((await support('')).status).toBe(400)
expect(await my()).toBe('8202')
// Anyone can read anyone's pick, as the same bare number.
const theirs = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=8204`,
{ headers: await bearer('206') }
)
expect(await self.json()).toBe(0)
expect(theirs.status).toBe(200)
expect(await theirs.text()).toBe('8202')
})
test('GET /api/influencerpartnerprogram/myinfluencer answers a bare 0', async () => {
// The `my` form takes the account from the token instead of a query parameter, and
// answers the same tier in the same shape.
const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/myinfluencer`, {
headers: await bearer('206'),
})
test('POST /api/influencerpartnerprogram/remove unsets the pick it names', async () => {
for (const [accountId, isInfluencer] of [
[8211, true],
[8212, true],
[8213, false],
] as const) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId, username: `Remove${accountId}`, isInfluencer }))
.run()
}
const post = async (path: 'support' | 'remove', body: string, sub = '8213') =>
exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'content-type': 'application/x-www-form-urlencoded' },
body,
})
const my = async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/myinfluencer`,
{ headers: await bearer('8213') }
)
return res.text()
}
expect((await post('support', 'influencerAccountId=8211')).status).toBe(200)
expect(await my()).toBe('8211')
// Naming someone other than the current pick is a no-op success: the pick stays.
const other = await post('remove', 'influencerAccountId=8212')
expect(other.status).toBe(200)
expect(await other.json()).toEqual({ Success: true, Error: null, error_id: null })
expect(await my()).toBe('8211')
// Naming the current pick clears it: the same envelope, and `…/myinfluencer` is 0
// again. The field is DROPPED from the account, not written as 0.
const res = await post('remove', 'influencerAccountId=8211')
expect(res.status).toBe(200)
expect(await res.text()).toBe('0')
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.json()).toEqual({ Success: true, Error: null, error_id: null })
expect(await my()).toBe('0')
const account = await getAccount(env.DB, 8213)
expect(account).not.toBeNull()
expect('supportedInfluencerId' in account!).toBe(false)
// Removing with nothing picked is also a no-op success.
expect((await post('remove', 'influencerAccountId=8211')).status).toBe(200)
expect(await my()).toBe('0')
// A body that names no integer is a 400.
expect((await post('remove', 'influencerAccountId=nope')).status).toBe(400)
expect((await post('remove', '')).status).toBe(400)
// Unauthenticated is a 401.
const anon = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/remove`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: 'influencerAccountId=8211',
})
expect(anon.status).toBe(401)
})
test('the influencer tier routes 401 without a bearer token', async () => {
test('GET /api/influencerpartnerprogram/influencer answers 0 for nobody, 400 for no accountId', async () => {
// An account that has picked nobody, and one that does not exist, both support nobody.
for (const accountId of ['206', '99999999']) {
const res = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=${accountId}`,
{ headers: await bearer('206') }
)
expect(res.status, accountId).toBe(200)
// The id is the WHOLE body — a bare number, not `{ Id: 0 }` or a string.
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.text()).toBe('0')
}
for (const query of ['', '?accountId=', '?accountId=abc']) {
const res = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/influencer${query}`,
{ headers: await bearer('206') }
)
expect(res.status, query).toBe(400)
}
})
test('the influencer support routes 401 without a bearer token', async () => {
// Auth is checked before anything is answered, so an unauthenticated caller is told
// that rather than handed a tier.
for (const path of ['influencer', 'myinfluencer']) {
// that rather than handed an id.
for (const path of ['influencer?accountId=206', 'myinfluencer']) {
const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/${path}`)
expect(res.status, path).toBe(401)
expect(await res.text()).toBe('')
}
const post = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/support`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: 'influencerAccountId=8201',
})
expect(post.status).toBe(401)
})
test('GET /api/makerai/checkfreetrialeligibility answers a bare false', async () => {
@@ -7187,6 +7309,8 @@ describe('econ endpoints', () => {
'POST /api/consumables/v1/updateActive',
'POST /api/equipment/v1/update',
'POST /api/gamerewards/v1/request',
'POST /api/influencerpartnerprogram/remove',
'POST /api/influencerpartnerprogram/support',
'POST /api/itemWishlists/v1/isonwishlist/bulk',
'POST /api/itemWishlists/v1/wishlist/add',
'POST /api/itemWishlists/v1/wishlist/remove',