mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
more routes
This commit is contained in:
@@ -15,24 +15,26 @@ 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
|
||||
/** Set via POST /account/me/email; absent until the player provides one. */
|
||||
email?: string
|
||||
}
|
||||
|
||||
interface AccountRow {
|
||||
@@ -68,15 +70,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,
|
||||
}
|
||||
}
|
||||
@@ -111,7 +113,7 @@ export async function updateAccount(
|
||||
overrides: Partial<Account>
|
||||
): Promise<Account> {
|
||||
const current = (await getAccount(db, id)) ?? defaultAccount(id)
|
||||
const updated: Account = { ...current, ...overrides, AccountId: id }
|
||||
const updated: Account = { ...current, ...overrides, accountId: id }
|
||||
const data = JSON.stringify(updated)
|
||||
const res = await db
|
||||
.prepare('UPDATE accounts SET data = ?2 WHERE account_id = ?1')
|
||||
@@ -136,8 +138,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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createAccount, defaultAccount, getAccount, getAccountsByIds, updateAcco
|
||||
import { validateAndGetAccountId } from './jwt'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from './accounts-db'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
@@ -47,6 +48,24 @@ async function formField(c: Context<App>, name: string): Promise<string> {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored account into the public account DTO — the client's camelCase
|
||||
* shape, excluding private fields like `email` (surfaced only by /account/me).
|
||||
*/
|
||||
function toAccountDto(account: Account) {
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
username: account.username,
|
||||
displayName: account.displayName,
|
||||
profileImage: account.profileImage,
|
||||
isJunior: account.isJunior,
|
||||
platforms: account.platforms,
|
||||
personalPronouns: account.personalPronouns,
|
||||
identityFlags: account.identityFlags,
|
||||
createdAt: account.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -70,16 +89,15 @@ const app = new Hono<App>()
|
||||
if (id === null) return unauthorized(c)
|
||||
// Load the stored account, falling back to a synthesized default.
|
||||
const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id)
|
||||
// `JuniorState` (an enum) and `ParentAccountId` are OMITTED when null —
|
||||
// `juniorState` (an enum) and `parentAccountId` are OMITTED when null —
|
||||
// emitting `"juniorState":null` makes the client's enum parser throw
|
||||
// ("Can't parse JSON to Enum format"). `Email`/`Phone`/`Birthday` are kept
|
||||
// as null (they aren't enums, so null is fine).
|
||||
// ("Can't parse JSON to Enum format"). `email`/`birthday` are kept as null
|
||||
// (they aren't enums, so null is fine).
|
||||
return c.json({
|
||||
...account,
|
||||
Email: null,
|
||||
Phone: null,
|
||||
Birthday: null,
|
||||
AvailableUsernameChanges: 1,
|
||||
...toAccountDto(account),
|
||||
email: account.email ?? null,
|
||||
birthday: null,
|
||||
availableUsernameChanges: 1,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,8 +113,8 @@ const app = new Hono<App>()
|
||||
.filter((n) => !Number.isNaN(n)) ?? []
|
||||
// Resolve stored accounts, synthesizing a default for any id not in the DB
|
||||
// so every requested id is present in the response.
|
||||
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.AccountId, a]))
|
||||
return c.json(ids.map((id) => stored.get(id) ?? defaultAccount(id)))
|
||||
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
|
||||
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
|
||||
})
|
||||
|
||||
.get('/account/:id/bio', (c) => {
|
||||
@@ -110,7 +128,7 @@ const app = new Hono<App>()
|
||||
const accountId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (Number.isNaN(accountId)) return c.body(null, 400)
|
||||
// Load the stored account, falling back to a synthesized default.
|
||||
return c.json((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId))
|
||||
return c.json(toAccountDto((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId)))
|
||||
})
|
||||
|
||||
// ---- Create --------------------------------------------------------------
|
||||
@@ -123,10 +141,10 @@ const app = new Hono<App>()
|
||||
// don't choose one initially).
|
||||
const platforms = Number.parseInt(platform, 10)
|
||||
const account = await createAccount(c.env.DB, {
|
||||
Platforms: Number.isNaN(platforms) ? 0 : platforms,
|
||||
platforms: Number.isNaN(platforms) ? 0 : platforms,
|
||||
})
|
||||
// TODO: also create a dorm Room/SubRoom for the new account.
|
||||
return c.json({ success: true, value: account })
|
||||
return c.json({ success: true, value: toAccountDto(account) })
|
||||
})
|
||||
|
||||
// ---- Parental control ----------------------------------------------------
|
||||
@@ -151,6 +169,26 @@ const app = new Hono<App>()
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// Set the player's email (persisted on the account row; surfaced by /account/me).
|
||||
.post('/account/me/email', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const email = (await formField(c, 'email')).trim()
|
||||
if (!email.includes('@')) return c.body(null, 400)
|
||||
await updateAccount(c.env.DB, id, { email })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// Set the player's identityFlags bitmask (persisted; surfaced by /account/me).
|
||||
.put('/account/me/identityflags', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const identityFlags = Number.parseInt((await formField(c, 'identityFlags')).trim(), 10)
|
||||
if (Number.isNaN(identityFlags)) return c.body(null, 400)
|
||||
await updateAccount(c.env.DB, id, { identityFlags })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
.put('/account/me/bio', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
@@ -165,7 +203,7 @@ const app = new Hono<App>()
|
||||
if (!imageName) return c.body(null, 400)
|
||||
// Persist the new avatar key on the account row (the C# also fires an
|
||||
// AccountUpdate websocket — no notify binding here, so it's omitted).
|
||||
await updateAccount(c.env.DB, id, { ProfileImage: imageName })
|
||||
await updateAccount(c.env.DB, id, { profileImage: imageName })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ beforeAll(async () => {
|
||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
const insert = env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
|
||||
await env.DB.batch([
|
||||
insert.bind(JSON.stringify({ AccountId: 0, Username: 'RecRoom', DisplayName: 'Rec Room' })),
|
||||
insert.bind(JSON.stringify({ AccountId: 1, Username: 'Coach', DisplayName: 'Coach' })),
|
||||
insert.bind(JSON.stringify({ accountId: 0, username: 'RecRoom', displayName: 'Rec Room' })),
|
||||
insert.bind(JSON.stringify({ accountId: 1, username: 'Coach', displayName: 'Coach' })),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -70,10 +70,10 @@ describe('public endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/123`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({
|
||||
AccountId: 123,
|
||||
Username: 'Player123',
|
||||
DisplayName: 'Player123',
|
||||
ProfileImage: 'DefaultProfileImage.jpg',
|
||||
accountId: 123,
|
||||
username: 'Player123',
|
||||
displayName: 'Player123',
|
||||
profileImage: 'DefaultProfileImage.jpg',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,12 +85,12 @@ describe('public endpoints', () => {
|
||||
test('GET /account/bulk resolves stored accounts and synthesizes the rest', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/bulk?id=1&id=2,3`)
|
||||
expect(res.status).toBe(200)
|
||||
const accounts = (await res.json()) as Array<{ AccountId: number; Username: string }>
|
||||
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
|
||||
// Every requested id is present and in order.
|
||||
expect(accounts.map((a) => a.AccountId)).toEqual([1, 2, 3])
|
||||
expect(accounts.map((a) => a.accountId)).toEqual([1, 2, 3])
|
||||
// id 1 is the seeded Coach account; 2 and 3 fall back to synthesized defaults.
|
||||
expect(accounts[0].Username).toBe('Coach')
|
||||
expect(accounts[1].Username).toBe('Player2')
|
||||
expect(accounts[0].username).toBe('Coach')
|
||||
expect(accounts[1].username).toBe('Player2')
|
||||
})
|
||||
|
||||
test('GET /account/:id/bio returns an empty bio', async () => {
|
||||
@@ -103,20 +103,20 @@ describe('public endpoints', () => {
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
success: boolean
|
||||
value: { AccountId: number; Username: string; DisplayName: string }
|
||||
value: { accountId: number; username: string; displayName: string }
|
||||
}
|
||||
expect(body.success).toBe(true)
|
||||
// Id is allocated above the seeded system accounts (0, 1).
|
||||
expect(body.value.AccountId).toBeGreaterThanOrEqual(2)
|
||||
expect(body.value.accountId).toBeGreaterThanOrEqual(2)
|
||||
// Username is auto-assigned (not the synthesized "Player<id>" fallback) and
|
||||
// the display name mirrors it.
|
||||
expect(body.value.Username).not.toMatch(/^Player\d+$/)
|
||||
expect(body.value.Username.length).toBeGreaterThan(0)
|
||||
expect(body.value.DisplayName).toBe(body.value.Username)
|
||||
expect(body.value.username).not.toMatch(/^Player\d+$/)
|
||||
expect(body.value.username.length).toBeGreaterThan(0)
|
||||
expect(body.value.displayName).toBe(body.value.username)
|
||||
// It's retrievable afterwards.
|
||||
const lookup = await exports.default.fetch(`${ORIGIN}/account/${body.value.AccountId}`)
|
||||
const found = (await lookup.json()) as { Username: string }
|
||||
expect(found.Username).toBe(body.value.Username)
|
||||
const lookup = await exports.default.fetch(`${ORIGIN}/account/${body.value.accountId}`)
|
||||
const found = (await lookup.json()) as { username: string }
|
||||
expect(found.username).toBe(body.value.username)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,15 +137,21 @@ describe('auth-gated endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer() })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
// Account JSON is camelCase (the client's AccountDTO), not the PascalCase we
|
||||
// store internally.
|
||||
expect(body).toMatchObject({
|
||||
AccountId: 42,
|
||||
Username: 'Player42',
|
||||
AvailableUsernameChanges: 1,
|
||||
accountId: 42,
|
||||
username: 'Player42',
|
||||
personalPronouns: 0,
|
||||
identityFlags: 0,
|
||||
availableUsernameChanges: 1,
|
||||
})
|
||||
// JuniorState + ParentAccountId must be omitted when null, not emitted as
|
||||
// null, or the client's enum parser throws on `juniorState`.
|
||||
expect('JuniorState' in body).toBe(false)
|
||||
expect('ParentAccountId' in body).toBe(false)
|
||||
// juniorState + parentAccountId must be omitted when null, not emitted as
|
||||
// null, or the client's enum parser throws on `juniorState`. `phone` isn't
|
||||
// part of the shape.
|
||||
expect('juniorState' in body).toBe(false)
|
||||
expect('parentAccountId' in body).toBe(false)
|
||||
expect('phone' in body).toBe(false)
|
||||
})
|
||||
|
||||
test('GET /parentalcontrol/me returns the flags', async () => {
|
||||
@@ -196,6 +202,64 @@ describe('auth-gated endpoints', () => {
|
||||
|
||||
// The stored value is returned by the self account (no hardcoded override).
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('777') })
|
||||
expect(((await me.json()) as { ProfileImage: string }).ProfileImage).toBe('deadbeef.jpg')
|
||||
expect(((await me.json()) as { profileImage: string }).profileImage).toBe('deadbeef.jpg')
|
||||
})
|
||||
|
||||
test('PUT /account/me/identityflags 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/identityflags`, {
|
||||
...form({ identityFlags: '384' }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('PUT /account/me/identityflags 400s on a non-numeric value', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/identityflags`, {
|
||||
...form({ identityFlags: 'nope' }),
|
||||
headers: { ...(await bearer('889')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('PUT /account/me/identityflags persists the flags, surfaced by /account/me', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/identityflags`, {
|
||||
...form({ identityFlags: '384' }),
|
||||
headers: { ...(await bearer('889')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('889') })
|
||||
expect(((await me.json()) as { identityFlags: number }).identityFlags).toBe(384)
|
||||
})
|
||||
|
||||
test('POST /account/me/email 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email: 'a@b.com' }),
|
||||
method: 'POST',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /account/me/email 400s on a malformed email', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email: 'notanemail' }),
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('888')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /account/me/email persists the email, surfaced by /account/me', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email: 'ners@recroom.com' }),
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('888')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// The stored email is now returned by the self account (was a null stub).
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('888') })
|
||||
expect(((await me.json()) as { email: string }).email).toBe('ners@recroom.com')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user