From bf88bcc48f4e5730aaaf0ec11d8696e489d896b7 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 30 Jun 2026 22:32:08 -0400 Subject: [PATCH] more routes --- apps/accounts/src/accounts-db.ts | 50 ++++---- apps/accounts/src/accounts.app.ts | 66 +++++++--- .../accounts/src/test/integration/api.test.ts | 116 ++++++++++++++---- apps/api/src/api.app.ts | 2 +- apps/api/src/test/integration/api.test.ts | 10 +- apps/auth/migrations/0001_accounts.sql | 8 +- apps/auth/src/accounts-db.ts | 46 +++---- apps/auth/src/auth.app.ts | 37 +++--- apps/auth/src/test/integration/api.test.ts | 33 +++-- apps/econ/src/avatar-db.ts | 4 +- apps/econ/src/test/integration/api.test.ts | 2 +- apps/rooms/src/rooms-db.ts | 35 +++++- apps/rooms/src/rooms.app.ts | 99 ++++++++++++++- apps/rooms/src/test/integration/api.test.ts | 91 ++++++++++++-- 14 files changed, 447 insertions(+), 152 deletions(-) diff --git a/apps/accounts/src/accounts-db.ts b/apps/accounts/src/accounts-db.ts index 523538d..822ef48 100644 --- a/apps/accounts/src/accounts-db.ts +++ b/apps/accounts/src/accounts-db.ts @@ -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 { 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 ): Promise { 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 } diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index d6e695a..6f9d92f 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -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, name: string): Promise { 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() .use( '*', @@ -70,16 +89,15 @@ const app = new Hono() 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() .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() 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() // 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() 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() 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 }) }) diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index 92c3259..e170fa4 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -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" 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 + // 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') }) }) diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index 8636da4..166e9bc 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -445,7 +445,7 @@ const app = new Hono({ 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() diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index aa19cc8..5463785 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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 () => { diff --git a/apps/auth/migrations/0001_accounts.sql b/apps/auth/migrations/0001_accounts.sql index 5079868..609be57 100644 --- a/apps/auth/migrations/0001_accounts.sql +++ b/apps/auth/migrations/0001_accounts.sql @@ -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"}'); diff --git a/apps/auth/src/accounts-db.ts b/apps/auth/src/accounts-db.ts index 9f6f050..a178390 100644 --- a/apps/auth/src/accounts-db.ts +++ b/apps/auth/src/accounts-db.ts @@ -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 { 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 } diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 41f6ed0..8dc9970 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -109,20 +109,14 @@ const app = new Hono() // 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() 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) diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 17775e0..699608d 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -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 () => { diff --git a/apps/econ/src/avatar-db.ts b/apps/econ/src/avatar-db.ts index 656f64f..63db290 100644 --- a/apps/econ/src/avatar-db.ts +++ b/apps/econ/src/avatar-db.ts @@ -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)`, ] diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 3bb8bf5..ce03d94 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -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() }) diff --git a/apps/rooms/src/rooms-db.ts b/apps/rooms/src/rooms-db.ts index cf94050..090936d 100644 --- a/apps/rooms/src/rooms-db.ts +++ b/apps/rooms/src/rooms-db.ts @@ -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 { + 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 { + 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 { +): 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() 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 } } /** diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index e789df0..405c0bc 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -17,6 +17,8 @@ import { getSimilarRooms, getVisitedRooms, searchRooms, + setRoomDescription, + setRoomName, toggleCheer, toggleFavorite, } from './rooms-db' @@ -92,6 +94,22 @@ function unauthorized(c: Context) { 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, + 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, value: unknown, error = '') { return c.json({ success: error === '', error, value }) @@ -273,8 +291,87 @@ const app = new Hono() 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 + 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 + 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 diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 18daad3..b03287a 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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, 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}`)