mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[auth] cli add tokens to plus members
This commit is contained in:
@@ -51,6 +51,31 @@ bun runx admin grant-developer --account 1 --revoke
|
|||||||
bun runx admin grant-moderator --username alice --remote
|
bun runx admin grant-moderator --username alice --remote
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `reload-plus` — credit every Plus subscriber's tokens
|
||||||
|
|
||||||
|
Also exposed directly as `just reload-plus`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just reload-plus 1000 --dry-run # list subscribers and their balances, change nothing
|
||||||
|
just reload-plus 1000 --remote # +1000 RecCenterTokens to each subscriber, production
|
||||||
|
```
|
||||||
|
|
||||||
|
Adds `<amount>` RecCenterTokens (currency type 2) to the `balance` row of every account
|
||||||
|
whose `hasPlus` flag is set — the flag `grant-plus` and the website's Discord claim write.
|
||||||
|
Subscribers are found through the indexed `has_plus` column on `account` (auth migration
|
||||||
|
0009), so the reload is one statement however many accounts there are. Nothing schedules
|
||||||
|
it: run it when the subscription's tokens are due, and note that running it twice credits
|
||||||
|
twice.
|
||||||
|
|
||||||
|
A subscriber who has never loaded the game has no balance row yet. They get one holding
|
||||||
|
their signup grant **plus** the reload, so the grant is not lost — the grant amount is
|
||||||
|
`RECFLARE_STARTING_TOKENS` from the environment or `.env`, falling back to econ's default
|
||||||
|
of 10000. Prints every credited account with its resulting balance.
|
||||||
|
|
||||||
|
This command targets the `balance` table, so it runs under `apps/econ`'s wrangler config;
|
||||||
|
with `--local` that is econ's dev D1 state, which needs both `balance` and a migrated
|
||||||
|
`account` table in it.
|
||||||
|
|
||||||
### `lookup` — print an account
|
### `lookup` — print an account
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -65,7 +90,7 @@ the account has a password, the developer role, and the moderator role.
|
|||||||
|
|
||||||
### Selecting an account
|
### Selecting an account
|
||||||
|
|
||||||
Every command targets exactly one account, by **either**:
|
Every command except `reload-plus` targets exactly one account, by **either**:
|
||||||
|
|
||||||
- `--account <id>` — numeric account id
|
- `--account <id>` — numeric account id
|
||||||
- `--username <name>` — username (case-insensitive)
|
- `--username <name>` — username (case-insensitive)
|
||||||
|
|||||||
@@ -109,10 +109,18 @@ update *args:
|
|||||||
runx *args:
|
runx *args:
|
||||||
bun runx "$@"
|
bun runx "$@"
|
||||||
|
|
||||||
# Admin account tools (set-password, clear-password, grant-developer, lookup).
|
# Admin account tools (set-password, clear-password, grant-developer, grant-plus, lookup).
|
||||||
# Run `just admin --help` for usage and examples. See CLI.md.
|
# Run `just admin --help` for usage and examples. See CLI.md.
|
||||||
[group('4. utility')]
|
[group('4. utility')]
|
||||||
[positional-arguments]
|
[positional-arguments]
|
||||||
[no-cd]
|
[no-cd]
|
||||||
admin *args:
|
admin *args:
|
||||||
bun runx admin "$@"
|
bun runx admin "$@"
|
||||||
|
|
||||||
|
# Credit every Rec Room Plus subscriber's RecCenterTokens balance by <amount>. Defaults to
|
||||||
|
# --local; pass --remote for production, --dry-run to only list who'd be credited. See CLI.md.
|
||||||
|
[group('4. utility')]
|
||||||
|
[positional-arguments]
|
||||||
|
[no-cd]
|
||||||
|
reload-plus *args:
|
||||||
|
bun runx admin reload-plus "$@"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Expose the account's Rec Room Plus flag (`hasPlus` in the JSON blob) as an indexed
|
||||||
|
-- generated column, so "every Plus member" is an index walk rather than a scan of every
|
||||||
|
-- account's JSON. The reader is the operator's periodic token reload
|
||||||
|
-- (`just reload-plus`, packages/tools/src/cmd/admin.cmd.ts), which credits the
|
||||||
|
-- `balance` row of each Plus member in one statement.
|
||||||
|
--
|
||||||
|
-- The index is PARTIAL: it holds only the rows where the flag is set, so it is as small
|
||||||
|
-- as the subscriber list and never grows with the account table. A query must say
|
||||||
|
-- exactly `has_plus = 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 has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1;
|
||||||
@@ -15,9 +15,11 @@ export const SCHEMA_DDL: string[] = [
|
|||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
avatar TEXT,
|
avatar TEXT,
|
||||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
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 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`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/** The stored avatar payload — opaque JSON the client sets and reads back. */
|
/** The stored avatar payload — opaque JSON the client sets and reads back. */
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { BalancePlatform } from '../../notify/src/notification-payloads'
|
import { BalancePlatform } from '../../notify/src/notification-payloads'
|
||||||
|
import { CurrencyType } from './currency'
|
||||||
|
|
||||||
|
// The currency vocabulary and the Plus reload SQL live in the import-free `currency.ts` so
|
||||||
|
// the Node CLI in @repo/tools can share them; re-exported here so this stays the module
|
||||||
|
// everything else reads balances from.
|
||||||
|
export { CurrencyType, DEFAULT_STARTING_TOKENS, PLUS_MEMBERS_SQL, plusReloadSql } from './currency'
|
||||||
|
export type { CurrencyTypeValue } from './currency'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Currency balances on the shared `recflare` D1 database.
|
* Currency balances on the shared `recflare` D1 database.
|
||||||
@@ -14,50 +21,6 @@ import { BalancePlatform } from '../../notify/src/notification-payloads'
|
|||||||
* it doesn't clash with the auth/rooms migration histories on the same database.
|
* it doesn't clash with the auth/rooms migration histories on the same database.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* The currencies the client knows about (its `CurrencyType` enum, obfuscated
|
|
||||||
* `GKPEKOLBBJL` — which lists every member below except `RoomInventoryItem`). The client sends
|
|
||||||
* these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is
|
|
||||||
* RecCenterTokens — so the values are fixed by the client, not by us.
|
|
||||||
*
|
|
||||||
* What each one is:
|
|
||||||
* - `Invalid` (0): the enum's zero value. Never a real balance; a request for it is
|
|
||||||
* a client bug or a probe, and `isSpendable` rejects it.
|
|
||||||
* - `LaserTagTickets` (1): earned in the Laser Tag activity, spent in its own store.
|
|
||||||
* - `RecCenterTokens` (2): THE general-purpose currency — what players mean by
|
|
||||||
* "tokens", earned everywhere and spent in the avatar/gift-drop storefronts. This
|
|
||||||
* is the only one the client fetches on load, and the only one we grant at signup.
|
|
||||||
* - `LostSkullsGold` (100) / `DraculaSilver` (101): per-activity currencies for the
|
|
||||||
* Isle of Lost Skulls and Rise of Jumbo quests. Earned and spent inside those
|
|
||||||
* activities only.
|
|
||||||
* - `RecRoyaleSeason1` (200): a season currency for Rec Royale; legacy, no live faucet.
|
|
||||||
* - `RoomCurrency` (300) / `RoomInventoryItem` (301): NOT global balances. These are
|
|
||||||
* scoped to a specific room and served by the `/api/roomcurrencies/*` and
|
|
||||||
* `/api/roomconsumables/*` endpoints, whose rows are keyed by room as well as by
|
|
||||||
* account. They must never be stored in this (account, currency) table — a single
|
|
||||||
* row here couldn't say WHICH room's currency it is, so a player's coins in one
|
|
||||||
* room would spend in every other. `isSpendable` rejects them for that reason.
|
|
||||||
* - `ProgressionEvent` (400): an XP/progression counter the client models as a
|
|
||||||
* currency. Not spendable.
|
|
||||||
* - `RoomieCredits` (500): the newest member of the client's enum. Nothing here grants or
|
|
||||||
* spends it yet; it is listed so the enum matches the client's and a value arriving on
|
|
||||||
* the wire has a name rather than reading as an unknown number.
|
|
||||||
*/
|
|
||||||
export const CurrencyType = {
|
|
||||||
Invalid: 0,
|
|
||||||
LaserTagTickets: 1,
|
|
||||||
RecCenterTokens: 2,
|
|
||||||
LostSkullsGold: 100,
|
|
||||||
DraculaSilver: 101,
|
|
||||||
RecRoyaleSeason1: 200,
|
|
||||||
RoomCurrency: 300,
|
|
||||||
RoomInventoryItem: 301,
|
|
||||||
ProgressionEvent: 400,
|
|
||||||
RoomieCredits: 500,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The account-scoped currencies this table stores. Everything else in `CurrencyType`
|
* The account-scoped currencies this table stores. Everything else in `CurrencyType`
|
||||||
* is either not a balance (Invalid, ProgressionEvent) or is room-scoped and belongs to
|
* is either not a balance (Invalid, ProgressionEvent) or is room-scoped and belongs to
|
||||||
@@ -74,13 +37,6 @@ const SPENDABLE: readonly number[] = [
|
|||||||
/** Whether a currency is an account-scoped balance this table may hold. */
|
/** Whether a currency is an account-scoped balance this table may hold. */
|
||||||
export const isSpendable = (currencyType: number): boolean => SPENDABLE.includes(currencyType)
|
export const isSpendable = (currencyType: number): boolean => SPENDABLE.includes(currencyType)
|
||||||
|
|
||||||
/**
|
|
||||||
* The signup grant, in RecCenterTokens, when the `STARTING_TOKENS` var is unset.
|
|
||||||
* An operator overrides it in wrangler.jsonc `vars`; 0 is a valid setting and means
|
|
||||||
* players start broke.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_STARTING_TOKENS = 10_000
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a player starts with, granted lazily the first time their balances are touched
|
* What a player starts with, granted lazily the first time their balances are touched
|
||||||
* (see `ensureStartingBalances`). Currencies absent here start at 0.
|
* (see `ensureStartingBalances`). Currencies absent here start at 0.
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* The currency vocabulary — which currencies exist and what a player starts with — and the
|
||||||
|
* operator's Plus token reload, which is SQL text built from them.
|
||||||
|
*
|
||||||
|
* Separate from `balance-db.ts` for the same reason `catalog-load.ts` is separate from
|
||||||
|
* `catalog-db.ts`: that module types its queries with `D1Database`, a Workers type, and the
|
||||||
|
* `runx admin reload-plus` CLI (@repo/tools) that runs the reload is a plain Node process
|
||||||
|
* with no such types. Nothing here imports anything, so both sides can use it; `balance-db.ts`
|
||||||
|
* re-exports all of it, so the worker keeps importing from there.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currencies the client knows about (its `CurrencyType` enum, obfuscated
|
||||||
|
* `GKPEKOLBBJL` — which lists every member below except `RoomInventoryItem`). The client sends
|
||||||
|
* these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is
|
||||||
|
* RecCenterTokens — so the values are fixed by the client, not by us.
|
||||||
|
*
|
||||||
|
* What each one is:
|
||||||
|
* - `Invalid` (0): the enum's zero value. Never a real balance; a request for it is
|
||||||
|
* a client bug or a probe, and `isSpendable` rejects it.
|
||||||
|
* - `LaserTagTickets` (1): earned in the Laser Tag activity, spent in its own store.
|
||||||
|
* - `RecCenterTokens` (2): THE general-purpose currency — what players mean by
|
||||||
|
* "tokens", earned everywhere and spent in the avatar/gift-drop storefronts. This
|
||||||
|
* is the only one the client fetches on load, and the only one we grant at signup.
|
||||||
|
* - `LostSkullsGold` (100) / `DraculaSilver` (101): per-activity currencies for the
|
||||||
|
* Isle of Lost Skulls and Rise of Jumbo quests. Earned and spent inside those
|
||||||
|
* activities only.
|
||||||
|
* - `RecRoyaleSeason1` (200): a season currency for Rec Royale; legacy, no live faucet.
|
||||||
|
* - `RoomCurrency` (300) / `RoomInventoryItem` (301): NOT global balances. These are
|
||||||
|
* scoped to a specific room and served by the `/api/roomcurrencies/*` and
|
||||||
|
* `/api/roomconsumables/*` endpoints, whose rows are keyed by room as well as by
|
||||||
|
* account. They must never be stored in this (account, currency) table — a single
|
||||||
|
* row here couldn't say WHICH room's currency it is, so a player's coins in one
|
||||||
|
* room would spend in every other. `isSpendable` rejects them for that reason.
|
||||||
|
* - `ProgressionEvent` (400): an XP/progression counter the client models as a
|
||||||
|
* currency. Not spendable.
|
||||||
|
* - `RoomieCredits` (500): the newest member of the client's enum. Nothing here grants or
|
||||||
|
* spends it yet; it is listed so the enum matches the client's and a value arriving on
|
||||||
|
* the wire has a name rather than reading as an unknown number.
|
||||||
|
*/
|
||||||
|
export const CurrencyType = {
|
||||||
|
Invalid: 0,
|
||||||
|
LaserTagTickets: 1,
|
||||||
|
RecCenterTokens: 2,
|
||||||
|
LostSkullsGold: 100,
|
||||||
|
DraculaSilver: 101,
|
||||||
|
RecRoyaleSeason1: 200,
|
||||||
|
RoomCurrency: 300,
|
||||||
|
RoomInventoryItem: 301,
|
||||||
|
ProgressionEvent: 400,
|
||||||
|
RoomieCredits: 500,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signup grant, in RecCenterTokens, when the `STARTING_TOKENS` var is unset.
|
||||||
|
* An operator overrides it in wrangler.jsonc `vars`; 0 is a valid setting and means
|
||||||
|
* players start broke.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_STARTING_TOKENS = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The periodic Rec Room Plus token reload: credit `amount` RecCenterTokens to every account
|
||||||
|
* whose `hasPlus` flag is set, in ONE statement. Run by the operator from the CLI
|
||||||
|
* (`just reload-plus <amount>`), which is why this is SQL text rather than a `D1Database`
|
||||||
|
* helper — `wrangler d1 execute --command` takes a statement, not bindings, so the two
|
||||||
|
* numbers are validated here and inlined as integer literals.
|
||||||
|
*
|
||||||
|
* Subscribers are found through `account.has_plus`, the generated column with the partial
|
||||||
|
* index (auth migration 0009); the predicate must stay exactly `has_plus = 1` for the
|
||||||
|
* index to be used.
|
||||||
|
*
|
||||||
|
* A subscriber with NO balance row yet is one who has never touched econ, so their signup
|
||||||
|
* grant (`ensureStartingBalances`) is still pending. Their row is created here with
|
||||||
|
* `startingTokens + amount` — the grant and the reload together — because a row holding
|
||||||
|
* only the reload would make `ensureStartingBalances` skip the grant forever (the grant is
|
||||||
|
* "row exists → already granted"). Existing rows simply gain `amount`, whatever they hold,
|
||||||
|
* including a 0 spent down to. Same `startingTokens` rule as everywhere else: it is the
|
||||||
|
* operator's `STARTING_TOKENS`, passed in, never a module default read here.
|
||||||
|
*
|
||||||
|
* `RETURNING` reports each credited account and its resulting balance, since wrangler's
|
||||||
|
* meta carries no reliable change count.
|
||||||
|
*/
|
||||||
|
export function plusReloadSql(amount: number, startingTokens: number): string {
|
||||||
|
if (!Number.isInteger(amount) || amount <= 0) {
|
||||||
|
throw new Error(`plusReloadSql: amount must be a positive integer, got ${amount}`)
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(startingTokens) || startingTokens < 0) {
|
||||||
|
throw new Error(
|
||||||
|
`plusReloadSql: startingTokens must be a non-negative integer, got ${startingTokens}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return `INSERT INTO balance (account_id, currency_type, amount)
|
||||||
|
SELECT account_id, ${CurrencyType.RecCenterTokens}, ${startingTokens + amount}
|
||||||
|
FROM account WHERE has_plus = 1
|
||||||
|
ON CONFLICT (account_id, currency_type) DO UPDATE SET amount = amount + ${amount}
|
||||||
|
RETURNING account_id, amount`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every Plus subscriber with their current RecCenterTokens balance — NULL for one whose
|
||||||
|
* signup grant is still pending (see `plusReloadSql`). The read half of the reload: what
|
||||||
|
* the CLI shows before it credits, and all a `--dry-run` does.
|
||||||
|
*/
|
||||||
|
export const PLUS_MEMBERS_SQL = `SELECT a.account_id, json_extract(a.data, '$.username') AS username, b.amount
|
||||||
|
FROM account a
|
||||||
|
LEFT JOIN balance b ON b.account_id = a.account_id AND b.currency_type = ${CurrencyType.RecCenterTokens}
|
||||||
|
WHERE a.has_plus = 1
|
||||||
|
ORDER BY a.account_id`
|
||||||
@@ -44,6 +44,8 @@ import {
|
|||||||
CurrencyType,
|
CurrencyType,
|
||||||
DEFAULT_STARTING_TOKENS,
|
DEFAULT_STARTING_TOKENS,
|
||||||
getBalance,
|
getBalance,
|
||||||
|
PLUS_MEMBERS_SQL,
|
||||||
|
plusReloadSql,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from '../../balance-db'
|
} from '../../balance-db'
|
||||||
import {
|
import {
|
||||||
@@ -5656,3 +5658,73 @@ describe('catalog', () => {
|
|||||||
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 1 })
|
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 1 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operator's Plus token reload (`just reload-plus`), which is SQL text rather than a
|
||||||
|
* helper because it is run through `wrangler d1 execute`. Exercised here against a real D1
|
||||||
|
* so the upsert's two halves — "existing row gains the amount" and "missing row is created
|
||||||
|
* with the signup grant folded in" — are pinned, and so the `has_plus` generated column the
|
||||||
|
* statement depends on is proven to exist in the mirrored schema.
|
||||||
|
*/
|
||||||
|
describe('plus token reload', () => {
|
||||||
|
const seed = async (accountId: number, hasPlus: boolean | undefined) => {
|
||||||
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
accountId,
|
||||||
|
username: `Plus${accountId}`,
|
||||||
|
...(hasPlus === undefined ? {} : { hasPlus }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('credits every hasPlus account once, folding the signup grant into new rows', async () => {
|
||||||
|
await seed(8001, true) // subscriber with an existing balance
|
||||||
|
await seed(8002, true) // subscriber who has never touched econ: no balance row
|
||||||
|
await seed(8003, false) // explicitly not a subscriber
|
||||||
|
await seed(8004, undefined) // flag never set
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT OR REPLACE INTO balance (account_id, currency_type, amount) VALUES (8001, 2, 250)'
|
||||||
|
).run()
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT OR REPLACE INTO balance (account_id, currency_type, amount) VALUES (8003, 2, 250)'
|
||||||
|
).run()
|
||||||
|
|
||||||
|
// The read half: subscribers only, with the pending grant shown as a NULL balance.
|
||||||
|
const members = await env.DB.prepare(PLUS_MEMBERS_SQL).all<{
|
||||||
|
account_id: number
|
||||||
|
username: string
|
||||||
|
amount: number | null
|
||||||
|
}>()
|
||||||
|
expect(members.results.filter((r) => r.account_id >= 8001 && r.account_id <= 8004)).toEqual([
|
||||||
|
{ account_id: 8001, username: 'Plus8001', amount: 250 },
|
||||||
|
{ account_id: 8002, username: 'Plus8002', amount: null },
|
||||||
|
])
|
||||||
|
|
||||||
|
const res = await env.DB.prepare(plusReloadSql(1000, 500)).all<{
|
||||||
|
account_id: number
|
||||||
|
amount: number
|
||||||
|
}>()
|
||||||
|
const credited = res.results.filter((r) => r.account_id >= 8001 && r.account_id <= 8004)
|
||||||
|
expect(credited).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ account_id: 8001, amount: 1250 },
|
||||||
|
{ account_id: 8002, amount: 1500 },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
expect(credited).toHaveLength(2)
|
||||||
|
|
||||||
|
// The non-subscribers were left exactly as they were, and the fresh row is now a real
|
||||||
|
// grant: `ensureStartingBalances` sees it and does not grant again.
|
||||||
|
expect(await getBalance(env.DB, 8003, CurrencyType.RecCenterTokens, 500)).toBe(250)
|
||||||
|
expect(await getBalance(env.DB, 8004, CurrencyType.RecCenterTokens, 500)).toBe(500)
|
||||||
|
expect(await getBalance(env.DB, 8002, CurrencyType.RecCenterTokens, 500)).toBe(1500)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a non-positive amount or a negative grant before touching the database', () => {
|
||||||
|
expect(() => plusReloadSql(0, 500)).toThrow('positive integer')
|
||||||
|
expect(() => plusReloadSql(1.5, 500)).toThrow('positive integer')
|
||||||
|
expect(() => plusReloadSql(100, -1)).toThrow('non-negative integer')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,18 +13,25 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema DDL — the head schema, i.e. what the table looks like after every migration
|
* 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
|
* (0001_accounts + 0002_avatar + 0009_account_has_plus, sans seed INSERTs; 0004 added a
|
||||||
* column and 0008 dropped it again, so it appears here in neither form).
|
* `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[] = [
|
export const SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS account (
|
`CREATE TABLE IF NOT EXISTS account (
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
avatar TEXT,
|
avatar TEXT,
|
||||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
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 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_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). */
|
/** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import * as readline from 'node:readline'
|
|||||||
import { Command } from '@commander-js/extra-typings'
|
import { Command } from '@commander-js/extra-typings'
|
||||||
import Table from 'cli-table3'
|
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 { hashPassword } from '../password'
|
||||||
|
|
||||||
import type { D1ExecResult } from '../d1'
|
import type { D1ExecResult } from '../d1'
|
||||||
@@ -19,6 +24,7 @@ import type { D1ExecResult } from '../d1'
|
|||||||
* runx admin lookup --username alice [--remote]
|
* runx admin lookup --username alice [--remote]
|
||||||
* runx admin grant-developer --account 1 [--revoke] [--remote]
|
* runx admin grant-developer --account 1 [--revoke] [--remote]
|
||||||
* runx admin grant-plus --username alice [--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')
|
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')
|
const lookup = new Command('lookup')
|
||||||
.description('Print an account by id or username')
|
.description('Print an account by id or username')
|
||||||
.option('--account <id>', 'Account id to look up')
|
.option('--account <id>', 'Account id to look up')
|
||||||
@@ -224,6 +307,7 @@ export const adminCmd = new Command('admin')
|
|||||||
.addCommand(grantDeveloper)
|
.addCommand(grantDeveloper)
|
||||||
.addCommand(grantModerator)
|
.addCommand(grantModerator)
|
||||||
.addCommand(grantPlus)
|
.addCommand(grantPlus)
|
||||||
|
.addCommand(reloadPlus)
|
||||||
.addCommand(lookup)
|
.addCommand(lookup)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
'after',
|
'after',
|
||||||
@@ -239,5 +323,7 @@ Examples:
|
|||||||
$ runx admin grant-developer --account 1 [--revoke]
|
$ runx admin grant-developer --account 1 [--revoke]
|
||||||
$ runx admin grant-moderator --username alice --remote
|
$ runx admin grant-moderator --username alice --remote
|
||||||
$ runx admin grant-plus --username alice # Rec Room Plus; takes effect next login
|
$ 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`
|
$ runx admin lookup --username alice --remote`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { getRepoRoot } from './path'
|
|||||||
/** The one shared database every D1-backed worker binds. */
|
/** The one shared database every D1-backed worker binds. */
|
||||||
export const DB_NAME = 'recflare'
|
export const DB_NAME = 'recflare'
|
||||||
|
|
||||||
export interface D1ExecResult {
|
export interface D1ExecResult<Row = Record<string, unknown>> {
|
||||||
results: Array<Record<string, unknown>>
|
results: Row[]
|
||||||
success: boolean
|
success: boolean
|
||||||
meta: { changes?: number; rows_read?: number }
|
meta: { changes?: number; rows_read?: number }
|
||||||
}
|
}
|
||||||
@@ -37,15 +37,27 @@ export function resolveRemote(opts: { local?: boolean; remote?: boolean }): bool
|
|||||||
return opts.remote === true
|
return opts.remote === true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The deployed D1's real id, from the environment or the gitignored root .env. */
|
/**
|
||||||
export async function getRemoteD1Id(): Promise<string> {
|
* One `RECFLARE_*` setting, from the process environment first and the gitignored root
|
||||||
if (process.env.RECFLARE_D1) return process.env.RECFLARE_D1
|
* `.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')
|
const envPath = path.join(getRepoRoot(), '.env')
|
||||||
if (await fs.pathExists(envPath)) {
|
if (await fs.pathExists(envPath)) {
|
||||||
const content = await fs.readFile(envPath, 'utf8')
|
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, '')
|
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)')
|
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
|
* only a duration, with no reliable `changes` count, so a statement that needs to know what it
|
||||||
* matched has to say `RETURNING`.
|
* matched has to say `RETURNING`.
|
||||||
*/
|
*/
|
||||||
export async function execSql(
|
export async function execSql<Row = Record<string, unknown>>(
|
||||||
sql: string,
|
sql: string,
|
||||||
remote: boolean,
|
remote: boolean,
|
||||||
worker = 'auth'
|
worker = 'auth'
|
||||||
): Promise<D1ExecResult> {
|
): Promise<D1ExecResult<Row>> {
|
||||||
const stdout = await runD1(worker, ['--command', sql, '--json'], remote)
|
const stdout = await runD1(worker, ['--command', sql, '--json'], remote)
|
||||||
// wrangler --json prints a one-element array of results to stdout.
|
// wrangler --json prints a one-element array of results to stdout.
|
||||||
const start = stdout.indexOf('[')
|
const start = stdout.indexOf('[')
|
||||||
if (start === -1) throw new Error(`unexpected d1 execute output:\n${stdout}`)
|
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]
|
const first = parsed[0]
|
||||||
if (!first) throw new Error(`empty d1 execute result:\n${stdout}`)
|
if (!first) throw new Error(`empty d1 execute result:\n${stdout}`)
|
||||||
return first
|
return first
|
||||||
|
|||||||
Reference in New Issue
Block a user