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
+26 -24
View File
@@ -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
}
+52 -14
View File
@@ -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 })
})
+90 -26
View File
@@ -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')
})
})
+1 -1
View File
@@ -445,7 +445,7 @@ const app = new Hono<App>({ strict: false })
// account row (a JSON blob in the shared accounts table) so it sticks.
if (savedImageType === SavedImageType.ProfileThumbnail) {
await c.env.DB.prepare(
"UPDATE accounts SET data = json_set(data, '$.ProfileImage', ?2) WHERE account_id = ?1"
"UPDATE accounts SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1"
)
.bind(id, name)
.run()
+5 -5
View File
@@ -44,12 +44,12 @@ beforeAll(async () => {
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS accounts (
data TEXT NOT NULL,
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
)`
).run()
await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
.bind(JSON.stringify({ AccountId: 42, Username: 'Tester', ProfileImage: 'DefaultProfileImage.jpg' }))
.bind(JSON.stringify({ accountId: 42, username: 'Tester', profileImage: 'DefaultProfileImage.jpg' }))
.run()
})
@@ -374,11 +374,11 @@ describe('images', () => {
const { ImageName } = (await res.json()) as { ImageName: string }
expect(ImageName).toMatch(/^[0-9a-f]+\.jpg$/)
// The account row now points its ProfileImage at the uploaded key.
// The account row now points its profileImage at the uploaded key.
const row = await env.DB.prepare('SELECT data FROM accounts WHERE account_id = 42').first<{
data: string
}>()
expect(JSON.parse(row!.data).ProfileImage).toBe(ImageName)
expect(JSON.parse(row!.data).profileImage).toBe(ImageName)
})
test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => {
+4 -4
View File
@@ -3,12 +3,12 @@
CREATE TABLE IF NOT EXISTS accounts (
data TEXT NOT NULL,
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);
-- Seed the system account (uid 0) and Coach (uid 1).
INSERT OR IGNORE INTO accounts (data) VALUES ('{"AccountId":0,"Username":"RecRoom","DisplayName":"Rec Room","ProfileImage":"DefaultProfileImage.jpg","IsJunior":false,"Platforms":0,"PersonalPronouns":0,"IdentityFlags":0,"CreatedAt":"2016-01-01T00:00:00Z"}');
INSERT OR IGNORE INTO accounts (data) VALUES ('{"AccountId":1,"Username":"Coach","DisplayName":"Coach","ProfileImage":"DefaultProfileImage.jpg","IsJunior":false,"Platforms":0,"PersonalPronouns":0,"IdentityFlags":0,"CreatedAt":"2016-01-01T00:00:00Z"}');
INSERT OR IGNORE INTO accounts (data) VALUES ('{"accountId":0,"username":"RecRoom","displayName":"Rec Room","profileImage":"DefaultProfileImage.jpg","isJunior":false,"platforms":0,"personalPronouns":0,"identityFlags":0,"createdAt":"2016-01-01T00:00:00Z"}');
INSERT OR IGNORE INTO accounts (data) VALUES ('{"accountId":1,"username":"Coach","displayName":"Coach","profileImage":"DefaultProfileImage.jpg","isJunior":false,"platforms":0,"personalPronouns":0,"identityFlags":0,"createdAt":"2016-01-01T00:00:00Z"}');
+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 () => {
+2 -2
View File
@@ -14,8 +14,8 @@ 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)`,
]
+1 -1
View File
@@ -19,7 +19,7 @@ const ORIGIN = 'https://example.com'
beforeAll(async () => {
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
.bind(JSON.stringify({ AccountId: 42, Username: 'Tester', DisplayName: 'Tester' }))
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.run()
})
+29 -6
View File
@@ -78,6 +78,26 @@ export async function cloneRoom(
return cloned
}
/** Set a room's Description in place (the caller is responsible for the owner check). */
export async function setRoomDescription(
db: D1Database,
roomId: number,
description: string
): Promise<void> {
await db
.prepare("UPDATE rooms SET data = json_set(data, '$.Description', ?2) WHERE room_id = ?1")
.bind(roomId, description)
.run()
}
/** Set a room's Name in place (the caller checks ownership + name uniqueness first). */
export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<void> {
await db
.prepare("UPDATE rooms SET data = json_set(data, '$.Name', ?2) WHERE room_id = ?1")
.bind(roomId, name)
.run()
}
interface RoomRow {
data: string
}
@@ -339,19 +359,21 @@ export async function getHotRooms(
/**
* Rooms similar to a target room: public, non-dorm rooms (excluding the target)
* that share at least one tag with it, ranked by shared-tag count then
* engagement. Returns a bare array; empty if the target isn't in D1 or is
* untagged. Paginated via skip/take. Small dataset, so done in memory.
* engagement. Returns a paginated `{ Results, TotalResults }` (the client's
* RoomSimilarity source expects an object, not a bare array); empty if the target
* isn't in D1 or is untagged. Small dataset, so done in memory.
*/
export async function getSimilarRooms(
db: D1Database,
roomId: number,
skip: number,
take: number
): Promise<Room[]> {
): Promise<{ Results: Room[]; TotalResults: number }> {
const empty = { Results: [] as Room[], TotalResults: 0 }
const target = await getRoomById(db, roomId)
if (!target) return []
if (!target) return empty
const targetTags = new Set(roomTags(target))
if (targetTags.size === 0) return []
if (targetTags.size === 0) return empty
const { results } = await db.prepare('SELECT data FROM rooms').all<RoomRow>()
const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
@@ -374,7 +396,8 @@ export async function getSimilarRooms(
hotScore(b.room) - hotScore(a.room) ||
roomIdOf(a.room) - roomIdOf(b.room)
)
return scored.slice(skip, skip + take).map((x) => x.room)
const rooms = scored.map((x) => x.room)
return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length }
}
/**
+98 -1
View File
@@ -17,6 +17,8 @@ import {
getSimilarRooms,
getVisitedRooms,
searchRooms,
setRoomDescription,
setRoomName,
toggleCheer,
toggleFavorite,
} from './rooms-db'
@@ -92,6 +94,22 @@ function unauthorized(c: Context<App>) {
return c.json({ error: 'Unauthorized' }, 401)
}
/**
* Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always
* HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success.
*/
function roomResult(
c: Context<App>,
fields: { Success: boolean; Value?: unknown; ErrorId?: string; Error?: string }
) {
return c.json({
Success: fields.Success,
Value: fields.Value ?? null,
ErrorId: fields.ErrorId ?? null,
Error: fields.Error ?? null,
})
}
/** Client envelope for room clone results: `{ success, error, value }`. */
function cloneResult(c: Context<App>, value: unknown, error = '') {
return c.json({ success: error === '', error, value })
@@ -273,8 +291,87 @@ const app = new Hono<App>()
return cloneResult(c, room)
})
// Update a room's description. Auth-gated (401) and owner-only. Business results
// use the `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
.put('/rooms/:roomId{[0-9]+}/description', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const description = typeof body.description === 'string' ? body.description : ''
await setRoomDescription(c.env.DB, roomId, description)
return roomResult(c, { Success: true })
})
// Rename a room. Auth-gated (401) and owner-only; the new name must be non-empty
// and not already taken by another room. Business results use the
// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
// NOTE: the ErrorId strings (besides Rooms.DoesntExist) are best guesses.
.put('/rooms/:roomId{[0-9]+}/name', async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.DoesntExist',
Error: 'This room does not exist!',
})
}
if (room.CreatorAccountId !== accountId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
Error: 'You are not the owner of this room!',
})
}
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const name = typeof body.name === 'string' ? body.name.trim() : ''
if (name === '') {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.InvalidName',
Error: 'You must enter a name for your room!',
})
}
// Reject if a different room already uses this name (case-insensitive).
const existing = await getRoomByName(c.env.DB, name)
if (existing && existing.RoomId !== roomId) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.AlreadyExists',
Error: 'A room with that name already exists!',
})
}
await setRoomName(c.env.DB, roomId, name)
return roomResult(c, { Success: true })
})
// Rooms similar to the given room (sharing tags). Paginated via skip/take (take
// defaults to 100). Returns a bare array; empty when the room is unknown/untagged.
// defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is
// unknown/untagged.
.get('/rooms/:roomId{[0-9]+}/similar', async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
+81 -10
View File
@@ -289,28 +289,32 @@ describe('rooms endpoints', () => {
expect(body.length).toBeLessThanOrEqual(5)
})
it('GET /rooms/:id/similar returns a bare array of tag-sharing rooms (excluding self)', async () => {
it('GET /rooms/:id/similar returns { Results, TotalResults } of tag-sharing rooms (excluding self)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/2/similar`)
expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ RoomId: number; Tags?: Array<{ Tag: string }> }>
expect(Array.isArray(body)).toBe(true)
expect(body.length).toBeGreaterThan(0)
const body = (await res.json()) as {
Results: Array<{ RoomId: number; Tags?: Array<{ Tag: string }> }>
TotalResults: number
}
expect(body.Results.length).toBeGreaterThan(0)
expect(body.TotalResults).toBeGreaterThanOrEqual(body.Results.length)
// Never includes the target room itself.
expect(body.some((r) => r.RoomId === 2)).toBe(false)
expect(body.Results.some((r) => r.RoomId === 2)).toBe(false)
// Every result shares the `rro` tag RecCenter (room 2) carries.
expect(body.every((r) => (r.Tags ?? []).some((t) => t.Tag === 'rro'))).toBe(true)
expect(body.Results.every((r) => (r.Tags ?? []).some((t) => t.Tag === 'rro'))).toBe(true)
})
it('GET /rooms/:id/similar respects take pagination', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/2/similar?skip=0&take=3`)
const body = (await res.json()) as unknown[]
expect(body.length).toBeLessThanOrEqual(3)
const body = (await res.json()) as { Results: unknown[]; TotalResults: number }
expect(body.Results.length).toBeLessThanOrEqual(3)
expect(body.TotalResults).toBeGreaterThan(body.Results.length)
})
it('GET /rooms/:id/similar returns [] for a room not in D1', async () => {
it('GET /rooms/:id/similar returns an empty result for a room not in D1', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/99999/similar`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
})
it('POST /rooms/:id/clone clones a base room into a new owned room', async () => {
@@ -401,6 +405,73 @@ describe('rooms endpoints', () => {
expect(missing).toMatchObject({ success: false, value: null })
})
const putForm = async (path: string, fields: Record<string, string>, sub?: string) =>
SELF.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: {
...(sub ? await bearer(sub) : {}),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
// Room-mutation envelope helper.
type RoomResult = { Success: boolean; Value: unknown; ErrorId: string | null; Error: string | null }
const bodyOf = async (res: Response) => (await res.json()) as RoomResult
it('PUT /rooms/:id/description is auth-gated, owner-only, and persists', async () => {
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/description', { description: 'x' })).status).toBe(401)
// Not the owner (RecCenter is owned by account 1) → 200 envelope, Success:false.
expect(await bodyOf(await putForm('/rooms/2/description', { description: 'x' }, '999'))).toMatchObject(
{ Success: false, ErrorId: 'Rooms.NotOwner' }
)
// Unknown room → Rooms.DoesntExist envelope.
expect(await bodyOf(await putForm('/rooms/99999/description', { description: 'x' }, '1'))).toMatchObject(
{ Success: false, ErrorId: 'Rooms.DoesntExist', Error: 'This room does not exist!' }
)
// Owner updates it, and it persists.
const ok = await putForm('/rooms/2/description', { description: 'blah blah blah' }, '1')
expect(ok.status).toBe(200)
expect(await bodyOf(ok)).toMatchObject({ Success: true, Value: null, ErrorId: null, Error: null })
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Description: string }
expect(room.Description).toBe('blah blah blah')
})
it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => {
// No token → 401 (auth gate).
expect((await putForm('/rooms/2/name', { name: 'Whatever' })).status).toBe(401)
// Wrong owner / unknown room → Success:false envelopes.
expect(await bodyOf(await putForm('/rooms/2/name', { name: 'Whatever' }, '999'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.NotOwner',
})
expect(await bodyOf(await putForm('/rooms/99999/name', { name: 'Whatever' }, '1'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.DoesntExist',
})
// Empty name → Success:false.
expect(await bodyOf(await putForm('/rooms/2/name', { name: ' ' }, '1'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.InvalidName',
})
// A name already used by a different room (GoldenTrophy is room 12).
expect(await bodyOf(await putForm('/rooms/2/name', { name: 'GoldenTrophy' }, '1'))).toMatchObject({
Success: false,
ErrorId: 'Rooms.AlreadyExists',
Error: 'A room with that name already exists!',
})
// Owner renames to a free name, and it persists (findable by the new name).
const ok = await putForm('/rooms/2/name', { name: 'RenamedCenter' }, '1')
expect(await bodyOf(ok)).toMatchObject({ Success: true })
const room = (await (await SELF.fetch(`${ORIGIN}/rooms?name=RenamedCenter`)).json()) as {
RoomId: number
}
expect(room.RoomId).toBe(2)
})
it('GET /photon_access_token (bare + /roomserver) returns permissions', async () => {
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
const res = await SELF.fetch(`${ORIGIN}${path}`)