[auth] cli add tokens to plus members

This commit is contained in:
Devin Zuczek
2026-09-14 22:32:10 -04:00
parent bacd359b61
commit 3299f05940
10 changed files with 360 additions and 67 deletions
+10 -3
View File
@@ -13,18 +13,25 @@
/**
* Schema DDL — the head schema, i.e. what the table looks like after every migration
* (0001_accounts + 0002_avatar, 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, 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.
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS account (
data TEXT NOT NULL,
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
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) 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`,
]
/** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */
+87 -1
View File
@@ -2,7 +2,12 @@ import * as readline from 'node:readline'
import { Command } from '@commander-js/extra-typings'
import Table from 'cli-table3'
import { execSql, resolveRemote, sqlStr, target } from '../d1'
import {
DEFAULT_STARTING_TOKENS,
PLUS_MEMBERS_SQL,
plusReloadSql,
} from '../../../../apps/econ/src/currency'
import { execSql, readRootEnv, resolveRemote, sqlStr, target } from '../d1'
import { hashPassword } from '../password'
import type { D1ExecResult } from '../d1'
@@ -19,6 +24,7 @@ import type { D1ExecResult } from '../d1'
* runx admin lookup --username alice [--remote]
* runx admin grant-developer --account 1 [--revoke] [--remote]
* runx admin grant-plus --username alice [--revoke] [--remote]
* runx admin reload-plus <amount> [--dry-run] [--remote]
*/
/**
@@ -173,6 +179,83 @@ const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moder
*/
const grantPlus = grantRoleCommand('grant-plus', 'hasPlus', 'Rec Room Plus', 'subscription')
/**
* The Rec Room Plus token reload: credit every subscriber's RecCenterTokens balance by
* `amount`, in one statement. Nothing schedules this — an operator runs it when the
* subscription's tokens are due (`just reload-plus 1000 --remote`), so running it twice
* credits twice; `--dry-run` shows who would be credited and what they hold, and changes
* nothing.
*
* Who is a subscriber is `account.hasPlus`, found through the `has_plus` generated column
* (auth migration 0009) — the same flag `grant-plus` and the website's Discord claim set,
* read straight off the row, so it does not lag a login the way the token's `rn.plus`
* claim does. A subscriber who has never touched econ has no balance row yet; they get one
* holding their signup grant plus the reload, so the grant isn't lost — see
* `plusReloadSql`. The grant amount is `RECFLARE_STARTING_TOKENS` from the environment
* or .env, the value a deploy ships to econ, falling back to econ's built-in default.
*
* Runs under econ's wrangler config because `balance` is econ's table; locally that
* means econ's dev D1 state, which has to hold both `balance` AND a migrated `account`.
*/
/** A row of `PLUS_MEMBERS_SQL` (and, sans `username`, of the reload's `RETURNING`). */
interface PlusMemberRow {
account_id: number
username?: string | null
/** NULL when the account has no balance row yet, i.e. its signup grant is still pending. */
amount: number | null
}
const reloadPlus = new Command('reload-plus')
.description('Credit every Rec Room Plus subscriber with <amount> RecCenterTokens')
.argument('<amount>', 'Tokens to add to each subscriber (a positive integer)')
.option('--dry-run', 'List the subscribers and their balances without crediting', false)
.option('--local', 'Target the local dev database (the default).', false)
.option('--remote', 'Target the deployed database instead of the local dev database.', false)
.action(async (amountArg, opts) => {
if (!/^\d+$/.test(amountArg) || Number(amountArg) < 1) {
throw new Error('<amount> must be a positive integer')
}
const amount = Number(amountArg)
const remote = resolveRemote(opts)
const startingRaw = await readRootEnv('RECFLARE_STARTING_TOKENS')
const startingTokens = startingRaw == null ? DEFAULT_STARTING_TOKENS : Number(startingRaw)
if (!Number.isInteger(startingTokens) || startingTokens < 0) {
throw new Error(`RECFLARE_STARTING_TOKENS is not a non-negative integer: ${startingRaw}`)
}
console.log(`Plus subscribers on ${target(remote)}`)
const before = (await execSql<PlusMemberRow>(PLUS_MEMBERS_SQL, remote, 'econ')).results
if (before.length === 0) {
console.log(chalk.yellow('no accounts have hasPlus set — nothing to reload'))
return
}
const table = new Table({ head: ['account', 'username', 'tokens'] })
for (const r of before) {
table.push([
String(r.account_id),
r.username ?? '',
r.amount == null ? chalk.dim(`(none: grant ${startingTokens} pending)`) : String(r.amount),
])
}
console.log(table.toString())
if (opts.dryRun) {
console.log(
chalk.yellow(`dry run: would add ${amount} tokens to ${before.length} account(s)`)
)
return
}
console.log(`Adding ${amount} tokens to ${before.length} account(s)`)
const credited = (
await execSql<PlusMemberRow>(plusReloadSql(amount, startingTokens), remote, 'econ')
).results
const after = new Table({ head: ['account', 'tokens'] })
for (const r of credited) after.push([String(r.account_id), String(r.amount)])
console.log(after.toString())
console.log(chalk.green(`✓ ${amount} tokens added to ${credited.length} account(s)`))
})
const lookup = new Command('lookup')
.description('Print an account by id or username')
.option('--account <id>', 'Account id to look up')
@@ -224,6 +307,7 @@ export const adminCmd = new Command('admin')
.addCommand(grantDeveloper)
.addCommand(grantModerator)
.addCommand(grantPlus)
.addCommand(reloadPlus)
.addCommand(lookup)
.addHelpText(
'after',
@@ -239,5 +323,7 @@ Examples:
$ runx admin grant-developer --account 1 [--revoke]
$ runx admin grant-moderator --username alice --remote
$ runx admin grant-plus --username alice # Rec Room Plus; takes effect next login
$ runx admin reload-plus 1000 --remote # +1000 tokens to every Plus subscriber
$ runx admin reload-plus 1000 --dry-run # just list them
$ runx admin lookup --username alice --remote`
)
+21 -9
View File
@@ -18,8 +18,8 @@ import { getRepoRoot } from './path'
/** The one shared database every D1-backed worker binds. */
export const DB_NAME = 'recflare'
export interface D1ExecResult {
results: Array<Record<string, unknown>>
export interface D1ExecResult<Row = Record<string, unknown>> {
results: Row[]
success: boolean
meta: { changes?: number; rows_read?: number }
}
@@ -37,15 +37,27 @@ export function resolveRemote(opts: { local?: boolean; remote?: boolean }): bool
return opts.remote === true
}
/** The deployed D1's real id, from the environment or the gitignored root .env. */
export async function getRemoteD1Id(): Promise<string> {
if (process.env.RECFLARE_D1) return process.env.RECFLARE_D1
/**
* One `RECFLARE_*` setting, from the process environment first and the gitignored root
* `.env` second — the same precedence `recflare_load_env` in src/sh/env.sh gives the deploy
* and dev scripts, so a CLI reads the value a deploy would ship. Undefined when set in
* neither place (a commented-out line in .env is "not set").
*/
export async function readRootEnv(key: string): Promise<string | undefined> {
if (process.env[key]) return process.env[key]
const envPath = path.join(getRepoRoot(), '.env')
if (await fs.pathExists(envPath)) {
const content = await fs.readFile(envPath, 'utf8')
const m = content.match(/^\s*RECFLARE_D1\s*=\s*(.+?)\s*$/m)
const m = content.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, 'm'))
if (m) return m[1].replace(/^["']|["']$/g, '')
}
return undefined
}
/** The deployed D1's real id, from the environment or the gitignored root .env. */
export async function getRemoteD1Id(): Promise<string> {
const id = await readRootEnv('RECFLARE_D1')
if (id) return id
throw new Error('RECFLARE_D1 is not set — add the recflare D1 id to .env (see .env.example)')
}
@@ -95,16 +107,16 @@ async function runD1(worker: string, extraArgs: string[], remote: boolean): Prom
* only a duration, with no reliable `changes` count, so a statement that needs to know what it
* matched has to say `RETURNING`.
*/
export async function execSql(
export async function execSql<Row = Record<string, unknown>>(
sql: string,
remote: boolean,
worker = 'auth'
): Promise<D1ExecResult> {
): Promise<D1ExecResult<Row>> {
const stdout = await runD1(worker, ['--command', sql, '--json'], remote)
// wrangler --json prints a one-element array of results to stdout.
const start = stdout.indexOf('[')
if (start === -1) throw new Error(`unexpected d1 execute output:\n${stdout}`)
const parsed = JSON.parse(stdout.slice(start)) as D1ExecResult[]
const parsed = JSON.parse(stdout.slice(start)) as Array<D1ExecResult<Row>>
const first = parsed[0]
if (!first) throw new Error(`empty d1 execute result:\n${stdout}`)
return first