diff --git a/CLI.md b/CLI.md new file mode 100644 index 0000000..9159a0b --- /dev/null +++ b/CLI.md @@ -0,0 +1,85 @@ +# Admin CLI + +Operator tools for accounts on the shared `recflare` D1 database, exposed as an +`admin` command group on the repo's `runx` CLI. Each command shells out to +`wrangler d1 execute recflare` — no running worker or auth token needed. + +Run from anywhere in the repo: + +```sh +bun runx admin [options] +``` + +## Commands + +### `set-password` — set (or replace) an account's login password + +```sh +bun runx admin set-password --account 1 +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): + +```sh +# interactive (prompts, hidden) +bun runx admin set-password --account 1 + +# non-interactive / scripted +echo "s3cret-pw" | bun runx admin set-password --account 1 +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). + +```sh +bun runx admin clear-password --username alice +``` + +### `grant-developer` — grant or revoke the developer role + +Backs `GET /role/developer/:id`. Off by default; only this command grants it. + +```sh +bun runx admin grant-developer --account 1 +bun runx admin grant-developer --account 1 --revoke +``` + +### `lookup` — print an account + +```sh +bun runx admin lookup --account 1 +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. + +## Options + +### Selecting an account + +Every command targets exactly one account, by **either**: + +- `--account ` — numeric account id +- `--username ` — username (case-insensitive) + +### Choosing the database + +- `--local` — the local dev database (**the default**) +- `--remote` — the deployed (production) database + +Passing both is an error. `--remote` requires `RECFLARE_D1` in the gitignored root +`.env` (see `.env.example`) and a wrangler login with access to the account. + +## Notes + +- Password hashing matches the auth worker exactly (PBKDF2-SHA256), so a password + set here verifies at login. +- A command that matches no account exits non-zero with `no account found for …`. +- Local writes target `apps/auth`'s dev D1 state; run `bun turbo -F auth migrate -- --local` + first if the local database hasn't been migrated yet. diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index f5e81b2..3623af7 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -9,16 +9,17 @@ import { getAccountByUsername, getAccountsByPlatformId, getPasswordHash, + hashPassword, RoomInstanceType, setLastLoginTime, setLoginContext, setPasswordHash, setPresence, + verifyPassword, } from '@repo/domain' import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers' import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt' -import { hashPassword, verifyPassword } from './password' import { consumeRefreshToken, issueRefreshToken } from './refresh-db' import { verifySteamTicket } from './steam-ticket' @@ -497,11 +498,14 @@ const app = new Hono() return c.json({ success: true }) }) - // Developer role lookup. No developer role granted by default. - .get('/role/developer/:id', (c) => { + // 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). + .get('/role/developer/:id', async (c) => { const { id } = c.req.param() logger.info('developer role lookup', { id }) - return c.json({ success: false }) + const accountId = Number.parseInt(id, 10) + const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId) + return c.json({ success: account?.isDeveloper === 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 d29693f..98c4bfa 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -4,10 +4,9 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../auth.app' -import { getAccountsByDeviceId, PRESENCE_SCHEMA_DDL, SCHEMA_DDL } from '@repo/domain' +import { getAccountsByDeviceId, hashPassword, PRESENCE_SCHEMA_DDL, SCHEMA_DDL } from '@repo/domain' import { isLinkedToPlatformIdentity } from '../../auth.app' -import { hashPassword } from '../../password' import { REFRESH_SCHEMA_DDL } from '../../refresh-db' import type { Env } from '../../context' @@ -575,6 +574,15 @@ describe('auth worker routes', () => { expect(await res.json()).toEqual({ success: false }) }) + test('GET /role/developer/:id grants developer when the account is flagged', async () => { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true })) + .run() + const res = await exports.default.fetch(`${ORIGIN}/role/developer/4242`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + }) + 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 7575910..1dc2a1f 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -81,6 +81,12 @@ export interface Account { * DTO (the DTO builders pick only known fields), so it doesn't leak. */ 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. + */ + isDeveloper?: boolean } interface AccountRow { diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index fbfcc81..f29375a 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,5 +1,6 @@ export { RoomInstanceType, Accessibility, Role } from './enums' export * from './accounts-db' +export * from './password' export * from './rooms-db' export * from './room-instance-db' export * from './presence-db' diff --git a/apps/auth/src/password.ts b/packages/domain/src/password.ts similarity index 67% rename from apps/auth/src/password.ts rename to packages/domain/src/password.ts index 4d86707..8706c4d 100644 --- a/apps/auth/src/password.ts +++ b/packages/domain/src/password.ts @@ -1,7 +1,13 @@ /** - * Password hashing for /connect/token credential login and - * /account/me/changepassword. PBKDF2-SHA256 with a random per-password salt, - * stored as `salt:hash` (both base64). The raw password is never persisted. + * Password hashing, shared by everything that reads or writes an account's + * credential: the `auth` worker (/connect/token credential login and + * /account/me/changepassword) and the `admin` CLI (`runx admin set-password`). + * It lives here in @repo/domain — next to the account storage the hash is written + * into — so there is exactly one definition of the on-disk format and a hash minted + * by one caller always verifies in another. + * + * PBKDF2-SHA256 with a random per-password salt, stored as `salt:hash` (both + * base64). The raw password is never persisted. */ const ITERATIONS = 100_000 diff --git a/packages/tools/src/bin/runx.cmd.ts b/packages/tools/src/bin/runx.cmd.ts index c4def41..6de3768 100644 --- a/packages/tools/src/bin/runx.cmd.ts +++ b/packages/tools/src/bin/runx.cmd.ts @@ -3,6 +3,7 @@ import 'zx/globals' import { program } from '@commander-js/extra-typings' import { catchProcessError } from '@jahands/cli-tools/proc' +import { adminCmd } from '../cmd/admin.cmd' import { buildCmd } from '../cmd/build.cmd' import { checkCmd } from '../cmd/check.cmd' import { ciCmd } from '../cmd/ci.cmd' @@ -18,6 +19,7 @@ program // While `packages/tools/bin` scripts work well for simple tasks, // a typescript CLI is nicer for more complex things. + .addCommand(adminCmd) .addCommand(fixCmd) .addCommand(buildCmd) .addCommand(checkCmd) diff --git a/packages/tools/src/cmd/admin.cmd.ts b/packages/tools/src/cmd/admin.cmd.ts new file mode 100644 index 0000000..049f759 --- /dev/null +++ b/packages/tools/src/cmd/admin.cmd.ts @@ -0,0 +1,256 @@ +import * as readline from 'node:readline' + +import { Command } from '@commander-js/extra-typings' +import Table from 'cli-table3' + +import { getRepoRoot } from '../path' +import { hashPassword } from '../password' + +/** + * Operator-facing admin tools for the shared `recflare` D1 database. Each command + * shells out to `wrangler d1 execute recflare` — no running worker or auth token + * needed — defaulting to the local dev database and targeting the deployed one only + * with `--remote`. Password hashing comes from @repo/domain, the same code the auth + * worker uses, so a hash set here always verifies at login. + * + * runx admin set-password --account 1 [--remote] + * runx admin clear-password --username alice [--remote] + * runx admin lookup --username alice [--remote] + * runx admin grant-developer --account 1 [--revoke] [--remote] + */ + +/** The one shared database every D1-backed worker binds. */ +const DB_NAME = 'recflare' + +interface D1ExecResult { + results: Array> + success: boolean + meta: { changes?: number; rows_read?: number } +} + +/** Escape a value for embedding inside a single-quoted SQL string literal. */ +const sqlStr = (s: string): string => s.replace(/'/g, "''") + +/** + * Resolve the account selector into a SQL WHERE fragment. Exactly one of + * `--account` / `--username` must be given. Account ids are validated numeric; + * usernames match the indexed, case-insensitive `username_lower` generated column. + */ +function whereClause(account?: string, username?: string): { where: string; label: string } { + if ((account == null) === (username == null)) { + throw new Error('provide exactly one of --account or --username') + } + if (account != null) { + if (!/^\d+$/.test(account)) throw new Error('--account must be a numeric account id') + return { where: `account_id = ${account}`, label: `account ${account}` } + } + return { + where: `username_lower = '${sqlStr(username!.toLowerCase())}'`, + label: `username "${username}"`, + } +} + +/** The deployed D1's real id, from the environment or the gitignored root .env. */ +async function getRemoteD1Id(): Promise { + if (process.env.RECFLARE_D1) return process.env.RECFLARE_D1 + 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) + if (m) return m[1].replace(/^["']|["']$/g, '') + } + throw new Error('RECFLARE_D1 is not set — add the recflare D1 id to .env (see .env.example)') +} + +/** + * Run a SQL statement against the shared database via wrangler. Runs from the auth + * worker's directory (it owns the accounts schema and binds the DB). For `--remote` + * the committed wrangler.jsonc's "local" database_id placeholder is spliced with the + * real id into a gitignored generated config — exactly like run-wrangler-migrate. + */ +async function execSql(sql: string, remote: boolean): Promise { + const authDir = path.join(getRepoRoot(), 'apps', 'auth') + cd(authDir) + + const args = ['d1', 'execute', DB_NAME, '--command', sql, '--json'] + let cleanup: (() => Promise) | undefined + + if (remote) { + const id = await getRemoteD1Id() + const src = await fs.readFile(path.join(authDir, 'wrangler.jsonc'), 'utf8') + const generated = src.replace(/("database_id"\s*:\s*")[^"]*(")/, `$1${id}$2`) + const genPath = path.join(authDir, 'wrangler.generated.jsonc') + await fs.writeFile(genPath, generated) + cleanup = () => fs.remove(genPath) + args.push('--config', 'wrangler.generated.jsonc', '--remote') + } else { + args.push('--local') + } + + try { + // Via `pnpm exec` so wrangler resolves from the auth worker's node_modules + // (it isn't a dependency of @repo/tools, so it's not on this process's PATH). + const out = await $`pnpm exec wrangler ${args}`.quiet() + // wrangler --json prints a one-element array of results to stdout. + const start = out.stdout.indexOf('[') + if (start === -1) throw new Error(`unexpected d1 execute output:\n${out.stdout}`) + const parsed = JSON.parse(out.stdout.slice(start)) as D1ExecResult[] + const first = parsed[0] + if (!first) throw new Error(`empty d1 execute result:\n${out.stdout}`) + return first + } finally { + if (cleanup) await cleanup() + } +} + +/** Prompt for a line of input without echoing what's typed (for passwords). */ +function promptHidden(query: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }) + // Mute the echo of typed characters; write the prompt ourselves. + ;(rl as unknown as { _writeToOutput: (s: string) => void })._writeToOutput = () => {} + process.stdout.write(query) + rl.question('', (answer) => { + process.stdout.write('\n') + rl.close() + resolve(answer) + }) + }) +} + +/** + * Get the new password: from `--password`, else from piped stdin (for scripting), + * else prompted interactively (hidden, entered twice and compared). + */ +async function resolvePassword(flag?: string): Promise { + if (flag != null && flag !== '') return flag + if (!process.stdin.isTTY) { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) chunks.push(chunk as Buffer) + const piped = Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '') + if (piped === '') throw new Error('no password provided on stdin') + return piped + } + const first = await promptHidden('New password: ') + if (first === '') throw new Error('password must not be empty') + const second = await promptHidden('Confirm password: ') + if (first !== second) throw new Error('passwords did not match') + return first +} + +/** A short, loud label for which database a command is about to touch. */ +const target = (remote: boolean): string => + remote ? chalk.red(`${DB_NAME} (remote)`) : chalk.cyan(`${DB_NAME} (local)`) + +/** Resolve the --local/--remote target flags. Local is the default. */ +function resolveRemote(opts: { local?: boolean; remote?: boolean }): boolean { + if (opts.local && opts.remote) throw new Error('pass at most one of --local / --remote') + return opts.remote === true +} + +/** + * Fail when a WHERE-scoped UPDATE matched no row (i.e. no such account). Relies on + * the statement's `RETURNING account_id` — wrangler's `--json` meta doesn't reliably + * carry a `changes` count, but the returned rows always reflect what actually matched. + */ +function assertMatched(res: D1ExecResult, label: string): void { + if (res.results.length < 1) throw new Error(`no account found for ${label}`) +} + +const setPassword = new Command('set-password') + .description("Set (or replace) an account's login password") + .option('--account ', 'Account id to target') + .option('--username ', 'Username to target (case-insensitive)') + .option('--password ', 'The new password (omit to be prompted, or pipe via stdin)') + .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 password = await resolvePassword(opts.password) + const hash = await hashPassword(password) + const sql = `UPDATE account SET data = json_set(data, '$.passwordHash', '${sqlStr(hash)}') WHERE ${where} RETURNING account_id` + console.log(`Setting password for ${label} on ${target(remote)}`) + assertMatched(await execSql(sql, remote), label) + console.log(chalk.green(`✓ password set for ${label}`)) + }) + +const clearPassword = new Command('clear-password') + .description("Remove an account's password so it has no login credential") + .option('--account ', 'Account id to target') + .option('--username ', 'Username to target (case-insensitive)') + .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 sql = `UPDATE account SET data = json_remove(data, '$.passwordHash') WHERE ${where} RETURNING account_id` + console.log(`Clearing password for ${label} on ${target(remote)}`) + assertMatched(await execSql(sql, remote), 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') + .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}`)) + }) + +const lookup = new Command('lookup') + .description('Print an account by id or username') + .option('--account ', 'Account id to look up') + .option('--username ', 'Username to look up (case-insensitive)') + .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 sql = `SELECT + json_extract(data, '$.accountId') AS accountId, + json_extract(data, '$.username') AS username, + json_extract(data, '$.platform') AS platform, + json_extract(data, '$.platformId') AS platformId, + 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 + FROM account WHERE ${where}` + const res = await execSql(sql, remote) + const row = res.results[0] + if (!row) { + console.log(chalk.yellow(`no account found for ${label} on ${target(remote)}`)) + return + } + const asText = (v: unknown): string => + v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v as number | string | boolean) + const table = new Table() + for (const [key, value] of Object.entries(row)) { + const shown = + key === 'hasPassword' || key === 'isDeveloper' ? (value === 1 ? 'yes' : 'no') : asText(value) + table.push({ [key]: shown }) + } + console.log(table.toString()) + }) + +export const adminCmd = new Command('admin') + .description('Operator tools for accounts on the shared recflare D1 database') + .addCommand(setPassword) + .addCommand(clearPassword) + .addCommand(grantDeveloper) + .addCommand(lookup) diff --git a/packages/tools/src/password.spec.ts b/packages/tools/src/password.spec.ts new file mode 100644 index 0000000..0402f49 --- /dev/null +++ b/packages/tools/src/password.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { hashPassword, verifyPassword } from './password' + +describe('password hashing', () => { + it('produces a base64 salt:hash pair', async () => { + const stored = await hashPassword('hunter2') + const [salt, hash] = stored.split(':') + expect(salt).toMatch(/^[A-Za-z0-9+/]+=*$/) + expect(hash).toMatch(/^[A-Za-z0-9+/]+=*$/) + }) + + it('round-trips a password it hashed', async () => { + const stored = await hashPassword('correct horse') + expect(await verifyPassword('correct horse', stored)).toBe(true) + expect(await verifyPassword('wrong horse', stored)).toBe(false) + }) + + // Golden vector: a `salt:hash` computed with the canonical parameters (PBKDF2- + // SHA256, 100k iterations, 256-bit). If this stops verifying, the CLI's hashing + // has drifted from @repo/domain and CLI-set passwords would fail at login. + it('verifies a hash produced with the canonical parameters', async () => { + const stored = 'BwcHBwcHBwcHBwcHBwcHBw==:QVZpoT+KgLqdTSvH1SI33TYsRXA/zkepPPmNBUZ8RyE=' + expect(await verifyPassword('correct horse', stored)).toBe(true) + expect(await verifyPassword('nope', stored)).toBe(false) + }) +}) diff --git a/packages/tools/src/password.ts b/packages/tools/src/password.ts new file mode 100644 index 0000000..463755e --- /dev/null +++ b/packages/tools/src/password.ts @@ -0,0 +1,46 @@ +/** + * Password hashing for the `admin` CLI. This is a deliberate copy of the canonical + * implementation in `@repo/domain` (packages/domain/src/password.ts), which the auth + * worker uses to verify logins. It's duplicated rather than imported because + * `@repo/tools` cannot depend on a workspace package (every package depends on + * `@repo/tools` for its scripts, so importing one back would be a dependency cycle). + * + * The format MUST stay identical to the canonical version or a password set by the + * CLI won't verify at login — `password.spec.ts` round-trips a hash to catch drift. + * PBKDF2-SHA256, random 16-byte salt, stored as `salt:hash` (both base64). + */ +const ITERATIONS = 100_000 + +const b64 = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes)) +const fromB64 = (s: string): Uint8Array => + Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0)) + +async function deriveBits(password: string, salt: Uint8Array): Promise { + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + 'PBKDF2', + false, + ['deriveBits'] + ) + const bits = await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt, iterations: ITERATIONS, hash: 'SHA-256' }, + keyMaterial, + 256 + ) + return new Uint8Array(bits) +} + +/** Hash a password into a `salt:hash` string (both base64). */ +export async function hashPassword(password: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)) + return `${b64(salt)}:${b64(await deriveBits(password, salt))}` +} + +/** Verify a password against a stored `salt:hash`. */ +export async function verifyPassword(password: string, stored: string): Promise { + const [saltB64, hashB64] = stored.split(':') + if (!saltB64 || !hashB64) return false + const actual = b64(await deriveBits(password, fromB64(saltB64))) + return actual === hashB64 +}