From cebeafeb40acb1a6f7376f99ddd7570252f8e4d0 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 8 Oct 2026 14:34:04 -0400 Subject: [PATCH] [api] add influencer badges --- apps/api/src/inventions-db.ts | 27 ++++++++-- apps/api/src/routes/avatar.ts | 17 +++++-- apps/api/src/test/integration/api.test.ts | 49 +++++++++++++++++++ .../migrations/0012_account_is_influencer.sql | 15 ++++++ apps/auth/src/auth.app.ts | 12 +++-- apps/auth/src/test/integration/api.test.ts | 23 +++++++++ apps/econ/src/avatar-db.ts | 4 +- apps/econ/src/econ.app.ts | 28 ++++++----- apps/econ/src/openapi.ts | 8 ++- apps/econ/src/test/integration/api.test.ts | 40 +++++++++++++-- packages/domain/src/accounts-db.ts | 33 +++++++++++-- packages/tools/src/cmd/admin.cmd.ts | 16 +++++- 12 files changed, 238 insertions(+), 34 deletions(-) create mode 100644 apps/auth/migrations/0012_account_is_influencer.sql diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index b36892b..7275034 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -18,7 +18,11 @@ * into the caller's own list, and to rank the "top today" feed by what players actually * picked up today. See @repo/domain's inventory-invention-db.ts. */ -import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain' +import { + getAccountByUsername, + getInventionAcquisitionCounts, + getOwnedInventionIds, +} from '@repo/domain' /** * Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql + @@ -727,6 +731,14 @@ export async function ownsAllInventions( * and a tag appears in no name or description, so those searches find nothing. Matching tags * needs them out of the JSON blob and into something indexable first; until then this stays a * text search rather than one that scans every row to look at its tags. + * + * A value starting with `@` IS special: `@djdevin` is a search for that player's inventions — + * the public ones, the same set `v1/fromcreators` lists for their portfolio — resolved by + * username, case-insensitively, through the indexed `username_lower`. Any terms after the + * `@name` narrow that player's inventions as text, so `@djdevin sofa` works. A username nobody + * has matches nothing rather than falling back to a text search for "@djdevin": a player who + * typed an `@` meant a person, and a list of inventions whose descriptions mention them is not + * what they asked for. */ export async function searchInventions( db: D1Database, @@ -743,11 +755,20 @@ export async function searchInventions( /** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */ const bind = (v: string | number): string => `?${binds.push(v)}` - for (const term of value + const terms = value .trim() .toLowerCase() .split(/[\s+]+/) - .filter(Boolean)) { + .filter(Boolean) + + if (terms[0]?.startsWith('@')) { + const username = terms.shift()!.slice(1) + const creator = username ? await getAccountByUsername(db, username) : null + if (!creator) return [] + where.push(`creator_player_id = ${bind(creator.accountId)}`) + } + + for (const term of terms) { const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`) const pattern = bind(`%${escaped}%`) where.push( diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 80009e5..d4d6b46 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -1732,8 +1732,9 @@ export const avatarRoutes = new Hono({ strict: false }) ) // Invention search/browse: published inventions matching `value` (matched against - // name + description; absent → browse everything published), newest first. - // Paginated via skip/take (take defaults to 100). Returns a bare array. + // name + description; absent → browse everything published; `@username` → that + // player's published inventions), newest first. Paginated via skip/take (take + // defaults to 100). Returns a bare array. // // Filtered, ordered and paged in SQL — it must not read the catalogue into memory to // answer one page. @@ -1746,10 +1747,16 @@ export const avatarRoutes = new Hono({ strict: false }) 'Published inventions matching `value`, newest first. `value` is split into terms ' + 'and every term must match, each against the name and the description. An absent ' + '`value` browses everything published — that is the browse screen’s initial ' + - 'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' + - 'chips is treated as text and matches nothing.', + 'request. A `value` starting with `@` names a player: `@djdevin` lists that ' + + 'player’s PUBLISHED inventions (the same set `v1/fromcreators` serves), resolved by ' + + 'username case-insensitively, with any further terms narrowing them as text; an ' + + 'unknown username is an empty array. Tags are NOT searched: a `#tag` term from the ' + + 'browse screen’s filter chips is treated as text and matches nothing.', parameters: [ - stringQuery('value', 'Search text; absent browses everything'), + stringQuery( + 'value', + 'Search text; absent browses everything; `@username` is a player’s inventions' + ), ...pageParams(100), ], responses: { 200: json(InventionDto.array(), 'The matching inventions') }, diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 2d952e2..c01183f 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -3671,6 +3671,55 @@ describe('public endpoints', () => { expect(await miss.json()).toEqual([]) }) + test('GET /api/inventions/v2/search with `@username` lists that player’s published inventions', async () => { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId: 8082, username: 'DJDevin' })) + .run() + const invention = (id: number, creator: number, name: string): SavedInvention => + ({ + InventionId: id, + ReplicationId: crypto.randomUUID(), + CreatorPlayerId: creator, + Name: name, + Description: '', + ImageName: '', + CurrentVersionNumber: 1, + CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' }, + IsPublished: true, + HideFromPlayer: false, + CreatedAt: `2026-07-${String(id - 100).padStart(2, '0')}T00:00:00Z`, + }) as unknown as SavedInvention + for (const inv of [ + invention(111, 8082, 'Devin Sofa'), + invention(112, 8082, 'Devin Lamp'), + // Another creator mentioning the name is NOT this player's invention. + { ...invention(113, 8083, 'Tribute to @djdevin'), Description: 'by @djdevin' }, + // Unpublished and hidden ones stay private, even to a search for the creator. + { ...invention(114, 8082, 'Devin Draft'), IsPublished: false }, + { ...invention(115, 8082, 'Devin Hidden'), HideFromPlayer: true }, + ]) { + await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)') + .bind(JSON.stringify(inv)) + .run() + } + const ids = async (value: string): Promise => { + const res = await exports.default.fetch( + `${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent(value)}&skip=0&take=100` + ) + expect(res.status).toBe(200) + return ((await res.json()) as SavedInvention[]).map((i) => i.InventionId) + } + + // The player's published inventions, newest first; the username is case-insensitive. + expect(await ids('@djdevin')).toEqual([112, 111]) + expect(await ids('@DJDevin')).toEqual([112, 111]) + // Further terms narrow within that player's inventions. + expect(await ids('@djdevin lamp')).toEqual([112]) + // An unknown player is nothing — not a text search for the literal "@nobody". + expect(await ids('@nobody')).toEqual([]) + expect(await ids('@')).toEqual([]) + }) + test('GET /api/inventions/v2/search filters and pages in SQL, and does not search tags', async () => { const published = (id: number, name: string, description: string, tags: string[]) => ({ diff --git a/apps/auth/migrations/0012_account_is_influencer.sql b/apps/auth/migrations/0012_account_is_influencer.sql new file mode 100644 index 0000000..50c736c --- /dev/null +++ b/apps/auth/migrations/0012_account_is_influencer.sql @@ -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; diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 3e0dc79..9ddbd89 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -295,11 +295,16 @@ async function roleFilterAnswer(c: Context, 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 | 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 } diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 00229d3..614377c 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -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) diff --git a/apps/econ/src/avatar-db.ts b/apps/econ/src/avatar-db.ts index d884e21..a0100c5 100644 --- a/apps/econ/src/avatar-db.ts +++ b/apps/econ/src/avatar-db.ts @@ -16,10 +16,12 @@ export const SCHEMA_DDL: string[] = [ avatar TEXT, account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL, username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL, - has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL + has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL, + is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`, `CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`, + `CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1`, ] /** The stored avatar payload — opaque JSON the client sets and reads back. */ diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index c3d3010..87873a5 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -15,6 +15,7 @@ import { grantInvention, levelReward, levelsReached, + listInfluencerIds, ownsInvention, setOutfit, } from '@repo/domain' @@ -6703,22 +6704,24 @@ const app = new Hono({ strict: false }) // Everyone in the influencer partner program, by account id — the list the client keeps // so it can badge an influencer wherever they turn up, rather than asking per player. + // Membership is the account's `isInfluencer` flag (`runx admin grant-influencer`), read + // through the `is_influencer` generated column's partial index so the account table is + // never scanned. // - // Empty: no programme runs here, so there is nobody to list. Note this is the LIST - // counterpart of the single-account check below, and the two answer very differently — - // that one 404s to say "not an influencer", this one is a 200 carrying an empty list, - // because "nobody is" is a complete answer to "who is?". - // - // `take` is accepted and ignored; there is nothing to page through. + // Paged in shape only: the client asks for `take=1000` and this server will not have + // that many, so the whole list is one page and `ContinuationToken` is always null. `take` + // is accepted and ignored. Note this is the LIST counterpart of the single-account tier + // check below, which still answers 0 for everyone. .get( '/api/influencerpartnerprogram/influencers', describeRoute({ tags: ['Econ'], summary: 'Every influencer in the partner program', description: [ - 'The account ids in the influencer partner program, as `{ InfluencerIds }` — an object', - 'around the list, not a bare array. Always empty here: no programme runs on this', - 'server. `take` is accepted and ignored, there being nothing to page.', + 'The account ids in the influencer partner program — every account flagged', + '`isInfluencer` — as `{ InfluencerIds, ContinuationToken }`: an object around the', + 'list, not a bare array. Served whole: `take` is accepted and ignored, and', + '`ContinuationToken` is always null.', ].join(' '), security: AUTHED, parameters: [ @@ -6726,19 +6729,20 @@ const app = new Hono({ strict: false }) name: 'take', in: 'query', required: false, - description: 'How many ids to return. Accepted and ignored.', + description: 'How many ids to return. Accepted and ignored — the list is served whole.', schema: { type: 'integer' }, }, ], responses: { - 200: json(InfluencerIdsResponse, 'The influencer ids — always empty'), + 200: json(InfluencerIdsResponse, 'The influencer ids, lowest first'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - return c.json({ InfluencerIds: [] }) + const InfluencerIds = await listInfluencerIds(c.env.DB) + return c.json({ InfluencerIds, ContinuationToken: null }) } ) diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index d5b662c..a25634a 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -222,12 +222,16 @@ export const RRPlusSignUpBonus = z.object({ /** * `GET /api/influencerpartnerprogram/influencers` — the ids of every influencer in the * partner program, which the client uses to badge them wherever they appear. An object - * around the list, not a bare array. + * around the list, not a bare array. `ContinuationToken` is the paging cursor; always null + * here, the whole list fitting in one page. */ export const InfluencerIdsResponse = z.object({ InfluencerIds: z .array(z.int()) - .describe('Account ids in the partner program. Empty — no programme runs here'), + .describe('Account ids in the partner program — every account flagged `isInfluencer`'), + ContinuationToken: z + .null() + .describe('Paging cursor for the next page. Always null — the list is served whole'), }) /** diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 74291d3..c0e6354 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -18,6 +18,7 @@ import { PROGRESSION_SCHEMA_DDL, RECEIVED_GIFT_SCHEMA_DDL, ROOM_SCHEMA_DDL, + updateAccount, } from '@repo/domain' import { PlatformType } from '@repo/domain/src/enums' @@ -6894,15 +6895,48 @@ describe('econ endpoints', () => { expect(await res.text()).toBe('') }) - test('GET /api/influencerpartnerprogram/influencers lists nobody', async () => { + test('GET /api/influencerpartnerprogram/influencers lists every isInfluencer account', async () => { + const seed = async (accountId: number, isInfluencer: boolean | undefined) => { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId, + username: `Influencer${accountId}`, + ...(isInfluencer === undefined ? {} : { isInfluencer }), + }) + ) + .run() + } + await seed(8103, true) + await seed(8101, true) + await seed(8102, false) // explicitly not one + await seed(8104, undefined) // flag never set + const res = await exports.default.fetch( `${ORIGIN}/api/influencerpartnerprogram/influencers?take=1000`, { headers: await bearer('207') } ) expect(res.status).toBe(200) // An object around the list, not a bare array — unlike its single-account siblings - // below, whose whole body is a bare tier number. - expect(await res.json()).toEqual({ InfluencerIds: [] }) + // below, whose whole body is a bare tier number. Lowest id first; the whole list is + // one page, so the continuation token is null rather than absent. + expect(await res.json()).toEqual({ InfluencerIds: [8101, 8103], ContinuationToken: null }) + + // The list is read through the `is_influencer` generated column's partial index + // (auth migration 0012), so the mirrored schema has to carry it — and the planner + // has to pick it over a scan of the account table. + const plan = await env.DB.prepare( + 'EXPLAIN QUERY PLAN SELECT account_id FROM account WHERE is_influencer = 1 ORDER BY account_id' + ).all<{ detail: string }>() + expect(plan.results.map((r) => r.detail).join('\n')).toContain('idx_account_is_influencer') + + // Revoking drops the account from the list on the next read — no cache in between. + await updateAccount(env.DB, 8103, { isInfluencer: false }) + const after = await exports.default.fetch( + `${ORIGIN}/api/influencerpartnerprogram/influencers?take=1000`, + { headers: await bearer('207') } + ) + expect(await after.json()).toEqual({ InfluencerIds: [8101], ContinuationToken: null }) }) test('GET /api/influencerpartnerprogram/influencers 401s without a bearer token', async () => { diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 310b658..1fcd3f5 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -15,13 +15,14 @@ import { bindPlaceholders, chunkForBinds } from './d1-binds' /** * Schema DDL — the head schema, i.e. what the table looks like after every migration - * (0001_accounts + 0002_avatar + 0009_account_has_plus, sans seed INSERTs; 0004 added a - * `platform_id` generated column and 0008 dropped it again, so it appears here in neither - * form). + * (0001_accounts + 0002_avatar + 0009_account_has_plus + 0012_account_is_influencer, sans + * seed INSERTs; 0004 added a `platform_id` generated column and 0008 dropped it again, so it + * appears here in neither form). * * `has_plus` mirrors the blob's `hasPlus` so the operator's Plus token reload can find every * subscriber through its partial index instead of scanning every account's JSON. It is 1 for * JSON true, 0 for false and NULL when the key is absent; the index holds only the 1s. + * `is_influencer` does the same for `isInfluencer`, for econ's influencer list. */ export const SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS account ( @@ -29,11 +30,13 @@ export const SCHEMA_DDL: string[] = [ avatar TEXT, account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL, username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL, - has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL + has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL, + is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`, `CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON account (username_lower)`, `CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`, + `CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1`, ] /** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */ @@ -127,6 +130,14 @@ export interface Account { * `runx admin grant-moderator`. Absent/false means no role. */ isModerator?: boolean + /** + * Whether this account holds the influencer role. It rides in the token's `role` claim + * (as `influencer`) and puts the account in econ's influencer partner program list + * (`GET /api/influencerpartnerprogram/influencers`, via {@link listInfluencerIds}) — + * there is no `/role/influencer/:id` lookup. Operator-granted only, via + * `runx admin grant-influencer`. Absent/false means no role. + */ + isInfluencer?: boolean /** * Whether this account is a SANDBOX account — served out of the sandbox Tachyon pool * rather than the live one (`match` `GET /player/connection-info`). Operator-set only; @@ -425,6 +436,20 @@ export async function getAccountsByIds(db: D1Database, ids: number[]): Promise page.results)) } +/** + * The id of every account flagged `isInfluencer`, lowest id first — the whole influencer + * partner program, which econ serves to every client on launch. Through the `is_influencer` + * generated column and its partial index (auth migration 0012): the predicate must stay + * exactly `is_influencer = 1` for the index to apply, and the index holds only those rows, + * so this never scans the account table. Only the ids are read; the blob is not parsed. + */ +export async function listInfluencerIds(db: D1Database): Promise { + const { results } = await db + .prepare(`SELECT account_id FROM account WHERE is_influencer = 1 ORDER BY account_id`) + .all<{ account_id: number }>() + return results.map((r) => r.account_id) +} + /** * Every account flagged `hasStudio`, lowest id first — the website's staff list. A JSON * scan rather than a generated column: the list is short and only staff read it. diff --git a/packages/tools/src/cmd/admin.cmd.ts b/packages/tools/src/cmd/admin.cmd.ts index 0077fb8..56c377a 100644 --- a/packages/tools/src/cmd/admin.cmd.ts +++ b/packages/tools/src/cmd/admin.cmd.ts @@ -28,6 +28,7 @@ import type { D1ExecResult } from '../d1' * runx admin clear-password --username alice [--remote] * runx admin lookup --username alice [--remote] * runx admin grant-developer --account 1 [--revoke] [--remote] + * runx admin grant-influencer --account 1 [--revoke] [--remote] * runx admin grant-plus --username alice [--revoke] [--remote] * runx admin reload-plus [--dry-run] [--remote] * runx admin cai-load [--file ] [--dry-run] [--remote] @@ -169,6 +170,11 @@ function grantRoleCommand(name: string, jsonKey: string, roleLabel: string, noun const grantDeveloper = grantRoleCommand('grant-developer', 'isDeveloper', 'developer') const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moderator') +/** + * The influencer role, the account's `isInfluencer` flag. `auth` stamps it into the token's + * `role` claim as `influencer` on the next login; nothing else reads the flag. + */ +const grantInfluencer = grantRoleCommand('grant-influencer', 'isInfluencer', 'influencer') /** * Rec Room Plus, the account's `hasPlus` flag. Players normally get it themselves by @@ -394,6 +400,7 @@ const lookup = new Command('lookup') (json_extract(data, '$.passwordHash') IS NOT NULL) AS hasPassword, (json_extract(data, '$.isDeveloper') = 1) AS isDeveloper, (json_extract(data, '$.isModerator') = 1) AS isModerator, + (json_extract(data, '$.isInfluencer') = 1) AS isInfluencer, (json_extract(data, '$.hasStudio') = 1) AS hasStudio FROM account WHERE ${where}` const res = await execSql(sql, remote) @@ -408,7 +415,13 @@ const lookup = new Command('lookup') : typeof v === 'object' ? JSON.stringify(v) : String(v as number | string | boolean) - const boolKeys = new Set(['hasPassword', 'isDeveloper', 'isModerator', 'hasStudio']) + const boolKeys = new Set([ + 'hasPassword', + 'isDeveloper', + 'isModerator', + 'isInfluencer', + 'hasStudio', + ]) const table = new Table() for (const [key, value] of Object.entries(row)) { const shown = boolKeys.has(key) ? (value === 1 ? 'yes' : 'no') : asText(value) @@ -426,6 +439,7 @@ export const adminCmd = new Command('admin') .addCommand(clearPassword) .addCommand(grantDeveloper) .addCommand(grantModerator) + .addCommand(grantInfluencer) .addCommand(grantPlus) .addCommand(grantStudio) .addCommand(reloadPlus)