Files
recflare/packages/domain/src/accounts-db.ts
T
2026-07-09 00:56:50 -04:00

253 lines
8.1 KiB
TypeScript

/**
* 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 apps/auth/migrations/
* 0001_accounts.sql, applied with its own `migrations_table` so it doesn't clash
* with the rooms migrations that share the database). This module is the single
* source of truth for the helpers; the `auth` and `accounts` workers both import
* it from `@repo/domain` (each uses the subset it needs).
*/
/** 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
/** Set via POST /account/me/email; absent until the player provides one. */
email?: string
/** Set via POST /account/me/phone; absent until the player provides one. */
phone?: string
/** Set via PUT /account/me/bio; read back via GET /account/:id/bio. */
bio?: string
/** Remaining username changes; decremented by PUT /account/me/username. */
availableUsernameChanges?: number
/**
* PBKDF2 `salt:hash` for credential login; set via /account/me/changepassword
* or create_account. Kept in the JSON blob but never projected into a public
* DTO (the DTO builders pick only known fields), so it doesn't leak.
*/
passwordHash?: 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 a single account by username (case-insensitive), or null if none. */
export async function getAccountByUsername(
db: D1Database,
username: string
): Promise<Account | null> {
return parseOne(
await db
.prepare('SELECT data FROM accounts WHERE username_lower = ?1')
.bind(username.toLowerCase())
.first<AccountRow>()
)
}
/** Default cap on how many matches `searchAccounts` returns. */
export const SEARCH_LIMIT = 20
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
/**
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered
* alphabetically. Backed by the indexed `username_lower` generated column, so the
* `name%` LIKE stays index-friendly. Returns up to `limit` matches.
*/
export async function searchAccounts(
db: D1Database,
name: string,
limit = SEARCH_LIMIT
): Promise<Account[]> {
const q = name.trim().toLowerCase()
if (q === '') return []
const { results } = await db
.prepare(
`SELECT data FROM accounts WHERE username_lower LIKE ?1 ESCAPE '\\' ORDER BY username_lower LIMIT ?2`
)
.bind(`${escapeLike(q)}%`, limit)
.all<AccountRow>()
return parseAll(results)
}
/** 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)
}
/**
* Merge `overrides` into the account row for `id` and persist it. Reads the
* current account (falling back to a synthesized default), applies the
* overrides, and writes the whole JSON blob back — inserting the row when the
* account isn't in the table yet. Returns the updated account.
*/
export async function updateAccount(
db: D1Database,
id: number,
overrides: Partial<Account>
): Promise<Account> {
const current = (await getAccount(db, id)) ?? defaultAccount(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')
.bind(id, data)
.run()
if (!res.meta.changes) {
await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(data).run()
}
return updated
}
/**
* 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
}