migrating more stuff into domain pkg

This commit is contained in:
Devin Zuczek
2026-07-09 00:56:38 -04:00
parent f0a273a3c4
commit 6e9d857ba6
23 changed files with 588 additions and 268 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
-- Accounts stored as a JSON blob with generated (virtual) columns for querying.
-- Generated from src/accounts-db.ts (SCHEMA_DDL) — keep in sync.
-- Generated from @repo/domain's accounts-db.ts (SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS accounts (
data TEXT NOT NULL,
+1 -1
View File
@@ -1,5 +1,5 @@
-- Store the player's avatar (set via the econ worker's /api/avatar/v2/set). It's
-- an opaque JSON payload that isn't queried, so a single nullable TEXT column on
-- the account row suffices. Kept in sync with SCHEMA_DDL in src/accounts-db.ts.
-- the account row suffices. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts.
ALTER TABLE accounts ADD COLUMN avatar TEXT;
+1
View File
@@ -16,6 +16,7 @@
"test": "run-vitest"
},
"dependencies": {
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"hono": "4.12.27",
"workers-tagged-logger": "1.0.1"
-173
View File
@@ -1,173 +0,0 @@
/**
* Account storage on the shared `recflare` 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 + 0002_avatar, sans seed INSERTs). */
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
)`,
`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 (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
}
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 default 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> = {}): 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<Account | null> {
return parseOne(
await db.prepare('SELECT data FROM accounts WHERE account_id = ?1').bind(id).first<AccountRow>()
)
}
/** Look up multiple accounts by AccountId (order not guaranteed). */
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
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<AccountRow>()
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<Account> = {}
): Promise<Account> {
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, displayName: username, ...overrides })
await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(JSON.stringify(account)).run()
return account
}
/**
* Read the account's stored password hash (`salt:hash`), or null when the account
* has none / doesn't exist. Kept in the account JSON blob but out of the public
* account DTO (which projects only known fields), so it never leaks.
*/
export async function getPasswordHash(db: D1Database, id: number): Promise<string | null> {
const row = await db
.prepare(
"SELECT json_extract(data, '$.passwordHash') AS hash FROM accounts WHERE account_id = ?1"
)
.bind(id)
.first<{ hash: string | null }>()
return row?.hash ?? null
}
/** Persist the account's password hash. Returns false when no such account exists. */
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
const { meta } = await db
.prepare(
"UPDATE accounts SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1"
)
.bind(id, hash)
.run()
return meta.changes > 0
}
+24 -4
View File
@@ -1,9 +1,9 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { createAccount, getPasswordHash, RoomInstanceType, setPasswordHash } from '@repo/domain'
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { createAccount, getPasswordHash, setPasswordHash } from './accounts-db'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from './jwt'
import { hashPassword, verifyPassword } from './password'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
@@ -65,7 +65,7 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb
roomInstanceId: ORIENTATION_INSTANCE_ID,
roomId: ORIENTATION_ROOM_ID,
subRoomId: num(sub?.SubRoomId, 1),
roomInstanceType: 0,
roomInstanceType: RoomInstanceType.Public,
location: str(sub?.UnitySceneId),
dataBlob: str(sub?.DataBlob),
eventId: 0,
@@ -153,14 +153,23 @@ const app = new Hono<App>()
// Resolve the account this token is for:
// - create_account: mint + persist a brand-new account (auto-assigned random
// username — players don't pick one initially); the token's `sub` is its id.
// A `password` may be posted to establish the account's login credential.
// - refresh_token: redeem a stored (single-use) refresh token for its account +
// platform, so an expiring session renews without re-login.
// - otherwise: the request MUST post a valid account_id — never fall back to a
// stub account (issuing account 1 to anyone would be bad).
// - otherwise: a credential login. The request MUST post a valid account_id AND
// the account's correct `password`. An account with no password set can't be
// logged into by id (no credential to verify) — closing the account_id-only
// takeover. New accounts establish a password via create_account or
// /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
const account = await createAccount(c.env.DB, { platforms: platformInt || 0 })
accountId = String(account.accountId)
// Establish the login password when one is posted (raw password never stored).
const password = typeof body.password === 'string' ? body.password : ''
if (password !== '') {
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
}
// Place the new player in Orientation (they don't explicitly matchmake into it).
await placeNewPlayerInOrientation(c.env, account.accountId)
} else if (grantType === 'refresh_token') {
@@ -183,6 +192,17 @@ const app = new Hono<App>()
400
)
}
// The account's password MUST be presented and match. An account with no
// stored hash has no credential to authenticate against, so login by id is
// refused — this closes the account_id-only takeover.
const storedHash = await getPasswordHash(c.env.DB, Number(posted))
const password = typeof body.password === 'string' ? body.password : ''
if (!storedHash || !(await verifyPassword(password, storedHash))) {
return c.json(
{ error: 'invalid_grant', error_description: 'invalid account_id or password' },
400
)
}
accountId = posted
}
+3 -3
View File
@@ -1,7 +1,7 @@
/**
* Password hashing for /account/me/changepassword. PBKDF2-SHA256 with a random
* per-password salt, stored as `salt:hash` (both base64). Not login-critical yet
* (login is account-id based), but we never store the raw password.
* Password hashing for /connect/token credential login and
* /account/me/changepassword. PBKDF2-SHA256 with a random per-password salt,
* stored as `salt:hash` (both base64). The raw password is never persisted.
*/
const ITERATIONS = 100_000
+57 -5
View File
@@ -4,7 +4,9 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../auth.app'
import { SCHEMA_DDL } from '../../accounts-db'
import { SCHEMA_DDL } from '@repo/domain'
import { hashPassword } from '../../password'
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
import type { Env } from '../../context'
@@ -18,6 +20,10 @@ const ORIGIN = 'https://example.com'
// The Orientation room (RoomId 13) new accounts are placed into on signup.
const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
// Credential login requires the account's password; seed a known one for the
// accounts the login tests authenticate as (42, 77).
const LOGIN_PASSWORD = 'correct-horse'
// 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.
@@ -26,6 +32,14 @@ beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Seed the accounts the credential-login tests use, each with LOGIN_PASSWORD set.
const hash = await hashPassword(LOGIN_PASSWORD)
for (const id of [42, 77]) {
await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
.run()
}
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
@@ -106,7 +120,7 @@ describe('auth worker routes', () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'account_id=42&platform_id=steam-123',
body: `account_id=42&platform_id=steam-123&password=${LOGIN_PASSWORD}`,
})
expect(res.status).toBe(200)
const json = (await res.json()) as {
@@ -150,6 +164,42 @@ describe('auth worker routes', () => {
expect(res.status).toBe(400)
})
test('POST /connect/token rejects a credential login with the wrong password', async () => {
const res = await postToken('account_id=42&password=wrong-password')
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
})
test('POST /connect/token rejects a credential login with no password', async () => {
const res = await postToken('account_id=42')
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
})
test('POST /connect/token refuses login to an account with no password set', async () => {
// Account 999 exists but never set a password — it has no credential to verify,
// so login by id alone is refused (this is the closed takeover hole).
await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 999, username: 'NoPass' }))
.run()
const res = await postToken('account_id=999&password=anything')
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
})
test('POST /connect/token create_account can set a password used for later login', async () => {
const created = await postToken('grant_type=create_account&platform_id=steam-pw2&password=hunter2')
expect(created.status).toBe(200)
const sub = decodePayload(created.json.access_token as string).sub as string
// The password set at creation authenticates a subsequent credential login.
const ok = await postToken(`account_id=${sub}&password=hunter2`)
expect(ok.status).toBe(200)
// A wrong password for that same account is rejected.
const bad = await postToken(`account_id=${sub}&password=nope`)
expect(bad.status).toBe(400)
})
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.
@@ -180,12 +230,14 @@ describe('auth worker routes', () => {
})
test('POST /connect/token maps the platform int to its enum name', async () => {
const payload = await tokenFor('account_id=42&platform=0')
const payload = await tokenFor(`account_id=42&platform=0&password=${LOGIN_PASSWORD}`)
expect(payload.platform).toBe('Steam')
})
test('POST /connect/token returns a refresh_token that redeems for a new token', async () => {
const login = await postToken('account_id=42&platform=0&platform_id=steam-123')
const login = await postToken(
`account_id=42&platform=0&platform_id=steam-123&password=${LOGIN_PASSWORD}`
)
expect(login.status).toBe(200)
const refreshToken = login.json.refresh_token as string
expect(typeof refreshToken).toBe('string')
@@ -205,7 +257,7 @@ describe('auth worker routes', () => {
})
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
const login = await postToken('account_id=77&platform=0')
const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
const refreshToken = login.json.refresh_token as string
const first = await postToken(