Issue #14: add isDeveloper/isModerator CLI commands

This commit is contained in:
Devin Zuczek
2026-07-16 12:25:25 -04:00
parent ed155c163c
commit f02a75aed4
7 changed files with 150 additions and 34 deletions
+8 -6
View File
@@ -20,7 +20,7 @@ bun runx admin set-password --username alice --remote
``` ```
The new password is taken from `--password <pw>`, else from piped stdin, else The new password is taken from `--password <pw>`, else from piped stdin, else
prompted interactively (hidden input, entered twice and compared): prompted interactively.
```sh ```sh
# interactive (prompts, hidden) # interactive (prompts, hidden)
@@ -33,20 +33,22 @@ bun runx admin set-password --account 1 --password "s3cret-pw"
### `clear-password` — remove an account's password ### `clear-password` — remove an account's password
Leaves the account with no login credential (it can't be logged into until a Leaves the account with no login credential (only platform login).
password is set again).
```sh ```sh
bun runx admin clear-password --username alice bun runx admin clear-password --username alice
``` ```
### `grant-developer` — grant or revoke the developer role ### `grant-developer` / `grant-moderator` — grant or revoke a role
Backs `GET /role/developer/:id`. Off by default; only this command grants it. Both are off by default; only these commands set them. A granted role backs its
`GET /role/<role>/:id` lookup **and** rides in the login token's `role` claim, so it
takes effect on the account's next login or token refresh.
```sh ```sh
bun runx admin grant-developer --account 1 bun runx admin grant-developer --account 1
bun runx admin grant-developer --account 1 --revoke bun runx admin grant-developer --account 1 --revoke
bun runx admin grant-moderator --username alice --remote
``` ```
### `lookup` — print an account ### `lookup` — print an account
@@ -57,7 +59,7 @@ bun runx admin lookup --username alice
``` ```
Prints id, username, platform, platform id, created/last-login times, and whether Prints id, username, platform, platform id, created/last-login times, and whether
the account has a password and the developer role. the account has a password, the developer role, and the moderator role.
## Options ## Options
+8
View File
@@ -108,3 +108,11 @@ update *args:
[positional-arguments] [positional-arguments]
runx *args: runx *args:
bun runx "$@" bun runx "$@"
# Admin account tools (set-password, clear-password, grant-developer, lookup).
# Run `just admin --help` for usage and examples. See CLI.md.
[group('4. utility')]
[positional-arguments]
[no-cd]
admin *args:
bun runx admin "$@"
+36 -1
View File
@@ -138,6 +138,19 @@ async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
} }
/**
* The elevated role names for an account's token `role` claim, derived from its
* role flags. Base roles (gameClient) are added by generateToken — these are only
* the operator-granted extras. Order is stable so tokens are deterministic.
*/
function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | null): string[] {
if (!account) return []
const roles: string[] = []
if (account.isDeveloper) roles.push('developer')
if (account.isModerator) roles.push('moderator')
return roles
}
/** /**
* The platform an account's `platformId` belongs to. Nothing defaults the `platform` * The platform an account's `platformId` belongs to. Nothing defaults the `platform`
* field (see defaultAccount), so an account can carry a platform identity with no * field (see defaultAccount), so an account can carry a platform identity with no
@@ -453,7 +466,18 @@ const app = new Hono<App>()
) )
} }
const accessToken = await generateToken(accountId, platformId, platform, jwtSecret) // Stamp the account's elevated roles into the token's `role` claim so the client
// authorizes developer/moderator powers from the token itself (not just the
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
// every login and every refresh_token grant.
const roleAccount = await getAccount(c.env.DB, Number(accountId))
const accessToken = await generateToken(
accountId,
platformId,
platform,
jwtSecret,
accountRoles(roleAccount)
)
// Issue a fresh, persisted refresh token (single-use; the client redeems it via // Issue a fresh, persisted refresh token (single-use; the client redeems it via
// grant_type=refresh_token). A refresh grant thus rotates its token. // grant_type=refresh_token). A refresh grant thus rotates its token.
const refreshToken = await issueRefreshToken(c.env.DB, { const refreshToken = await issueRefreshToken(c.env.DB, {
@@ -500,6 +524,7 @@ const app = new Hono<App>()
// Developer role lookup. The role is off by default and only an operator grants // Developer role lookup. The role is off by default and only an operator grants
// it (via `runx admin grant-developer`, which sets the account's isDeveloper flag). // it (via `runx admin grant-developer`, which sets the account's isDeveloper flag).
// The same flag also rides in the token's `role` claim (see accountRoles).
.get('/role/developer/:id', async (c) => { .get('/role/developer/:id', async (c) => {
const { id } = c.req.param() const { id } = c.req.param()
logger.info('developer role lookup', { id }) logger.info('developer role lookup', { id })
@@ -508,4 +533,14 @@ const app = new Hono<App>()
return c.json({ success: account?.isDeveloper === true }) return c.json({ success: account?.isDeveloper === true })
}) })
// Moderator role lookup, mirroring developer. Operator-granted only (via
// `runx admin grant-moderator`); the flag also rides in the token's `role` claim.
.get('/role/moderator/:id', async (c) => {
const { id } = c.req.param()
logger.info('moderator role lookup', { id })
const accountId = Number.parseInt(id, 10)
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
return c.json({ success: account?.isModerator === true })
})
export default app export default app
@@ -256,9 +256,28 @@ describe('auth worker routes', () => {
expect(payload.iss).toBe('https://auth.recflare.net') expect(payload.iss).toBe('https://auth.recflare.net')
expect(payload.aud).toBe('https://auth.recflare.net') expect(payload.aud).toBe('https://auth.recflare.net')
expect(payload.role).toContain('gameClient') expect(payload.role).toContain('gameClient')
// A plain account carries only the base role — no elevated roles.
expect(payload.role).not.toContain('developer')
expect(payload.role).not.toContain('moderator')
expect(payload.scope).toContain('rn.api') expect(payload.scope).toContain('rn.api')
}) })
test('POST /connect/token stamps developer/moderator roles into the token', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 91,
username: 'StaffPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isDeveloper: true,
isModerator: true,
})
)
.run()
const payload = await tokenFor(`account_id=91&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
})
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => { 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' }) const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
expect(res.status).toBe(400) expect(res.status).toBe(400)
@@ -583,6 +602,17 @@ describe('auth worker routes', () => {
expect(await res.json()).toEqual({ success: true }) expect(await res.json()).toEqual({ success: true })
}) })
test('GET /role/moderator/:id reflects the isModerator flag', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 4343, username: 'ModPlayer', isModerator: true }))
.run()
const granted = await exports.default.fetch(`${ORIGIN}/role/moderator/4343`)
expect(await granted.json()).toEqual({ success: true })
// An account without the flag (42) is not a moderator.
const plain = await exports.default.fetch(`${ORIGIN}/role/moderator/42`)
expect(await plain.json()).toEqual({ success: false })
})
test('unknown path returns 404', async () => { test('unknown path returns 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`) const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404) expect(res.status).toBe(404)
+9 -3
View File
@@ -82,11 +82,17 @@ export interface Account {
*/ */
passwordHash?: string passwordHash?: string
/** /**
* Whether this account holds the developer role (backs GET /role/developer/:id). * Whether this account holds the developer role (backs GET /role/developer/:id
* Not set by any player-facing flow — only an operator grants it, via * and the token's `role` claim). Not set by any player-facing flow — only an
* `runx admin grant-developer`. Absent/false means no developer role. * operator grants it, via `runx admin grant-developer`. Absent/false means no role.
*/ */
isDeveloper?: boolean isDeveloper?: boolean
/**
* Whether this account holds the moderator role (backs GET /role/moderator/:id
* and the token's `role` claim). Operator-granted only, via
* `runx admin grant-moderator`. Absent/false means no role.
*/
isModerator?: boolean
} }
interface AccountRow { interface AccountRow {
+10 -4
View File
@@ -69,14 +69,20 @@ const TOKEN_SCOPES = [
'offline_access', 'offline_access',
] ]
/** Roles granted — the client needs `gameClient` to operate. */ /**
const TOKEN_ROLES = ['gameClient' /* 'developer', 'moderator', 'junior'*/] * Base roles every token carries — the client needs `gameClient` to operate.
* Elevated roles (e.g. `developer`, `moderator`) are NOT baked in here; the auth
* worker passes them per-account as `extraRoles` from the account's role flags, so
* a plain player's token stays `['gameClient']` and only granted accounts get more.
*/
const BASE_ROLES = ['gameClient']
export async function generateToken( export async function generateToken(
accountId: string, accountId: string,
platformId: string, platformId: string,
platform: string, platform: string,
secret: string secret: string,
extraRoles: string[] = []
): Promise<string> { ): Promise<string> {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
// The client reads `role`/`scope` (and expects a well-formed iss/aud) to // The client reads `role`/`scope` (and expects a well-formed iss/aud) to
@@ -97,7 +103,7 @@ export async function generateToken(
platform_id: platformId, platform_id: platformId,
'rn.ver': '20230302', 'rn.ver': '20230302',
'rn.plat': '0', 'rn.plat': '0',
role: TOKEN_ROLES, role: [...BASE_ROLES, ...extraRoles],
scope: TOKEN_SCOPES, scope: TOKEN_SCOPES,
jti: crypto.randomUUID(), jti: crypto.randomUUID(),
}, },
+49 -20
View File
@@ -194,23 +194,35 @@ const clearPassword = new Command('clear-password')
console.log(chalk.green(`✓ password cleared for ${label}`)) console.log(chalk.green(`✓ password cleared for ${label}`))
}) })
const grantDeveloper = new Command('grant-developer') /**
.description('Grant (or, with --revoke, remove) the developer role on an account') * Build a `grant-<role>` command that toggles a boolean role flag on the account
.option('--account <id>', 'Account id to target') * blob. `jsonKey` is the account field (e.g. `isDeveloper`) — a fixed literal, not
.option('--username <name>', 'Username to target (case-insensitive)') * user input. Both the /role/:role lookup and the token's `role` claim read it.
.option('--revoke', 'Remove the developer role instead of granting it', false) */
.option('--local', 'Target the local dev database (the default).', false) function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) {
.option('--remote', 'Target the deployed database instead of the local dev database.', false) return new Command(name)
.action(async (opts) => { .description(`Grant (or, with --revoke, remove) the ${roleLabel} role on an account`)
const { where, label } = whereClause(opts.account, opts.username) .option('--account <id>', 'Account id to target')
const remote = resolveRemote(opts) .option('--username <name>', 'Username to target (case-insensitive)')
const value = opts.revoke ? 'false' : 'true' .option('--revoke', `Remove the ${roleLabel} role instead of granting it`, false)
const sql = `UPDATE account SET data = json_set(data, '$.isDeveloper', json('${value}')) WHERE ${where} RETURNING account_id` .option('--local', 'Target the local dev database (the default).', false)
const verb = opts.revoke ? 'Revoking' : 'Granting' .option('--remote', 'Target the deployed database instead of the local dev database.', false)
console.log(`${verb} developer role for ${label} on ${target(remote)}`) .action(async (opts) => {
assertMatched(await execSql(sql, remote), label) const { where, label } = whereClause(opts.account, opts.username)
console.log(chalk.green(`✓ developer role ${opts.revoke ? 'revoked' : 'granted'} for ${label}`)) const remote = resolveRemote(opts)
}) const value = opts.revoke ? 'false' : 'true'
const sql = `UPDATE account SET data = json_set(data, '$.${jsonKey}', json('${value}')) WHERE ${where} RETURNING account_id`
const verb = opts.revoke ? 'Revoking' : 'Granting'
console.log(`${verb} ${roleLabel} role for ${label} on ${target(remote)}`)
assertMatched(await execSql(sql, remote), label)
console.log(
chalk.green(`${roleLabel} role ${opts.revoke ? 'revoked' : 'granted'} for ${label}`)
)
})
}
const grantDeveloper = grantRoleCommand('grant-developer', 'isDeveloper', 'developer')
const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moderator')
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')
@@ -229,7 +241,8 @@ const lookup = new Command('lookup')
json_extract(data, '$.createdAt') AS createdAt, json_extract(data, '$.createdAt') AS createdAt,
json_extract(data, '$.lastLoginTime') AS lastLoginTime, json_extract(data, '$.lastLoginTime') AS lastLoginTime,
(json_extract(data, '$.passwordHash') IS NOT NULL) AS hasPassword, (json_extract(data, '$.passwordHash') IS NOT NULL) AS hasPassword,
(json_extract(data, '$.isDeveloper') = 1) AS isDeveloper (json_extract(data, '$.isDeveloper') = 1) AS isDeveloper,
(json_extract(data, '$.isModerator') = 1) AS isModerator
FROM account WHERE ${where}` FROM account WHERE ${where}`
const res = await execSql(sql, remote) const res = await execSql(sql, remote)
const row = res.results[0] const row = res.results[0]
@@ -239,10 +252,10 @@ const lookup = new Command('lookup')
} }
const asText = (v: unknown): string => const asText = (v: unknown): string =>
v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v as number | string | boolean) v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v as number | string | boolean)
const boolKeys = new Set(['hasPassword', 'isDeveloper', 'isModerator'])
const table = new Table() const table = new Table()
for (const [key, value] of Object.entries(row)) { for (const [key, value] of Object.entries(row)) {
const shown = const shown = boolKeys.has(key) ? (value === 1 ? 'yes' : 'no') : asText(value)
key === 'hasPassword' || key === 'isDeveloper' ? (value === 1 ? 'yes' : 'no') : asText(value)
table.push({ [key]: shown }) table.push({ [key]: shown })
} }
console.log(table.toString()) console.log(table.toString())
@@ -253,4 +266,20 @@ export const adminCmd = new Command('admin')
.addCommand(setPassword) .addCommand(setPassword)
.addCommand(clearPassword) .addCommand(clearPassword)
.addCommand(grantDeveloper) .addCommand(grantDeveloper)
.addCommand(grantModerator)
.addCommand(lookup) .addCommand(lookup)
.addHelpText(
'after',
`
Select an account with --account <id> or --username <name>.
Target --local (default) or --remote (production; needs RECFLARE_D1 in .env).
Add --help to any subcommand for its options, e.g. \`runx admin set-password --help\`.
Examples:
$ runx admin set-password --account 1 # prompts, hidden
$ echo "s3cret" | runx admin set-password --account 1
$ runx admin clear-password --username alice
$ runx admin grant-developer --account 1 [--revoke]
$ runx admin grant-moderator --username alice --remote
$ runx admin lookup --username alice --remote`
)