From 335b4d68ce438ef579dd840f880c8267750e92c8 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 15 Jun 2026 22:39:24 -0400 Subject: [PATCH] more endpoints, fix orientation on new accounts --- apps/accounts/src/accounts-db.ts | 118 ++++++++++++++++++ apps/accounts/src/accounts.app.ts | 82 +++++------- apps/accounts/src/context.ts | 4 +- .../accounts/src/test/integration/api.test.ts | 50 ++++++-- apps/accounts/wrangler.jsonc | 9 ++ apps/api/src/api.app.ts | 15 ++- apps/api/src/context.ts | 3 + apps/api/src/test/integration/api.test.ts | 30 +++++ apps/api/wrangler.jsonc | 8 ++ apps/auth/migrations/0001_accounts.sql | 14 +++ apps/auth/src/accounts-db.ts | 118 ++++++++++++++++++ apps/auth/src/auth.app.ts | 86 +++++++++++-- apps/auth/src/context.ts | 8 +- apps/auth/src/test/integration/api.test.ts | 64 +++++++++- apps/auth/wrangler.jsonc | 20 +++ apps/datacollection/src/datacollection.app.ts | 12 +- 16 files changed, 554 insertions(+), 87 deletions(-) create mode 100644 apps/accounts/src/accounts-db.ts create mode 100644 apps/auth/migrations/0001_accounts.sql create mode 100644 apps/auth/src/accounts-db.ts diff --git a/apps/accounts/src/accounts-db.ts b/apps/accounts/src/accounts-db.ts new file mode 100644 index 0000000..9cf71ec --- /dev/null +++ b/apps/accounts/src/accounts-db.ts @@ -0,0 +1,118 @@ +/** + * Account storage on the shared `rec-rooms` D1 database. Each account is a single + * JSON blob in the `data` column; queryable fields (AccountId, Username) are + * SQLite generated (virtual) columns extracted from that JSON and indexed — + * the same JSON-blob pattern the `rooms` worker uses. + * + * The `auth` worker owns this schema/migration (see migrations/0001_accounts.sql, + * applied with its own `migrations_table` so it doesn't clash with the rooms + * migrations that share the database). Other workers bind the table read/write + * and keep these helpers in sync. + */ + +/** Schema DDL (mirror of migrations/0001_accounts.sql, sans the seed INSERTs). */ +export const SCHEMA_DDL: string[] = [ + `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 + )`, + `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, matches the C# `Account`). */ +export interface Account { + AccountId: number + Username: string + DisplayName: string + ProfileImage: string + IsJunior: boolean + Platforms: number + PersonalPronouns: number + IdentityFlags: number + CreatedAt: string +} + +interface AccountRow { + data: string +} + +const parseOne = (row: AccountRow | null): Account | null => + row ? (JSON.parse(row.data) as Account) : null +const parseAll = (rows: AccountRow[]): Account[] => rows.map((r) => JSON.parse(r.data) as Account) + +/** Word lists for auto-assigned usernames (players don't pick one on signup). */ +const ADJECTIVES = [ + 'Swift', 'Brave', 'Clever', 'Happy', 'Mighty', 'Lucky', 'Sunny', 'Cosmic', + 'Witty', 'Nimble', 'Jolly', 'Bold', 'Gentle', 'Fuzzy', 'Speedy', 'Shiny', +] +const NOUNS = [ + 'Fox', 'Otter', 'Falcon', 'Panda', 'Tiger', 'Comet', 'Maple', 'Pixel', + 'Robin', 'Wolf', 'Koala', 'Dragon', 'Penguin', 'Badger', 'Heron', 'Lynx', +] + +/** A random, readable username (e.g. "SwiftFox4821"). */ +export function randomUsername(): string { + const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)] + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)] + const n = Math.floor(Math.random() * 10000) + return `${adj}${noun}${n}` +} + +/** + * Build a full account object from an id, applying the C# fallbacks for any + * column the caller doesn't override. Used both to synthesize accounts that + * aren't in the DB and as the base for a freshly created account. + */ +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(), + ...overrides, + } +} + +/** Look up a single account by AccountId. */ +export async function getAccount(db: D1Database, id: number): Promise { + return parseOne( + await db.prepare('SELECT data FROM accounts WHERE account_id = ?1').bind(id).first() + ) +} + +/** Look up multiple accounts by AccountId (order not guaranteed). */ +export async function getAccountsByIds(db: D1Database, ids: number[]): Promise { + if (ids.length === 0) return [] + const placeholders = ids.map((_, i) => `?${i + 1}`).join(',') + const { results } = await db + .prepare(`SELECT data FROM accounts WHERE account_id IN (${placeholders})`) + .bind(...ids) + .all() + return parseAll(results) +} + +/** + * Create and persist a new account. The id is the next free integer (above the + * seeded system accounts); the username is auto-assigned (players don't choose + * one initially) and the display name defaults to it. + */ +export async function createAccount( + db: D1Database, + overrides: Partial = {} +): Promise { + const row = await db + .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 }) + 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 92fd085..3c884d8 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -3,33 +3,21 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' +import { createAccount, defaultAccount, getAccount, getAccountsByIds } from './accounts-db' import { validateAndGetAccountId } from './jwt' import type { Context } from 'hono' import type { App } from './context' /** - * Ported from the C# `AccountsController`. Endpoints that the C# backs with EF - * Core (`AppDbContext`) are stubbed here — there's no DB binding yet, so reads - * return synthesized defaults (the C# fills every field with a fallback anyway) - * and writes accept-and-ack without persisting. Each stub is marked `TODO`. + * Ported from the C# `AccountsController`. Account reads/writes are backed by the + * shared `accounts` table in D1 (schema owned by the `auth` worker). Accounts not + * in the table fall back to a synthesized default (the C# fills every column with + * a fallback anyway). Profile mutations still accept-and-ack (marked `TODO`). * * Auth-gated routes still validate the Bearer JWT issued by the `auth` worker. */ -/** Account shape returned by the public lookup endpoints. */ -interface Account { - AccountId: number - ProfileImage: string - IsJunior: boolean - Platforms: number - PersonalPronouns: number - IdentityFlags: number - Username: string - DisplayName: string - CreatedAt: string -} - /** * Resolve the account id from a Bearer token, mirroring the repeated * auth-header check in the C#. Returns `null` when the header is missing, @@ -37,7 +25,6 @@ interface Account { */ async function authedId(c: Context): Promise { const authHeader = c.req.header('Authorization') ?? '' - console.log(authHeader) if (!authHeader.toLowerCase().startsWith('bearer ')) return null const token = authHeader.slice('Bearer '.length) @@ -60,24 +47,6 @@ async function formField(c: Context, name: string): Promise { return typeof value === 'string' ? value : '' } -/** - * Synthesize an `Account` from an id using the same fallbacks the C# applies - * when a column is null. Stands in for `db.Accounts.FindAsync(id)`. - */ -function defaultAccount(id: number): Account { - return { - AccountId: id, - ProfileImage: 'DefaultProfileImage.jpg', - IsJunior: false, - Platforms: 0, - PersonalPronouns: 0, - IdentityFlags: 0, - Username: `Player${id}`, - DisplayName: `Player${id}`, - CreatedAt: new Date().toISOString(), - } -} - const app = new Hono() .use( '*', @@ -99,8 +68,8 @@ const app = new Hono() .get('/account/me', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - // TODO: load the real account; the C# 404s when the row is missing. - const account = defaultAccount(id) + // Load the stored account, falling back to a synthesized default. + const account = (await getAccount(c.env.DB, id)) ?? defaultAccount(id) // The C# `SelfAccount` marks `JuniorState` (an enum) and `ParentAccountId` // with `[JsonIgnore(WhenWritingNull)]`, so they're OMITTED when null — // emitting `"juniorState":null` makes the client's enum parser throw @@ -118,15 +87,18 @@ const app = new Hono() // ---- Bulk / single lookup ------------------------------------------------ // Register the static `bulk` path before the `/account/:id` param route. - .get('/account/bulk', (c) => { + .get('/account/bulk', async (c) => { // C# reads repeated `id` query params; also accept a comma-separated list. - const ids = c.req - .queries('id') - ?.flatMap((v) => v.split(',')) - .map((s) => Number.parseInt(s.trim(), 10)) - .filter((n) => !Number.isNaN(n)) - // TODO: query Accounts for these ids instead of synthesizing. - return c.json((ids ?? []).map(defaultAccount)) + const ids = + c.req + .queries('id') + ?.flatMap((v) => v.split(',')) + .map((s) => Number.parseInt(s.trim(), 10)) + .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 (matches the C#). + 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))) }) .get('/account/:id/bio', (c) => { @@ -136,22 +108,26 @@ const app = new Hono() return c.json({ accountId, bio: '' }) }) - .get('/account/:id', (c) => { + .get('/account/:id', async (c) => { const accountId = Number.parseInt(c.req.param('id'), 10) if (Number.isNaN(accountId)) return c.body(null, 400) - // TODO: load the real account; the C# 404s when the row is missing. - return c.json(defaultAccount(accountId)) + // Load the stored account, falling back to a synthesized default. + return c.json((await getAccount(c.env.DB, accountId)) ?? defaultAccount(accountId)) }) // ---- Create -------------------------------------------------------------- .post('/account/create', async (c) => { // Parsed for fidelity; unused until there's a DB to persist CachedLogins. - await formField(c, 'platform') + const platform = await formField(c, 'platform') await formField(c, 'platformId') - const accountId = Math.floor(Math.random() * (99999 - 10000 + 1)) + 10000 - const account = defaultAccount(accountId) - // TODO: persist the account + a dorm Room/SubRoom once a DB binding exists. + // Persist a new account with an auto-assigned random username (players + // don't choose one initially). + const platforms = Number.parseInt(platform, 10) + const account = await createAccount(c.env.DB, { + Platforms: Number.isNaN(platforms) ? 0 : platforms, + }) + // TODO: also create a dorm Room/SubRoom for the new account. return c.json({ success: true, value: account }) }) diff --git a/apps/accounts/src/context.ts b/apps/accounts/src/context.ts index 329ac40..b5f78d8 100644 --- a/apps/accounts/src/context.ts +++ b/apps/accounts/src/context.ts @@ -2,7 +2,9 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' export type Env = SharedHonoEnv & { - // add additional Bindings here + // Shared rooms/accounts D1 database (schema owned by the `auth` worker). Used + // to look up accounts in bulk/by id and to create new accounts. + DB: D1Database } /** Variables can be extended */ diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index 30c3f8a..9e62f69 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -1,10 +1,30 @@ +import { env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' -import { describe, expect, test } from 'vitest' +import { beforeAll, describe, expect, test } from 'vitest' import '../../accounts.app' +import { SCHEMA_DDL } from '../../accounts-db' + +import type { Env } from '../../context' + +declare module 'cloudflare:test' { + interface ProvidedEnv extends Env {} +} + const ORIGIN = 'https://accounts.rec.djdevin.net' +// Apply the accounts schema + seed the system (uid 0) and Coach (uid 1) accounts +// into the test D1 (mirrors apps/auth/migrations/0001_accounts.sql). +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' })), + ]) +}) + // Mint a token the way the `auth` worker does, using the same dev secret, so the // accounts worker's validation accepts it. Kept inline to avoid a cross-package // import. @@ -62,11 +82,15 @@ describe('public endpoints', () => { expect(res.status).toBe(400) }) - test('GET /account/bulk returns one account per id', async () => { + 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 }> + 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]) + // 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') }) test('GET /account/:id/bio returns an empty bio', async () => { @@ -74,13 +98,25 @@ describe('public endpoints', () => { expect(await res.json()).toEqual({ accountId: 7, bio: '' }) }) - test('POST /account/create returns a wrapped account', async () => { + test('POST /account/create persists a new account with a random username', async () => { const res = await exports.default.fetch(`${ORIGIN}/account/create`, { method: 'POST' }) expect(res.status).toBe(200) - const body = (await res.json()) as { success: boolean; value: { AccountId: number } } + const body = (await res.json()) as { + success: boolean + value: { AccountId: number; Username: string; DisplayName: string } + } expect(body.success).toBe(true) - expect(body.value.AccountId).toBeGreaterThanOrEqual(10000) - expect(body.value.AccountId).toBeLessThanOrEqual(99999) + // Id is allocated above the seeded system accounts (0, 1). + 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) + // 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) }) }) diff --git a/apps/accounts/wrangler.jsonc b/apps/accounts/wrangler.jsonc index 9e8096f..51ca3f8 100644 --- a/apps/accounts/wrangler.jsonc +++ b/apps/accounts/wrangler.jsonc @@ -10,6 +10,15 @@ "custom_domain": true } ], + // Shared D1 database. The `accounts` table schema/migrations are owned by the + // `auth` worker; this worker binds it read/write to look up and create accounts. + "d1_databases": [ + { + "binding": "DB", + "database_name": "rec-rooms", + "database_id": "d44083e1-5bfe-4467-aa9a-f13c5c2496d5" + } + ], "logpush": false, "upload_source_maps": true, "observability": { diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index 064f996..36f9c8b 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -431,16 +431,23 @@ const app = new Hono({ strict: false }) .get('/api/images/v2/named', (c) => c.json([])) // TODO: hydrate from JSON/namedimages.json .post('/api/images/v4/uploadsaved', async (c) => { const body = await c.req.parseBody().catch(() => ({}) as Record) - const file = body.file - if (!(file instanceof File)) return c.json({ error: 'No file found in request' }, 400) + // The client posts the file as `image`; accept `file` too for safety. + const candidate = body.image ?? body.file + if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400) + const file = candidate const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'] const dot = file.name.lastIndexOf('.') const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : '' const extension = valid.includes(ext) ? ext : '.png' - // TODO: persist the upload (R2?). For now just mint a name. - return c.json({ ImageName: crypto.randomUUID().replace(/-/g, '') + extension }) + // Store the upload in the shared image bucket under a random key. The `img` + // worker serves it back by that key, which is the returned ImageName. + const name = crypto.randomUUID().replace(/-/g, '') + extension + await c.env.IMAGES.put(name, await file.arrayBuffer(), { + httpMetadata: { contentType: file.type || 'image/png' }, + }) + return c.json({ ImageName: name }) }) // ---- Rooms ---------------------------------------------------------------- diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts index a9d4fc1..c2a2bc0 100644 --- a/apps/api/src/context.ts +++ b/apps/api/src/context.ts @@ -5,6 +5,9 @@ export type Env = SharedHonoEnv & { // Shared rooms database (schema/migrations owned by the `rooms` worker). Used // read-only here for the /roomserver/rooms/* endpoints. DB: D1Database + // Image bucket (shared with the `img` worker, which serves objects back by + // key). Uploaded saved images are written here. + IMAGES: R2Bucket } /** Variables can be extended */ diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 8ba07f1..7a9060d 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -337,3 +337,33 @@ describe('room server', () => { expect(await res.json()).toEqual({ Cheered: false, Favorited: false }) }) }) + +describe('images', () => { + test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]) + const fd = new FormData() + fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' })) + + const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, { + method: 'POST', + body: fd, + }) + expect(res.status).toBe(200) + const { ImageName } = (await res.json()) as { ImageName: string } + expect(ImageName).toMatch(/^[0-9a-f]+\.png$/) + + // The object is in the shared bucket under that key. + const stored = await env.IMAGES.get(ImageName) + expect(stored).not.toBeNull() + expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes) + }) + + test('POST /api/images/v4/uploadsaved 400s without a file', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'foo=bar', + }) + expect(res.status).toBe(400) + }) +}) diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index f66ce29..f749572 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -18,6 +18,14 @@ "database_id": "d44083e1-5bfe-4467-aa9a-f13c5c2496d5" } ], + // Image bucket shared with the `img` worker (which serves objects back by key). + // Saved-image uploads are written here. + "r2_buckets": [ + { + "binding": "IMAGES", + "bucket_name": "rec-img" + } + ], "logpush": false, "upload_source_maps": true, "observability": { diff --git a/apps/auth/migrations/0001_accounts.sql b/apps/auth/migrations/0001_accounts.sql new file mode 100644 index 0000000..5079868 --- /dev/null +++ b/apps/auth/migrations/0001_accounts.sql @@ -0,0 +1,14 @@ +-- Accounts stored as a JSON blob with generated (virtual) columns for querying. +-- Generated from src/accounts-db.ts (SCHEMA_DDL) — keep in sync. + +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 + ); +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"}'); diff --git a/apps/auth/src/accounts-db.ts b/apps/auth/src/accounts-db.ts new file mode 100644 index 0000000..9cf71ec --- /dev/null +++ b/apps/auth/src/accounts-db.ts @@ -0,0 +1,118 @@ +/** + * Account storage on the shared `rec-rooms` D1 database. Each account is a single + * JSON blob in the `data` column; queryable fields (AccountId, Username) are + * SQLite generated (virtual) columns extracted from that JSON and indexed — + * the same JSON-blob pattern the `rooms` worker uses. + * + * The `auth` worker owns this schema/migration (see migrations/0001_accounts.sql, + * applied with its own `migrations_table` so it doesn't clash with the rooms + * migrations that share the database). Other workers bind the table read/write + * and keep these helpers in sync. + */ + +/** Schema DDL (mirror of migrations/0001_accounts.sql, sans the seed INSERTs). */ +export const SCHEMA_DDL: string[] = [ + `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 + )`, + `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, matches the C# `Account`). */ +export interface Account { + AccountId: number + Username: string + DisplayName: string + ProfileImage: string + IsJunior: boolean + Platforms: number + PersonalPronouns: number + IdentityFlags: number + CreatedAt: string +} + +interface AccountRow { + data: string +} + +const parseOne = (row: AccountRow | null): Account | null => + row ? (JSON.parse(row.data) as Account) : null +const parseAll = (rows: AccountRow[]): Account[] => rows.map((r) => JSON.parse(r.data) as Account) + +/** Word lists for auto-assigned usernames (players don't pick one on signup). */ +const ADJECTIVES = [ + 'Swift', 'Brave', 'Clever', 'Happy', 'Mighty', 'Lucky', 'Sunny', 'Cosmic', + 'Witty', 'Nimble', 'Jolly', 'Bold', 'Gentle', 'Fuzzy', 'Speedy', 'Shiny', +] +const NOUNS = [ + 'Fox', 'Otter', 'Falcon', 'Panda', 'Tiger', 'Comet', 'Maple', 'Pixel', + 'Robin', 'Wolf', 'Koala', 'Dragon', 'Penguin', 'Badger', 'Heron', 'Lynx', +] + +/** A random, readable username (e.g. "SwiftFox4821"). */ +export function randomUsername(): string { + const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)] + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)] + const n = Math.floor(Math.random() * 10000) + return `${adj}${noun}${n}` +} + +/** + * Build a full account object from an id, applying the C# fallbacks for any + * column the caller doesn't override. Used both to synthesize accounts that + * aren't in the DB and as the base for a freshly created account. + */ +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(), + ...overrides, + } +} + +/** Look up a single account by AccountId. */ +export async function getAccount(db: D1Database, id: number): Promise { + return parseOne( + await db.prepare('SELECT data FROM accounts WHERE account_id = ?1').bind(id).first() + ) +} + +/** Look up multiple accounts by AccountId (order not guaranteed). */ +export async function getAccountsByIds(db: D1Database, ids: number[]): Promise { + if (ids.length === 0) return [] + const placeholders = ids.map((_, i) => `?${i + 1}`).join(',') + const { results } = await db + .prepare(`SELECT data FROM accounts WHERE account_id IN (${placeholders})`) + .bind(...ids) + .all() + return parseAll(results) +} + +/** + * Create and persist a new account. The id is the next free integer (above the + * seeded system accounts); the username is auto-assigned (players don't choose + * one initially) and the display name defaults to it. + */ +export async function createAccount( + db: D1Database, + overrides: Partial = {} +): Promise { + const row = await db + .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 }) + 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 b08418f..0e41ff2 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -3,6 +3,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { logger, withNotFound, withOnError } from '@repo/hono-helpers' +import { createAccount } from './accounts-db' import { generateToken, TOKEN_TTL_SECONDS } from './jwt' import type { App } from './context' @@ -25,6 +26,66 @@ const PLATFORM_TYPES: Record = { 8: 'Pico', } +/** New players start in the Orientation room (RoomId 13) — the new-user flow. */ +const ORIENTATION_ROOM_ID = 13 +/** Presence TTL (s) — matches the match worker; refreshed by each heartbeat. */ +const PRESENCE_TTL = 900 + +/** + * Seed a freshly created account's match presence to the Orientation room. The + * client is placed into Orientation by its new-user flow without a matchmake + * call, so the match heartbeat would otherwise report no/stale (dorm) presence + * and bounce the player out. We write the Orientation instance (built from the + * shared rooms D1, matching the match worker's `roomInstanceFromRoom` shape) so + * the heartbeat keeps them there. + */ +async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: number): Promise { + const row = await env.DB.prepare('SELECT data FROM rooms WHERE room_id = ?1') + .bind(ORIENTATION_ROOM_ID) + .first<{ data: string }>() + if (!row) return + + const room = JSON.parse(row.data) as Record + const subRooms = room.SubRooms + const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as + | Record + | undefined + const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback) + const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback) + + const roomInstance = { + roomInstanceId: ORIENTATION_ROOM_ID, + roomId: ORIENTATION_ROOM_ID, + subRoomId: num(sub?.SubRoomId, 1), + roomInstanceType: 0, + location: str(sub?.UnitySceneId), + dataBlob: str(sub?.DataBlob), + eventId: 0, + clubId: 0, + roomCode: '', + photonRegion: 'us', + photonRegionId: 'us', + photonRoomId: `rec.${ORIENTATION_ROOM_ID}`, + name: `^${str(room.Name, 'Orientation')}`, + maxCapacity: num(sub?.MaxPlayers, 4), + isFull: false, + isPrivate: false, + isInProgress: false, + EncryptVoiceChat: false, + } + const presence = { + roomInstance, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 1, + platform: 0, + appVersion: '20230302', + } + await env.MATCH_PRESENCE.put(`presence:${accountId}`, JSON.stringify(presence), { + expirationTtl: PRESENCE_TTL, + }) +} + const app = new Hono() .use( '*', @@ -73,21 +134,24 @@ const app = new Hono() const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN const platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '') - // grant_type=create_account mints a brand-new account (the C# persists it - // plus a dorm; with no DB we just allocate a random id — the accounts worker - // synthesizes the account on demand). Otherwise use the posted account_id, + // 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). - const accountId = - grantType === 'create_account' - ? String(Math.floor(Math.random() * (99999 - 10000 + 1)) + 10000) - : typeof body.account_id === 'string' && body.account_id - ? body.account_id - : '1' + let accountId: string + if (grantType === 'create_account') { + 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) + } else { + accountId = typeof body.account_id === 'string' && body.account_id ? body.account_id : '1' + } const accessToken = await generateToken(accountId, platformId, platform) - // TODO: once a DB binding exists, create the account + dorm on create_account - // and remove any RoomInstance owned by accountId on login. + // TODO: also create the player's dorm on create_account, and remove any + // RoomInstance owned by accountId on login. return c.json({ access_token: accessToken, diff --git a/apps/auth/src/context.ts b/apps/auth/src/context.ts index 329ac40..6a53a54 100644 --- a/apps/auth/src/context.ts +++ b/apps/auth/src/context.ts @@ -2,7 +2,13 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' export type Env = SharedHonoEnv & { - // add additional Bindings here + // Shared rooms/accounts D1 database. The `auth` worker owns the `accounts` + // table (creates accounts on signup, seeds the system + Coach accounts). + DB: D1Database + // Shared match-presence KV (owned by the `match` worker). On account creation + // the new player's presence is seeded to the Orientation room so the match + // heartbeat keeps them there instead of bouncing them to the dorm. + MATCH_PRESENCE: KVNamespace } /** Variables can be extended */ diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 0973655..bd8ded0 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -1,10 +1,45 @@ +import { env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' -import { describe, expect, test } from 'vitest' +import { beforeAll, describe, expect, test } from 'vitest' import '../../auth.app' +import { SCHEMA_DDL } from '../../accounts-db' + +import type { Env } from '../../context' + +declare module 'cloudflare:test' { + interface ProvidedEnv extends Env {} +} + const ORIGIN = 'https://auth.rec.djdevin.net' +// The Orientation room (RoomId 13) new accounts are placed into on signup. +const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8' + +// Apply the accounts schema so create_account can persist (mirrors the migration), +// and seed the Orientation room (owned by the rooms worker) so signup can place +// the new player there. +beforeAll(async () => { + for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() + await env.DB.prepare( + `CREATE TABLE IF NOT EXISTS rooms ( + data TEXT NOT NULL, + room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL + )` + ).run() + await env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)') + .bind( + JSON.stringify({ + RoomId: 13, + Name: 'Orientation', + IsDorm: false, + SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }], + }) + ) + .run() +}) + /** Decode a JWT payload (no verification) for asserting claims. */ function decodePayload(token: string): Record { const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/') @@ -87,11 +122,32 @@ describe('auth worker routes', () => { expect(payload.sub).toBe('1') }) - test('POST /connect/token grant_type=create_account mints a new account id', async () => { + test('POST /connect/token grant_type=create_account persists a new account', async () => { const payload = await tokenFor('grant_type=create_account&platform_id=steam-123') + // The token's sub is the new account id, allocated above the system accounts. const sub = Number.parseInt(payload.sub as string, 10) - expect(sub).toBeGreaterThanOrEqual(10000) - expect(sub).toBeLessThanOrEqual(99999) + expect(sub).toBeGreaterThanOrEqual(2) + // The account exists in the DB with an auto-assigned (non-default) username. + const row = await env.DB.prepare('SELECT data FROM accounts WHERE account_id = ?1') + .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+$/) + }) + + test('POST /connect/token create_account seeds the new player into Orientation', async () => { + const payload = await tokenFor('grant_type=create_account&platform_id=steam-456') + const sub = payload.sub as string + const presence = await env.MATCH_PRESENCE.get<{ + roomInstance: { roomId: number; location: string; name: string } + }>(`presence:${sub}`, 'json') + expect(presence).not.toBeNull() + expect(presence!.roomInstance).toMatchObject({ + roomId: 13, + location: ORIENTATION_SCENE, + name: '^Orientation', + }) }) test('POST /connect/token maps the platform int to its enum name', async () => { diff --git a/apps/auth/wrangler.jsonc b/apps/auth/wrangler.jsonc index d230c07..b541a14 100644 --- a/apps/auth/wrangler.jsonc +++ b/apps/auth/wrangler.jsonc @@ -10,6 +10,26 @@ "custom_domain": true } ], + // Shared D1 database (also used by the rooms worker). The `auth` worker owns + // the `accounts` table; a dedicated migrations_table keeps its migration + // history separate from the rooms worker's migrations on the same database. + "d1_databases": [ + { + "binding": "DB", + "database_name": "rec-rooms", + "database_id": "d44083e1-5bfe-4467-aa9a-f13c5c2496d5", + "migrations_dir": "migrations", + "migrations_table": "d1_migrations_auth" + } + ], + // Shared match-presence KV (owned by the `match` worker) — used to place new + // accounts into the Orientation room on signup. + "kv_namespaces": [ + { + "binding": "MATCH_PRESENCE", + "id": "9f53f04b7dd244658d59f515a14748b6" + } + ], "logpush": false, "upload_source_maps": true, "observability": { diff --git a/apps/datacollection/src/datacollection.app.ts b/apps/datacollection/src/datacollection.app.ts index 7f02689..6287bc9 100644 --- a/apps/datacollection/src/datacollection.app.ts +++ b/apps/datacollection/src/datacollection.app.ts @@ -22,16 +22,16 @@ const app = new Hono() .get('/', (c) => c.json({ service: 'datacollection', status: 'ok' })) // Telemetry sink. The client POSTs analytics events here; we accept and ack - // without persisting (no binding yet). Body shape is unknown/unused. - .post('/data/event', (c) => c.body(null, 200)) + // with an empty JSON object (no persistence — no binding yet). + .post('/data/event', (c) => c.json({})) - // Periodic session heartbeat. Same deal — accept and ack with 200. - .post('/data/heartbeat', (c) => c.body(null, 200)) + // Periodic session heartbeat. Same deal — accept and ack with `{}`. + .post('/data/heartbeat', (c) => c.json({})) // Analytics identify call (player/device identification). Accept and ack. - .post('/identify', (c) => c.body(null, 200)) + .post('/identify', (c) => c.json({})) // Generic analytics HTTP API sink. Accept and ack. - .post('/httpapi', (c) => c.body(null, 200)) + .post('/httpapi', (c) => c.json({})) export default app