From f02a75aed4d53d07264b580b2a51330d21ce767e Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 16 Jul 2026 12:25:25 -0400 Subject: [PATCH] Issue #14: add isDeveloper/isModerator CLI commands --- CLI.md | 14 +++-- Justfile | 8 +++ apps/auth/src/auth.app.ts | 37 +++++++++++- apps/auth/src/test/integration/api.test.ts | 30 ++++++++++ packages/domain/src/accounts-db.ts | 12 +++- packages/jwt/src/jwt.ts | 14 +++-- packages/tools/src/cmd/admin.cmd.ts | 69 +++++++++++++++------- 7 files changed, 150 insertions(+), 34 deletions(-) diff --git a/CLI.md b/CLI.md index 9159a0b..9818697 100644 --- a/CLI.md +++ b/CLI.md @@ -20,7 +20,7 @@ bun runx admin set-password --username alice --remote ``` The new password is taken from `--password `, else from piped stdin, else -prompted interactively (hidden input, entered twice and compared): +prompted interactively. ```sh # interactive (prompts, hidden) @@ -33,20 +33,22 @@ bun runx admin set-password --account 1 --password "s3cret-pw" ### `clear-password` — remove an account's password -Leaves the account with no login credential (it can't be logged into until a -password is set again). +Leaves the account with no login credential (only platform login). ```sh 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//: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 bun runx admin grant-developer --account 1 bun runx admin grant-developer --account 1 --revoke +bun runx admin grant-moderator --username alice --remote ``` ### `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 -the account has a password and the developer role. +the account has a password, the developer role, and the moderator role. ## Options diff --git a/Justfile b/Justfile index ce49ce3..8488ba9 100644 --- a/Justfile +++ b/Justfile @@ -108,3 +108,11 @@ update *args: [positional-arguments] runx *args: 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 "$@" diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 3623af7..5b80ac8 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -138,6 +138,19 @@ async function authedId(c: Context): Promise { 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 | 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` * field (see defaultAccount), so an account can carry a platform identity with no @@ -453,7 +466,18 @@ const app = new Hono() ) } - 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 // grant_type=refresh_token). A refresh grant thus rotates its token. const refreshToken = await issueRefreshToken(c.env.DB, { @@ -500,6 +524,7 @@ const app = new Hono() // 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). + // The same flag also rides in the token's `role` claim (see accountRoles). .get('/role/developer/:id', async (c) => { const { id } = c.req.param() logger.info('developer role lookup', { id }) @@ -508,4 +533,14 @@ const app = new Hono() 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 diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 98c4bfa..6fe9066 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -256,9 +256,28 @@ describe('auth worker routes', () => { expect(payload.iss).toBe('https://auth.recflare.net') expect(payload.aud).toBe('https://auth.recflare.net') 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') }) + 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 () => { const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' }) expect(res.status).toBe(400) @@ -583,6 +602,17 @@ describe('auth worker routes', () => { 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 () => { const res = await exports.default.fetch(`${ORIGIN}/nope`) expect(res.status).toBe(404) diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 1dc2a1f..019b073 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -82,11 +82,17 @@ export interface Account { */ passwordHash?: string /** - * 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 - * `runx admin grant-developer`. Absent/false means no developer role. + * Whether this account holds the developer role (backs GET /role/developer/:id + * and the token's `role` claim). Not set by any player-facing flow — only an + * operator grants it, via `runx admin grant-developer`. Absent/false means no role. */ 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 { diff --git a/packages/jwt/src/jwt.ts b/packages/jwt/src/jwt.ts index 2b30eb5..d16d771 100644 --- a/packages/jwt/src/jwt.ts +++ b/packages/jwt/src/jwt.ts @@ -69,14 +69,20 @@ const TOKEN_SCOPES = [ '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( accountId: string, platformId: string, platform: string, - secret: string + secret: string, + extraRoles: string[] = [] ): Promise { const now = Math.floor(Date.now() / 1000) // The client reads `role`/`scope` (and expects a well-formed iss/aud) to @@ -97,7 +103,7 @@ export async function generateToken( platform_id: platformId, 'rn.ver': '20230302', 'rn.plat': '0', - role: TOKEN_ROLES, + role: [...BASE_ROLES, ...extraRoles], scope: TOKEN_SCOPES, jti: crypto.randomUUID(), }, diff --git a/packages/tools/src/cmd/admin.cmd.ts b/packages/tools/src/cmd/admin.cmd.ts index 049f759..22bfff0 100644 --- a/packages/tools/src/cmd/admin.cmd.ts +++ b/packages/tools/src/cmd/admin.cmd.ts @@ -194,23 +194,35 @@ const clearPassword = new Command('clear-password') 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') - .option('--account ', 'Account id to target') - .option('--username ', 'Username to target (case-insensitive)') - .option('--revoke', 'Remove the developer role instead of granting it', 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 (opts) => { - const { where, label } = whereClause(opts.account, opts.username) - const remote = resolveRemote(opts) - const value = opts.revoke ? 'false' : 'true' - const sql = `UPDATE account SET data = json_set(data, '$.isDeveloper', json('${value}')) WHERE ${where} RETURNING account_id` - const verb = opts.revoke ? 'Revoking' : 'Granting' - console.log(`${verb} developer role for ${label} on ${target(remote)}`) - assertMatched(await execSql(sql, remote), label) - console.log(chalk.green(`✓ developer role ${opts.revoke ? 'revoked' : 'granted'} for ${label}`)) - }) +/** + * Build a `grant-` command that toggles a boolean role flag on the account + * blob. `jsonKey` is the account field (e.g. `isDeveloper`) — a fixed literal, not + * user input. Both the /role/:role lookup and the token's `role` claim read it. + */ +function grantRoleCommand(name: string, jsonKey: string, roleLabel: string) { + return new Command(name) + .description(`Grant (or, with --revoke, remove) the ${roleLabel} role on an account`) + .option('--account ', 'Account id to target') + .option('--username ', 'Username to target (case-insensitive)') + .option('--revoke', `Remove the ${roleLabel} role instead of granting it`, 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 (opts) => { + const { where, label } = whereClause(opts.account, opts.username) + 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') .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, '$.lastLoginTime') AS lastLoginTime, (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}` const res = await execSql(sql, remote) const row = res.results[0] @@ -239,10 +252,10 @@ const lookup = new Command('lookup') } const asText = (v: unknown): string => 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() for (const [key, value] of Object.entries(row)) { - const shown = - key === 'hasPassword' || key === 'isDeveloper' ? (value === 1 ? 'yes' : 'no') : asText(value) + const shown = boolKeys.has(key) ? (value === 1 ? 'yes' : 'no') : asText(value) table.push({ [key]: shown }) } console.log(table.toString()) @@ -253,4 +266,20 @@ export const adminCmd = new Command('admin') .addCommand(setPassword) .addCommand(clearPassword) .addCommand(grantDeveloper) + .addCommand(grantModerator) .addCommand(lookup) + .addHelpText( + 'after', + ` +Select an account with --account or --username . +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` + )