mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
add a few CLI commands
This commit is contained in:
@@ -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 <command> [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 <pw>`, 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 <id>` — numeric account id
|
||||||
|
- `--username <name>` — 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.
|
||||||
@@ -9,16 +9,17 @@ import {
|
|||||||
getAccountByUsername,
|
getAccountByUsername,
|
||||||
getAccountsByPlatformId,
|
getAccountsByPlatformId,
|
||||||
getPasswordHash,
|
getPasswordHash,
|
||||||
|
hashPassword,
|
||||||
RoomInstanceType,
|
RoomInstanceType,
|
||||||
setLastLoginTime,
|
setLastLoginTime,
|
||||||
setLoginContext,
|
setLoginContext,
|
||||||
setPasswordHash,
|
setPasswordHash,
|
||||||
setPresence,
|
setPresence,
|
||||||
|
verifyPassword,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import { hashPassword, verifyPassword } from './password'
|
|
||||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||||
import { verifySteamTicket } from './steam-ticket'
|
import { verifySteamTicket } from './steam-ticket'
|
||||||
|
|
||||||
@@ -497,11 +498,14 @@ const app = new Hono<App>()
|
|||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Developer role lookup. No developer role granted by default.
|
// Developer role lookup. The role is off by default and only an operator grants
|
||||||
.get('/role/developer/:id', (c) => {
|
// it (via `runx admin grant-developer`, which sets the account's isDeveloper flag).
|
||||||
|
.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 })
|
||||||
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
|
export default app
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
|
|
||||||
import '../../auth.app'
|
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 { isLinkedToPlatformIdentity } from '../../auth.app'
|
||||||
import { hashPassword } from '../../password'
|
|
||||||
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
@@ -575,6 +574,15 @@ describe('auth worker routes', () => {
|
|||||||
expect(await res.json()).toEqual({ success: false })
|
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 () => {
|
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)
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ export interface Account {
|
|||||||
* DTO (the DTO builders pick only known fields), so it doesn't leak.
|
* DTO (the DTO builders pick only known fields), so it doesn't leak.
|
||||||
*/
|
*/
|
||||||
passwordHash?: string
|
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 {
|
interface AccountRow {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { RoomInstanceType, Accessibility, Role } from './enums'
|
export { RoomInstanceType, Accessibility, Role } from './enums'
|
||||||
export * from './accounts-db'
|
export * from './accounts-db'
|
||||||
|
export * from './password'
|
||||||
export * from './rooms-db'
|
export * from './rooms-db'
|
||||||
export * from './room-instance-db'
|
export * from './room-instance-db'
|
||||||
export * from './presence-db'
|
export * from './presence-db'
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Password hashing for /connect/token credential login and
|
* Password hashing, shared by everything that reads or writes an account's
|
||||||
* /account/me/changepassword. PBKDF2-SHA256 with a random per-password salt,
|
* credential: the `auth` worker (/connect/token credential login and
|
||||||
* stored as `salt:hash` (both base64). The raw password is never persisted.
|
* /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
|
const ITERATIONS = 100_000
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ import 'zx/globals'
|
|||||||
import { program } from '@commander-js/extra-typings'
|
import { program } from '@commander-js/extra-typings'
|
||||||
import { catchProcessError } from '@jahands/cli-tools/proc'
|
import { catchProcessError } from '@jahands/cli-tools/proc'
|
||||||
|
|
||||||
|
import { adminCmd } from '../cmd/admin.cmd'
|
||||||
import { buildCmd } from '../cmd/build.cmd'
|
import { buildCmd } from '../cmd/build.cmd'
|
||||||
import { checkCmd } from '../cmd/check.cmd'
|
import { checkCmd } from '../cmd/check.cmd'
|
||||||
import { ciCmd } from '../cmd/ci.cmd'
|
import { ciCmd } from '../cmd/ci.cmd'
|
||||||
@@ -18,6 +19,7 @@ program
|
|||||||
// While `packages/tools/bin` scripts work well for simple tasks,
|
// While `packages/tools/bin` scripts work well for simple tasks,
|
||||||
// a typescript CLI is nicer for more complex things.
|
// a typescript CLI is nicer for more complex things.
|
||||||
|
|
||||||
|
.addCommand(adminCmd)
|
||||||
.addCommand(fixCmd)
|
.addCommand(fixCmd)
|
||||||
.addCommand(buildCmd)
|
.addCommand(buildCmd)
|
||||||
.addCommand(checkCmd)
|
.addCommand(checkCmd)
|
||||||
|
|||||||
@@ -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<Record<string, unknown>>
|
||||||
|
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<string> {
|
||||||
|
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<D1ExecResult> {
|
||||||
|
const authDir = path.join(getRepoRoot(), 'apps', 'auth')
|
||||||
|
cd(authDir)
|
||||||
|
|
||||||
|
const args = ['d1', 'execute', DB_NAME, '--command', sql, '--json']
|
||||||
|
let cleanup: (() => Promise<void>) | 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<string> {
|
||||||
|
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<string> {
|
||||||
|
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 <id>', 'Account id to target')
|
||||||
|
.option('--username <name>', 'Username to target (case-insensitive)')
|
||||||
|
.option('--password <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 <id>', 'Account id to target')
|
||||||
|
.option('--username <name>', '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 <id>', 'Account id to target')
|
||||||
|
.option('--username <name>', '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 <id>', 'Account id to look up')
|
||||||
|
.option('--username <name>', '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)
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<ArrayBuffer> =>
|
||||||
|
Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0))
|
||||||
|
|
||||||
|
async function deriveBits(password: string, salt: Uint8Array<ArrayBuffer>): Promise<Uint8Array> {
|
||||||
|
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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
const [saltB64, hashB64] = stored.split(':')
|
||||||
|
if (!saltB64 || !hashB64) return false
|
||||||
|
const actual = b64(await deriveBits(password, fromB64(saltB64)))
|
||||||
|
return actual === hashB64
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user