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:
@@ -0,0 +1,5 @@
|
||||
# domain
|
||||
|
||||
Shared domain enums and constants for the recflare workers. Single source of
|
||||
truth for the numeric values the client (and our D1 JSON blobs) encode, so the
|
||||
same magic numbers aren't re-hardcoded per worker.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@repo/domain",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"check:lint": "run-oxlint",
|
||||
"check:types": "run-tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "4.20260630.1",
|
||||
"@repo/tools": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Domain enums — the numeric codes the Rec Room client encodes into the room /
|
||||
* room-instance JSON we store in D1. Single source of truth so workers reference
|
||||
* a name instead of re-hardcoding the integer. Regular (not `const`) enums, since
|
||||
* the tsconfig sets `isolatedModules` (which disallows `const enum` across files).
|
||||
*/
|
||||
|
||||
/** The kind of a room instance (live session), matching the client's `RoomInstanceType`. */
|
||||
export enum RoomInstanceType {
|
||||
Public = 0,
|
||||
Private = 1,
|
||||
Dormroom = 2,
|
||||
Event = 3,
|
||||
Meetup = 4,
|
||||
Clubhouse = 5,
|
||||
}
|
||||
|
||||
/** A room's (or image's) visibility, matching the client's `RoomAccessibility`. */
|
||||
export enum Accessibility {
|
||||
Private = 0,
|
||||
Public = 1,
|
||||
Unlisted = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* A room-role tier (the `Role` byte on a room's `Roles` entries). Named tiers we
|
||||
* reference by value today — the owner (max byte) and co-owner.
|
||||
*/
|
||||
export enum Role {
|
||||
CoOwner = 30,
|
||||
Owner = 255,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RoomInstanceType, Accessibility, Role } from './enums'
|
||||
export * from './accounts-db'
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/workers-lib.json",
|
||||
// domain has no vitest of its own, so pull in only the D1/Workers ambient types
|
||||
// (drop @cloudflare/vitest-pool-workers/types that workers-lib.json adds).
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,24 @@ HOST="$SUBDOMAIN.$DOMAIN"
|
||||
# Secrets Store — RECFLARE_SECRETS_STORE: a single store id (all workers bind the
|
||||
# one shared store for the JWT signing key).
|
||||
CONFIG="wrangler.jsonc"
|
||||
|
||||
# Workers built with @cloudflare/vite-plugin (e.g. the React SPA in www) emit a
|
||||
# deploy-ready config into dist/<dir>/ that carries the built worker
|
||||
# (main: index.js), the resolved assets.directory, and no_bundle. The committed
|
||||
# wrangler.jsonc leaves assets.directory out on purpose — the plugin fills it in —
|
||||
# so deploying the source config fails with "assets ... missing the required
|
||||
# directory property". Prefer the generated config when it exists. These workers
|
||||
# have no D1/KV/Secrets bindings, so the id-splicing below is skipped.
|
||||
VITE_CONFIG="dist/$DIR/wrangler.json"
|
||||
if [ -f "$VITE_CONFIG" ]; then
|
||||
CONFIG="$VITE_CONFIG"
|
||||
fi
|
||||
|
||||
NEEDS_D1=$(grep -q '"database_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
|
||||
NEEDS_KV=$(grep -q '"id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
|
||||
NEEDS_STORE=$(grep -q '"store_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
|
||||
|
||||
if [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; then
|
||||
if [ "$CONFIG" = "wrangler.jsonc" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then
|
||||
CONFIG="wrangler.generated.jsonc"
|
||||
# Generated alongside the original so its relative paths (main, migrations_dir)
|
||||
# still resolve. Gitignored; removed on exit so `wrangler dev` is unaffected.
|
||||
@@ -116,6 +129,11 @@ if [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Vite-built configs set no_bundle (vite already bundled and minified), which is
|
||||
# incompatible with --minify. Only pass --minify when wrangler does the bundling.
|
||||
MINIFY="--minify"
|
||||
[ "$CONFIG" = "$VITE_CONFIG" ] && MINIFY=""
|
||||
|
||||
# Deploy with wrangler using the extracted values as binding variables
|
||||
echo "Deploying worker $NAME version $VERSION to $HOST"
|
||||
wrangler deploy \
|
||||
@@ -124,5 +142,5 @@ wrangler deploy \
|
||||
--var SENTRY_RELEASE:"$VERSION" \
|
||||
--var DOMAIN:"$DOMAIN" \
|
||||
--domain "$HOST" \
|
||||
--minify \
|
||||
$MINIFY \
|
||||
"$@"
|
||||
|
||||
Reference in New Issue
Block a user