mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
Issue #14: add isDeveloper/isModerator CLI commands
This commit is contained in:
@@ -138,6 +138,19 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
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`
|
||||
* 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
|
||||
// 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<App>()
|
||||
|
||||
// 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<App>()
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user