feat: account system, OIDC, meta-progression research tree, and automated tests
Account & Persistence Layer (server/db.mjs)
- SQLite via node:sqlite DatabaseSync with WAL mode, foreign keys, and
synchronous=NORMAL for microsecond response times
- Users table: UUID primary key, unique lowercase username, scrypt-hashed
password, optional OIDC sub/issuer, crystal balance, timestamps
- Sessions table: 64-byte random hex token, FK to users, configurable TTL
with automatic expiry cleanup every 10 minutes
- User upgrades table: composite PK (user_id, upgrade_id), level tracking,
ON CONFLICT DO UPDATE for idempotent merges
- User stats table: games played/won, total kills/score, highest wave
- Password hashing: crypto.scrypt with 16-byte salt and 64-byte derived
key, constant-time comparison via crypto.timingSafeEqual
- Guest merge: caps crystals at 100,000, caps levels to defined maxLevels,
uses MAX(level, new) to preserve higher account levels, ignores unknown
upgrade IDs, transactional with BEGIN IMMEDIATE/COMMIT/ROLLBACK
- Atomic crystal purchasing: subtracts cost only if balance sufficient,
upgrades level within transaction, returns full user object on success
Meta-Progression Definitions (shared/meta-upgrades.mjs)
- Single source of truth shared between server (authoritative validation)
and client (talent tree UI)
- 3 branches: Economy (start_gold, wave_bonus, obstacle_discount),
Defense (bonus_lives, shockwave, fortress_shield),
Towers (tower_range, tower_speed, dot_potency)
- 9 upgrades with 1–5 levels each, progressive cost curves
- calcCrystalsEarned(wave, score, win): base 2.5 per wave, +40 for victory,
+floor(score/250), minimum 1 crystal per game
REST API (server/server.mjs)
- /api/auth/register: username 3–16 chars a-z0-9_-; password min 8 chars;
case-insensitive uniqueness; auto-creates user_stats row; returns
session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS)
- /api/auth/login: constant-time username lookup via scrypt verify;
rate-limited 20 auth attempts/IP/minute
- /api/auth/logout: deletes session server-side, clears cookie
- /api/auth/me: returns publicUser (id, username, crystals, upgrades,
stats, oidc flag) or null
- /api/auth/oidc/login: PKCE Authorization Code flow with SHA-256 S256
challenge/verifier; discovers .well-known/openid-configuration;
verifies RS256 id_token signature via JWKS public key; validates
issuer, audience, and expiry; finds or creates user by OIDC sub/issuer
- /api/auth/oidc/callback: exchanges code for tokens, verifies id_token,
issues session cookie, redirects to /?auth=ok or /?auth=error
- /api/upgrades/buy: validates upgrade ID against META_UPGRADES,
checks current level < maxLevel, deducts cost from crystals
- /api/game/finish: server-authoritative crystal calculation;
bounds-checks inputs (wave ≤ 9999, score ≤ 10M, kills ≤ 1M);
updates user_stats (games_played, games_won, total_kills, total_score,
highest_wave via MAX)
- /api/auth/merge-guest: one-time guest-to-account crystal and upgrade
migration with level caps
- /api/config: public endpoint exposing OIDC enabled state and button label
- Security: CSP header on all responses, X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: no-referrer, cache-control
no-store on API responses, path-traversal protection on static serving
Multiplayer Fairness
- Meta-upgrades (start_gold, bonus_lives, tower_range, tower_speed,
dot_potency, wave_bonus, obstacle_discount, shockwave, fortress_shield)
applied only in solo campaign mode
- Co-op and Duel multiplayer sessions reset all meta buffs to zero,
preserving lockstep determinism and competitive balance
- Multiplayer results set crystalsEarned: 0 to prevent duplicate rewards
Frontend (Vue 3 + TypeScript)
- AuthModal.vue: username/password login and registration form with
validation, OIDC single sign-on button (shown when configured),
guest-to-account upgrade on first login
- UserProfileBar.vue: top-bar indicator showing crystal count (💎),
user display name, research and login/logout buttons, reactive
auth state via auth controller
- ResearchTree.vue: interactive talent tree modal with 3 branches,
per-upgrade cost/level display, purchase confirmation, disabled
state for unaffordable/maxed upgrades, branch icons and descriptions
- auth.ts: reactive controller managing login, registration, OIDC
redirect detection (?auth=ok/?auth=error), guest profile migration
on first login, upgrade purchasing, and game result reporting
- meta.ts: frontend helpers for branch definitions, upgrade costs,
and guest profile persistence in localStorage
- engine.ts: solo meta bonus application (start_gold, bonus_lives,
tower_range, tower_speed, dot_potency, wave_bonus, obstacle_discount,
shockwave, fortress_shield); crystal rewards in finish() path
- mpgame.ts: meta buff reset in multiplayer sessions; crystalsEarned: 0
- sound.ts: shield sound synthesis for fortress_shield absorption
- store.ts: auth state, research tree toggle, upgrade snapshot
- types.ts: SfxName extended with "shield" sound
Docker Configuration
- Multi-stage build: node:22-alpine build → node:22-alpine runtime
(production deps only: ws)
- VOLUME /app/data for persistent SQLite database
- HEALTHCHECK on /health endpoint
- docker-compose.yml: port 3001, persistent ./data volume,
commented OIDC environment variables (OIDC_ENABLED, OIDC_ISSUER,
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_BUTTON_LABEL)
- .gitignore: data/, *.db, *.db-journal, *.db-wal, *.db-shm
Automated Tests (npm test)
- scripts/test-db.mjs (17 unit tests): isolated SQLite persistence –
scrypt hash/verify roundtrip, timing-safe constant-time comparison,
user creation with lowercase enforcement, UNIQUE constraint, session
create/get/delete lifecycle, expired session invalidation, crystal
addition, 5-level upgrade cost progression with max-level guard,
guest merge (crystal cap 100k, level cap, MAX() semantics, unknown
upgrade rejection), game result stats accumulation, calcCrystalsEarned
formula verification
- scripts/test-auth.mjs (31 integration tests): spawns real server with
isolated DATA_DIR, exercises full REST flow – register validation
(username too short, password too short, duplicate, case-insensitive),
login (wrong password, correct), session cookie attributes (HttpOnly,
SameSite=Lax, Path=/), /me endpoint, upgrade purchase (insufficient
crystals, unknown ID, successful purchase), game finish rewards
(victory, defeat, negative values clamped to 1 crystal minimum),
guest merge (crystals, level caps, unknown upgrades), logout,
OIDC-disabled endpoints (400), unauthenticated guards (401), 404
routing, CSP header on static files, rate limiting (429 after 20+
auth attempts per minute), and persistence across server restart
(kill + respawn with same DATA_DIR preserves all state)
This commit is contained in:
parent
ce2484bb8d
commit
1a9ac5bf45
24 changed files with 2216 additions and 12 deletions
294
server/db.mjs
Normal file
294
server/db.mjs
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { DatabaseSync } from 'node:sqlite'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
const DB_PATH = path.join(DATA_DIR, 'trxtd.db')
|
||||
const db = new DatabaseSync(DB_PATH)
|
||||
|
||||
// Performance optimizations (WAL mode, normal synchronous)
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT,
|
||||
oidc_sub TEXT UNIQUE,
|
||||
oidc_issuer TEXT,
|
||||
crystals INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_login INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_upgrades (
|
||||
user_id TEXT NOT NULL,
|
||||
upgrade_id TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
unlocked_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(user_id, upgrade_id),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_stats (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
games_played INTEGER NOT NULL DEFAULT 0,
|
||||
games_won INTEGER NOT NULL DEFAULT 0,
|
||||
total_kills INTEGER NOT NULL DEFAULT 0,
|
||||
total_score INTEGER NOT NULL DEFAULT 0,
|
||||
highest_wave INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_oidc ON users(oidc_sub, oidc_issuer);
|
||||
`)
|
||||
|
||||
export function closeDb() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
// Helper: Password hashing using scrypt
|
||||
export async function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex')
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) return reject(err)
|
||||
resolve(`${salt}:${derivedKey.toString('hex')}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifyPassword(password, storedHash) {
|
||||
if (!storedHash || !storedHash.includes(':')) return false
|
||||
const [salt, key] = storedHash.split(':')
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) return reject(err)
|
||||
const keyBuffer = Buffer.from(key, 'hex')
|
||||
resolve(crypto.timingSafeEqual(derivedKey, keyBuffer))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// User operations
|
||||
export function createUserLocal(username, displayName, passwordHash) {
|
||||
const id = crypto.randomUUID()
|
||||
const now = Date.now()
|
||||
const insertUser = db.prepare(`
|
||||
INSERT INTO users (id, username, display_name, password_hash, crystals, created_at, last_login)
|
||||
VALUES (?, ?, ?, ?, 0, ?, ?)
|
||||
`)
|
||||
insertUser.run(id, username.toLowerCase(), displayName, passwordHash, now, now)
|
||||
|
||||
const insertStats = db.prepare(`
|
||||
INSERT INTO user_stats (user_id, games_played, games_won, total_kills, total_score, highest_wave)
|
||||
VALUES (?, 0, 0, 0, 0, 0)
|
||||
`)
|
||||
insertStats.run(id)
|
||||
|
||||
return getUserById(id)
|
||||
}
|
||||
|
||||
export function findOrCreateUserOidc(oidcSub, oidcIssuer, preferredUsername, displayName) {
|
||||
const findStmt = db.prepare(`SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?`)
|
||||
let user = findStmt.get(oidcSub, oidcIssuer)
|
||||
|
||||
const now = Date.now()
|
||||
if (user) {
|
||||
db.prepare(`UPDATE users SET last_login = ?, display_name = ? WHERE id = ?`).run(now, displayName || user.display_name, user.id)
|
||||
return getUserById(user.id)
|
||||
}
|
||||
|
||||
// Create unique username if taken
|
||||
let cleanUsername = (preferredUsername || 'user').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 16) || 'player'
|
||||
let finalUsername = cleanUsername
|
||||
let counter = 1
|
||||
while (getUserByUsername(finalUsername)) {
|
||||
finalUsername = `${cleanUsername}${counter++}`
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const insertUser = db.prepare(`
|
||||
INSERT INTO users (id, username, display_name, oidc_sub, oidc_issuer, crystals, created_at, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?)
|
||||
`)
|
||||
insertUser.run(id, finalUsername, displayName || finalUsername, oidcSub, oidcIssuer, now, now)
|
||||
|
||||
const insertStats = db.prepare(`
|
||||
INSERT INTO user_stats (user_id, games_played, games_won, total_kills, total_score, highest_wave)
|
||||
VALUES (?, 0, 0, 0, 0, 0)
|
||||
`)
|
||||
insertStats.run(id)
|
||||
|
||||
return getUserById(id)
|
||||
}
|
||||
|
||||
export function getUserById(id) {
|
||||
const user = db.prepare(`SELECT id, username, display_name, oidc_sub, crystals, created_at, last_login FROM users WHERE id = ?`).get(id)
|
||||
if (!user) return null
|
||||
|
||||
const upgrades = db.prepare(`SELECT upgrade_id, level FROM user_upgrades WHERE user_id = ?`).all(id)
|
||||
const upgradeMap = {}
|
||||
for (const u of upgrades) {
|
||||
upgradeMap[u.upgrade_id] = u.level
|
||||
}
|
||||
|
||||
const stats = db.prepare(`SELECT * FROM user_stats WHERE user_id = ?`).get(id) || {
|
||||
games_played: 0,
|
||||
games_won: 0,
|
||||
total_kills: 0,
|
||||
total_score: 0,
|
||||
highest_wave: 0,
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
upgrades: upgradeMap,
|
||||
stats: {
|
||||
gamesPlayed: stats.games_played,
|
||||
gamesWon: stats.games_won,
|
||||
totalKills: stats.total_kills,
|
||||
totalScore: stats.total_score,
|
||||
highestWave: stats.highest_wave,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserByUsername(username) {
|
||||
return db.prepare(`SELECT * FROM users WHERE username = ?`).get(username.toLowerCase())
|
||||
}
|
||||
|
||||
// Session operations
|
||||
export function createSession(userId, ttlDays = 30) {
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
const now = Date.now()
|
||||
const expiresAt = now + ttlDays * 24 * 60 * 60 * 1000
|
||||
db.prepare(`INSERT INTO sessions (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`).run(token, userId, expiresAt, now)
|
||||
return { token, expiresAt }
|
||||
}
|
||||
|
||||
export function getSession(token) {
|
||||
if (!token) return null
|
||||
const now = Date.now()
|
||||
const row = db.prepare(`
|
||||
SELECT s.token, s.user_id, s.expires_at, u.username, u.display_name
|
||||
FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ? AND s.expires_at > ?
|
||||
`).get(token, now)
|
||||
return row || null
|
||||
}
|
||||
|
||||
export function deleteSession(token) {
|
||||
if (token) {
|
||||
db.prepare(`DELETE FROM sessions WHERE token = ?`).run(token)
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanExpiredSessions() {
|
||||
const now = Date.now()
|
||||
db.prepare(`DELETE FROM sessions WHERE expires_at <= ?`).run(now)
|
||||
}
|
||||
|
||||
// Meta-Progression operations
|
||||
export function addCrystals(userId, amount) {
|
||||
db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(amount, userId)
|
||||
return getUserById(userId)
|
||||
}
|
||||
|
||||
export function buyUpgrade(userId, upgradeId, cost, maxLevel = 5) {
|
||||
const user = getUserById(userId)
|
||||
if (!user || user.crystals < cost) return { ok: false, error: 'Nicht genügend Kristalle.' }
|
||||
|
||||
const currentLevel = user.upgrades[upgradeId] || 0
|
||||
if (currentLevel >= maxLevel) return { ok: false, error: 'Upgrade bereits auf Maximalstufe.' }
|
||||
|
||||
const nextLevel = currentLevel + 1
|
||||
const now = Date.now()
|
||||
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
db.prepare(`UPDATE users SET crystals = crystals - ? WHERE id = ? AND crystals >= ?`).run(cost, userId, cost)
|
||||
db.prepare(`
|
||||
INSERT INTO user_upgrades (user_id, upgrade_id, level, unlocked_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, upgrade_id) DO UPDATE SET level = ?
|
||||
`).run(userId, upgradeId, nextLevel, now, nextLevel)
|
||||
db.exec('COMMIT')
|
||||
return { ok: true, user: getUserById(userId) }
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK')
|
||||
return { ok: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeGuest(userId, crystals, upgrades, maxLevels) {
|
||||
const cappedCrystals = Math.max(0, Math.min(100000, Math.floor(Number(crystals) || 0)))
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (cappedCrystals > 0) {
|
||||
db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(cappedCrystals, userId)
|
||||
}
|
||||
if (upgrades && typeof upgrades === 'object') {
|
||||
for (const [upgradeId, rawLevel] of Object.entries(upgrades)) {
|
||||
const max = maxLevels[upgradeId]
|
||||
if (!max) continue
|
||||
const level = Math.max(0, Math.min(max, Math.floor(Number(rawLevel) || 0)))
|
||||
if (level <= 0) continue
|
||||
const now = Date.now()
|
||||
db.prepare(`
|
||||
INSERT INTO user_upgrades (user_id, upgrade_id, level, unlocked_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, upgrade_id) DO UPDATE SET level = MAX(level, ?)
|
||||
`).run(userId, upgradeId, level, now, level)
|
||||
}
|
||||
}
|
||||
db.exec('COMMIT')
|
||||
return getUserById(userId)
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function recordGameResult(userId, { win, score, wave, kills, crystalsEarned }) {
|
||||
const now = Date.now()
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (crystalsEarned > 0) {
|
||||
db.prepare(`UPDATE users SET crystals = crystals + ? WHERE id = ?`).run(crystalsEarned, userId)
|
||||
}
|
||||
db.prepare(`
|
||||
UPDATE user_stats SET
|
||||
games_played = games_played + 1,
|
||||
games_won = games_won + ?,
|
||||
total_kills = total_kills + ?,
|
||||
total_score = total_score + ?,
|
||||
highest_wave = MAX(highest_wave, ?)
|
||||
WHERE user_id = ?
|
||||
`).run(win ? 1 : 0, kills, score, wave, userId)
|
||||
db.exec('COMMIT')
|
||||
return getUserById(userId)
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue