[api] add influencer badges

This commit is contained in:
Devin Zuczek
2026-10-08 14:34:04 -04:00
parent 6b8c908771
commit cebeafeb40
12 changed files with 238 additions and 34 deletions
@@ -0,0 +1,15 @@
-- Expose the account's influencer flag (`isInfluencer` in the JSON blob) as an indexed
-- generated column, so "every influencer" is an index walk rather than a scan of every
-- account's JSON. The reader is econ's `GET /api/influencerpartnerprogram/influencers`,
-- which the client asks for the whole list on every launch so it can badge influencers
-- wherever they turn up.
--
-- Same construction as `has_plus` (0009): a PARTIAL index holding only the rows where the
-- flag is set, so it is as small as the influencer list. A query must say exactly
-- `is_influencer = 1` to use it. `json_extract` yields 1 for JSON true, 0 for false and
-- NULL when the key is absent, so neither a revoked nor a never-granted account is in it.
-- Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror in
-- apps/econ/src/avatar-db.ts).
ALTER TABLE account ADD COLUMN is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1;
+9 -3
View File
@@ -295,11 +295,16 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
*
* `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it.
* RecFlare Studio treats that exact claim as Full access, which is what lets the editor
* upload; staff set the flag from the website. Order is stable so tokens are
* deterministic: screenshare, developer, moderator, junior, then betastudio.
* upload; staff set the flag from the website. `influencer` rides on `isInfluencer`
* (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup
* answers it. Order is stable so tokens are deterministic: screenshare, developer,
* moderator, junior, betastudio, then influencer.
*/
function accountRoles(
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio'> | null
account: Pick<
Account,
'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer'
> | null
): string[] {
const roles = ['screenshare']
if (!account) return roles
@@ -307,6 +312,7 @@ function accountRoles(
if (account.isModerator) roles.push('moderator')
if (account.isJunior) roles.push('junior')
if (account.hasStudio) roles.push('betastudio')
if (account.isInfluencer) roles.push('influencer')
return roles
}
@@ -895,6 +895,29 @@ describe('auth worker routes', () => {
expect(payload.scope).not.toContain('rn.privilege')
})
test('POST /connect/token stamps the influencer role for an isInfluencer account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 95,
username: 'InfluencerPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isInfluencer: true,
})
)
.run()
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
)
expect(payload.role).not.toContain('developer')
// Off the flag, off the token — no other flag implies it.
await updateAccount(env.DB, 95, { isInfluencer: false })
const revoked = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(revoked.role).not.toContain('influencer')
})
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
expect(res.status).toBe(400)