big test rc

This commit is contained in:
React User
2026-06-02 07:48:41 +00:00
parent 04b3463b19
commit c7d89471ef
236 changed files with 14480 additions and 64 deletions
+339 -45
View File
@@ -3,11 +3,14 @@ import { createClient, SupabaseClient } from '@supabase/supabase-js';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { Settings } from './types';
import { logDebug, logInfo } from './server_log';
const BCRYPT_ROUNDS = 10;
const TOKEN_TTL_SEC = 60 * 60 * 24 * 7; // 7 days
const USERNAME_MIN = 2;
const PASSWORD_MIN = 6;
const EMAIL_MAX = 254;
const SYSTEM_ACCOUNT_ID = 1;
/** 1 USD = 4 RC (IAP packs) */
export const RC_PER_USD = 4;
@@ -26,6 +29,7 @@ function isIapDummyMode(settings: Settings): boolean {
export interface AuthUserPublic {
id: string;
username: string;
email: string | null;
cc: number;
rc: number;
created_at: string;
@@ -98,6 +102,50 @@ function coerceDbUserId(id: unknown): number | null {
return null;
}
function normalizeEmail(raw: string): string {
return raw.trim().toLowerCase();
}
function isValidEmail(email: string): boolean {
if (email.length < 3 || email.length > EMAIL_MAX) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function rowToAuthUserPublic(row: Record<string, unknown>): AuthUserPublic {
return {
id: String(row.id),
username: typeof row.username === 'string' ? row.username : String(row.username ?? ''),
email: row.email != null && row.email !== '' ? String(row.email) : null,
cc: Number(row.cc ?? 0),
rc: Number(row.rc ?? 0),
created_at: typeof row.created_at === 'string' ? row.created_at : String(row.created_at ?? ''),
last_logged_at: row.last_logged_at != null && row.last_logged_at !== ''
? String(row.last_logged_at)
: null,
};
}
function isMissingPurchaseRpcError(err: { code?: string; message?: string } | null): boolean {
if (!err) return false;
if (err.code === 'PGRST202') return true;
const m = err.message ?? '';
return m.includes('purchase_rc') || m.includes('Could not find the function');
}
function summarizePostgrestErr(err: { code?: string; message?: string; details?: string; hint?: string }): object {
return {
code: err.code ?? null,
message: err.message ?? null,
details: err.details ?? null,
hint: err.hint ?? null,
};
}
/** Integer-ish RC for comparisons after reads (DB may still be `real` until bigint migration). */
function roundedStoredRc(value: unknown): number {
return Math.round(Number(value ?? 0));
}
export interface MatchmakingUser {
userId: number;
username: string;
@@ -146,47 +194,65 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
}
const username = typeof req.body?.username === 'string' ? req.body.username.trim() : '';
const password = typeof req.body?.password === 'string' ? req.body.password : '';
const emailRaw = typeof req.body?.email === 'string' ? req.body.email : '';
const email = normalizeEmail(emailRaw);
if (username.length < USERNAME_MIN) {
res.status(400).json({ ok: false, error: `Username must be at least ${USERNAME_MIN} characters` });
return;
}
if (!isValidEmail(email)) {
res.status(400).json({ ok: false, error: 'A valid email address is required' });
return;
}
if (password.length < PASSWORD_MIN) {
res.status(400).json({ ok: false, error: `Password must be at least ${PASSWORD_MIN} characters` });
return;
}
const supabase = createSupabase(settings);
const { data: existing } = await supabase.from('users').select('id').eq('username', username).maybeSingle();
if (existing) {
const [{ data: existingUser }, { data: existingEmail }] = await Promise.all([
supabase.from('users').select('id').eq('username', username).maybeSingle(),
supabase.from('users').select('id').eq('email', email).maybeSingle(),
]);
if (existingUser) {
res.status(409).json({ ok: false, error: 'Username already taken' });
return;
}
if (existingEmail) {
res.status(409).json({ ok: false, error: 'Email already registered' });
return;
}
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const { data: row, error } = await supabase
.from('users')
.insert({
username,
email,
password: hash,
cc: 0,
rc: 0,
})
.select('id, username, cc, rc, created_at, last_logged_at')
.select('id, username, email, cc, rc, created_at, last_logged_at')
.single();
if (error || !row) {
if (error?.code === '23505') {
const msg = error.message ?? '';
if (msg.includes('email')) {
res.status(409).json({ ok: false, error: 'Email already registered' });
return;
}
if (msg.includes('username')) {
res.status(409).json({ ok: false, error: 'Username already taken' });
return;
}
}
res.status(500).json({ ok: false, error: error?.message ?? 'Registration failed' });
return;
}
const user: AuthUserPublic = {
id: row.id,
username: row.username,
cc: row.cc,
rc: row.rc,
created_at: row.created_at,
last_logged_at: row.last_logged_at ?? null,
};
const user = rowToAuthUserPublic(row as Record<string, unknown>);
const token = signToken(user.id, settings.auth_jwt_secret!.trim());
res.status(201).json({ ok: true, token, user });
});
@@ -206,7 +272,7 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
const supabase = createSupabase(settings);
const { data: row, error } = await supabase
.from('users')
.select('id, username, password, cc, rc, created_at')
.select('id, username, email, password, cc, rc, created_at')
.eq('username', username)
.maybeSingle();
@@ -225,11 +291,7 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
await supabase.from('users').update({ last_logged_at: nowIso }).eq('id', row.id);
const user: AuthUserPublic = {
id: row.id,
username: row.username,
cc: row.cc,
rc: row.rc,
created_at: row.created_at,
...rowToAuthUserPublic(row as Record<string, unknown>),
last_logged_at: nowIso,
};
const token = signToken(user.id, settings.auth_jwt_secret!.trim());
@@ -255,22 +317,14 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
const supabase = createSupabase(settings);
const { data, error } = await supabase
.from('users')
.select('id, username, cc, rc, created_at, last_logged_at')
.select('id, username, email, cc, rc, created_at, last_logged_at')
.eq('id', v.userId)
.maybeSingle();
if (error || !data) {
res.status(404).json({ ok: false, error: 'User not found' });
return;
}
const user: AuthUserPublic = {
id: data.id,
username: data.username,
cc: data.cc,
rc: data.rc,
created_at: data.created_at,
last_logged_at: data.last_logged_at ?? null,
};
res.json({ ok: true, user });
res.json({ ok: true, user: rowToAuthUserPublic(data as Record<string, unknown>) });
};
app.get('/auth/me', handleGetCurrentUser);
@@ -324,40 +378,280 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
}
const supabase = createSupabase(settings);
const { data: row, error: selErr } = await supabase
const buyerId = coerceDbUserId(v.userId);
if (buyerId == null || buyerId < 1) {
res.status(400).json({ ok: false, error: 'Invalid token user id' });
return;
}
const amount = Math.trunc(pack.rc);
const logLevel = settings.log_level ?? 0;
const selfPurchase = buyerId === SYSTEM_ACCOUNT_ID;
logInfo('[purchase-rc] start', {
buyer_id: buyerId,
pack_id: packId,
amount,
self_purchase: selfPurchase,
});
const { data: rpcData, error: rpcErr } = await supabase.rpc('purchase_rc', {
p_buyer_id: buyerId,
p_amount: amount,
});
logDebug(logLevel, '[purchase-rc] rpc raw', {
has_error: Boolean(rpcErr),
error: rpcErr ? summarizePostgrestErr(rpcErr) : null,
data_type: rpcData == null ? 'null' : typeof rpcData,
data_is_array: Array.isArray(rpcData),
});
if (!rpcErr && rpcData != null && typeof rpcData === 'object' && !Array.isArray(rpcData)) {
const user = rowToAuthUserPublic(rpcData as Record<string, unknown>);
logInfo('[purchase-rc] rpc success', {
buyer_id: buyerId,
path: 'purchase_rc',
buyer_rc_after: user.rc,
});
res.json({
ok: true,
pack: { id: packId, usd: pack.usd, rc_added: amount },
user,
});
return;
}
if (rpcErr && !isMissingPurchaseRpcError(rpcErr)) {
logInfo('[purchase-rc] rpc failed (not fallback)', summarizePostgrestErr(rpcErr));
const msg = rpcErr.message ?? '';
if (msg.includes('insufficient_supply')) {
res.status(409).json({ ok: false, error: 'Insufficient RC in supply account' });
return;
}
if (msg.includes('buyer_not_found')) {
res.status(404).json({ ok: false, error: 'User not found' });
return;
}
if (msg.includes('invalid_buyer') || msg.includes('invalid_amount')) {
res.status(400).json({ ok: false, error: 'Invalid purchase' });
return;
}
res.status(500).json({ ok: false, error: msg });
return;
}
if (rpcErr && isMissingPurchaseRpcError(rpcErr)) {
logInfo('[purchase-rc] rpc not available — fallback', {
...summarizePostgrestErr(rpcErr),
hint: 'Apply schemas/rpc_purchase_rc.sql in Supabase SQL editor',
});
} else if (!rpcErr && (rpcData == null || typeof rpcData !== 'object')) {
logInfo('[purchase-rc] rpc returned empty or non-object — fallback', {
rpc_data_null: rpcData == null,
typeof_data: typeof rpcData,
});
}
/** Fallback when `public.purchase_rc` is not installed — apply `schemas/rpc_purchase_rc.sql` in Supabase. */
if (buyerId === SYSTEM_ACCOUNT_ID) {
logInfo('[purchase-rc] fallback self-purchase branch', { buyer_id: buyerId, amount });
const { data: sysRow, error: oneErr } = await supabase
.from('users')
.select('id, username, email, cc, rc, created_at, last_logged_at')
.eq('id', SYSTEM_ACCOUNT_ID)
.single();
if (oneErr || !sysRow) {
logInfo('[purchase-rc] fallback self: load system user failed', summarizePostgrestErr(oneErr ?? {}));
res.status(404).json({ ok: false, error: 'System account or user not found' });
return;
}
const sourceRc = Math.round(Number((sysRow as { rc: unknown }).rc ?? 0));
logDebug(logLevel, '[purchase-rc] fallback self: balances', {
system_rc: sourceRc,
});
if (sourceRc < amount) {
logInfo('[purchase-rc] reject insufficient supply (fallback self)', {
source_rc: sourceRc,
amount,
});
res.status(409).json({ ok: false, error: 'Insufficient RC in supply account' });
return;
}
const { error: txErr } = await supabase.from('transactions').insert({
from: SYSTEM_ACCOUNT_ID,
to: SYSTEM_ACCOUNT_ID,
amount,
remarks: 'purchase',
});
if (txErr) {
logInfo('[purchase-rc] fallback self: transaction insert failed', summarizePostgrestErr(txErr));
res.status(500).json({ ok: false, error: `Purchase logged failed: ${txErr.message}` });
return;
}
logInfo('[purchase-rc] fallback self success (audit only, rc unchanged)', {
buyer_id: buyerId,
});
res.json({
ok: true,
pack: { id: packId, usd: pack.usd, rc_added: amount },
user: rowToAuthUserPublic(sysRow as Record<string, unknown>),
});
return;
}
const { data: rows, error: selErr } = await supabase
.from('users')
.select('rc')
.eq('id', v.userId)
.maybeSingle();
if (selErr || row === null) {
.select('id, rc')
.in('id', [buyerId, SYSTEM_ACCOUNT_ID]);
logDebug(logLevel, '[purchase-rc] fallback two-party select', {
error: selErr ? summarizePostgrestErr(selErr) : null,
row_count: rows?.length ?? 0,
ids: rows?.map((r) => ({ id: r.id, rc: r.rc })),
});
if (selErr || !rows || rows.length < 2) {
logInfo('[purchase-rc] reject not enough user rows', {
row_count: rows?.length ?? 0,
need: 2,
select_error: selErr ? summarizePostgrestErr(selErr) : null,
});
res.status(404).json({ ok: false, error: 'User not found' });
return;
}
const newRc = (row.rc ?? 0) + pack.rc;
const supply = rows.find((r) => coerceDbUserId(r.id) === SYSTEM_ACCOUNT_ID);
const target = rows.find((r) => coerceDbUserId(r.id) === buyerId);
if (!supply || !target) {
logInfo('[purchase-rc] reject missing supply or target row after select', {
has_supply: Boolean(supply),
has_target: Boolean(target),
});
res.status(404).json({ ok: false, error: 'System account or user not found' });
return;
}
const sourceRc = Math.round(Number(supply.rc ?? 0));
const targetRc = Math.round(Number(target.rc ?? 0));
if (sourceRc < amount) {
logInfo('[purchase-rc] reject insufficient supply (fallback)', {
source_rc: sourceRc,
amount,
});
res.status(409).json({ ok: false, error: 'Insufficient RC in supply account' });
return;
}
const newSourceRc = sourceRc - amount;
const { data: sourceAfter, error: sourceUpErr } = await supabase
.from('users')
.update({ rc: newSourceRc })
.eq('id', SYSTEM_ACCOUNT_ID)
.select('id, rc')
.single();
logDebug(logLevel, '[purchase-rc] fallback debit system', {
error: sourceUpErr ? summarizePostgrestErr(sourceUpErr) : null,
intended_rc: newSourceRc,
returned_row: sourceAfter,
});
if (sourceUpErr || !sourceAfter) {
logInfo('[purchase-rc] debit system account failed', {
error: sourceUpErr ? summarizePostgrestErr(sourceUpErr) : { message: 'no row returned' },
});
res.status(500).json({
ok: false,
error:
sourceUpErr?.message ??
'Could not debit supply account (id 1). Check RLS/policies or that this row exists.',
});
return;
}
const rcAfterDebit = roundedStoredRc((sourceAfter as { rc: unknown }).rc);
if (rcAfterDebit !== newSourceRc) {
logInfo(
'[purchase-rc] stored rc did not match debit — likely PostgreSQL `real` (float32) cannot represent this delta at the current balance. Apply schemas/alter_users_rc_bigint.sql.',
{
intended_rc: newSourceRc,
stored_rc_rounded: rcAfterDebit,
supply_rc_before: sourceRc,
amount,
}
);
res.status(500).json({
ok: false,
error:
'RC storage type is too coarse for this balance (float32 loses small changes around large balances). Run schemas/alter_users_rc_bigint.sql in Supabase SQL, then retry.',
});
return;
}
const { data: updated, error: upErr } = await supabase
.from('users')
.update({ rc: newRc })
.eq('id', v.userId)
.select('id, username, cc, rc, created_at, last_logged_at')
.update({ rc: targetRc + amount })
.eq('id', buyerId)
.select('id, username, email, cc, rc, created_at, last_logged_at')
.single();
logDebug(logLevel, '[purchase-rc] fallback credit buyer', {
error: upErr ? summarizePostgrestErr(upErr) : null,
buyer_id: buyerId,
intended_rc: targetRc + amount,
returned_row_id: updated?.id,
});
if (upErr || !updated) {
await supabase.from('users').update({ rc: sourceRc }).eq('id', SYSTEM_ACCOUNT_ID);
logInfo('[purchase-rc] credit buyer failed — rolled back system rc', {
buyer_id: buyerId,
error: upErr ? summarizePostgrestErr(upErr) : { message: 'no row returned' },
});
res.status(500).json({ ok: false, error: upErr?.message ?? 'Failed to update balance' });
return;
}
const user: AuthUserPublic = {
id: updated.id,
username: updated.username,
cc: updated.cc,
rc: updated.rc,
created_at: updated.created_at,
last_logged_at: updated.last_logged_at ?? null,
};
const intendedBuyerRc = targetRc + amount;
const buyerRcStored = roundedStoredRc(updated.rc);
if (buyerRcStored !== intendedBuyerRc) {
await supabase.from('users').update({ rc: sourceRc }).eq('id', SYSTEM_ACCOUNT_ID);
await supabase.from('users').update({ rc: targetRc }).eq('id', buyerId);
logInfo(
'[purchase-rc] credit verification failed — rolled back both users; check users.rc column type (real vs bigint)',
{
buyer_id: buyerId,
intended_buyer_rc: intendedBuyerRc,
stored_buyer_rc: buyerRcStored,
amount,
}
);
res.status(500).json({
ok: false,
error:
'RC balance update did not persist correctly (precision/column type). Run schemas/alter_users_rc_bigint.sql in Supabase, then retry.',
});
return;
}
const { error: txErr } = await supabase.from('transactions').insert({
from: SYSTEM_ACCOUNT_ID,
to: buyerId,
amount,
remarks: 'purchase',
});
if (txErr) {
logInfo('[purchase-rc] fallback: transaction insert failed after balance updates', summarizePostgrestErr(txErr));
res.status(500).json({ ok: false, error: `Purchase logged failed: ${txErr.message}` });
return;
}
const user = rowToAuthUserPublic(updated as Record<string, unknown>);
logInfo('[purchase-rc] fallback success', {
buyer_id: buyerId,
system_rc_after: sourceAfter.rc,
buyer_rc_after: user.rc,
});
res.json({
ok: true,
pack: { id: packId, usd: pack.usd, rc_added: pack.rc },
pack: { id: packId, usd: pack.usd, rc_added: amount },
user,
});
});