more routes

This commit is contained in:
Devin Zuczek
2026-06-30 22:32:08 -04:00
parent 3a3f96bba6
commit bf88bcc48f
14 changed files with 447 additions and 152 deletions
+23 -23
View File
@@ -15,24 +15,24 @@ export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS accounts (
data TEXT NOT NULL,
avatar TEXT,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.AccountId')) VIRTUAL,
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Username'))) VIRTUAL
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON accounts (account_id)`,
`CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON accounts (username_lower)`,
]
/** Client-facing account shape (PascalCase, as the client expects). */
/** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */
export interface Account {
AccountId: number
Username: string
DisplayName: string
ProfileImage: string
IsJunior: boolean
Platforms: number
PersonalPronouns: number
IdentityFlags: number
CreatedAt: string
accountId: number
username: string
displayName: string
profileImage: string
isJunior: boolean
platforms: number
personalPronouns: number
identityFlags: number
createdAt: string
}
interface AccountRow {
@@ -68,15 +68,15 @@ export function randomUsername(): string {
*/
export function defaultAccount(id: number, overrides: Partial<Account> = {}): Account {
return {
AccountId: id,
Username: `Player${id}`,
DisplayName: `Player${id}`,
ProfileImage: 'DefaultProfileImage.jpg',
IsJunior: false,
Platforms: 0,
PersonalPronouns: 0,
IdentityFlags: 0,
CreatedAt: new Date().toISOString(),
accountId: id,
username: `Player${id}`,
displayName: `Player${id}`,
profileImage: 'DefaultProfileImage.jpg',
isJunior: false,
platforms: 0,
personalPronouns: 0,
identityFlags: 0,
createdAt: new Date().toISOString(),
...overrides,
}
}
@@ -112,8 +112,8 @@ export async function createAccount(
.prepare('SELECT COALESCE(MAX(account_id), 1) + 1 AS next FROM accounts')
.first<{ next: number }>()
const id = row?.next ?? 2
const username = overrides.Username ?? randomUsername()
const account = defaultAccount(id, { Username: username, DisplayName: username, ...overrides })
const username = overrides.username ?? randomUsername()
const account = defaultAccount(id, { username, displayName: username, ...overrides })
await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(JSON.stringify(account)).run()
return account
}
+19 -18
View File
@@ -109,20 +109,14 @@ const app = new Hono<App>()
// EAC challenge — a fresh GUID, JSON-quoted, served as plain text.
.get('/eac/challenge', (c) => c.text(`"AA=="`))
// Cached logins for a platform id. No DB binding yet — always empty.
// Cached logins for a platform id. No CachedLogins storage yet, so there's never
// a cached account — return []. The client then goes through a fresh login /
// create_account instead of auto-logging into a stub account.
.get('/cachedlogin/forplatformid/:platform/:id', (c) => {
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
// TODO: query CachedLogins once a DB binding exists.
return c.json([
{
accountId: 1,
platform: '0',
platformId: '0',
lastLoginTime: '2026-06-10T00:00:00Z',
requirePassword: false,
},
])
// TODO: query CachedLogins once they're persisted.
return c.json([])
})
// Bulk cached-login lookup by platform id (friends resolution). The client
@@ -141,17 +135,24 @@ const app = new Hono<App>()
const platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
// grant_type=create_account mints + persists a brand-new account (with an
// auto-assigned random username — players don't choose one initially). The
// token's `sub` is the new account's id. Otherwise use the posted account_id,
// falling back to "1" (the cachedlogin stub hands the client account 1).
// auto-assigned random username — players don't choose one initially) and the
// token's `sub` is its id. Otherwise the request MUST post a valid account_id
// never fall back to a stub account (issuing account 1 to anyone would be bad).
let accountId: string
if (grantType === 'create_account') {
const account = await createAccount(c.env.DB, { Platforms: platformInt || 0 })
accountId = String(account.AccountId)
const account = await createAccount(c.env.DB, { platforms: platformInt || 0 })
accountId = String(account.accountId)
// Place the new player in Orientation (they don't matchmake into it).
await placeNewPlayerInOrientation(c.env, account.AccountId)
await placeNewPlayerInOrientation(c.env, account.accountId)
} else {
accountId = typeof body.account_id === 'string' && body.account_id ? body.account_id : '1'
const posted = typeof body.account_id === 'string' ? body.account_id.trim() : ''
if (!/^\d+$/.test(posted)) {
return c.json(
{ error: 'invalid_request', error_description: 'account_id is required' },
400
)
}
accountId = posted
}
const accessToken = await generateToken(accountId, platformId, platform)
+16 -17
View File
@@ -67,11 +67,10 @@ describe('auth worker routes', () => {
expect(await res.text()).toBe('"AA=="')
})
test('GET /cachedlogin/forplatformid/:platform/:id returns a cached login', async () => {
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/abc123`)
expect(res.status).toBe(200)
const logins = (await res.json()) as Array<{ accountId: number }>
expect(logins[0]).toMatchObject({ accountId: 1 })
expect(await res.json()).toEqual([])
})
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
@@ -107,19 +106,19 @@ describe('auth worker routes', () => {
expect(payload.scope).toContain('rn.api')
})
test('POST /connect/token falls back to account 1 when no account_id is posted', 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' })
expect(res.status).toBe(200)
const { access_token } = (await res.json()) as { access_token: string }
const payload = JSON.parse(
new TextDecoder().decode(
Uint8Array.from(
atob(access_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')),
(ch) => ch.charCodeAt(0)
)
)
) as { sub: string }
expect(payload.sub).toBe('1')
expect(res.status).toBe(400)
expect((await res.json()) as { error: string }).toMatchObject({ error: 'invalid_request' })
})
test('POST /connect/token 400s on a non-numeric account_id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'account_id=notanumber',
})
expect(res.status).toBe(400)
})
test('POST /connect/token grant_type=create_account persists a new account', async () => {
@@ -132,8 +131,8 @@ describe('auth worker routes', () => {
.bind(sub)
.first<{ data: string }>()
expect(row).not.toBeNull()
const account = JSON.parse(row!.data) as { Username: string }
expect(account.Username).not.toMatch(/^Player\d+$/)
const account = JSON.parse(row!.data) as { username: string }
expect(account.username).not.toMatch(/^Player\d+$/)
})
test('POST /connect/token create_account seeds the new player into Orientation', async () => {