big test rc
This commit is contained in:
+339
-45
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user