mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
migrating more stuff into domain pkg
This commit is contained in:
@@ -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,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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user