Files
kickkingsapi/auth_bridge.ts
2026-08-14 20:55:11 +00:00

819 lines
32 KiB
TypeScript

import { Request, Response, Express } from 'express';
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
/** Unity should POST /auth/keepalive every 10s. Session stays active for 3 missed pings. */
const SESSION_ACTIVE_MS = 30_000;
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;
/** Fixed packs: $5, $20, $50 */
export const RC_PACKS: Record<string, { usd: number; rc: number }> = {
usd_5: { usd: 5, rc: 5 * RC_PER_USD },
usd_20: { usd: 20, rc: 20 * RC_PER_USD },
usd_50: { usd: 50, rc: 50 * RC_PER_USD },
};
function isIapDummyMode(settings: Settings): boolean {
return settings.iap_dummy_mode === true;
}
export interface AuthUserPublic {
id: string;
username: string;
email: string | null;
cc: number;
rc: number;
created_at: string;
last_logged_at: string | null;
}
function authConfigured(settings: Settings): boolean {
return Boolean(
settings.supabase_url?.trim() &&
settings.supabase_service_role_key?.trim() &&
settings.auth_jwt_secret?.trim()
);
}
/** Supabase URL + service role only (e.g. match history without full auth). */
export function supabaseConfigured(settings: Settings): boolean {
return Boolean(settings.supabase_url?.trim() && settings.supabase_service_role_key?.trim());
}
export function createSupabase(settings: Settings): SupabaseClient {
return createClient(settings.supabase_url!.trim(), settings.supabase_service_role_key!.trim(), {
auth: { persistSession: false, autoRefreshToken: false },
});
}
function signToken(userId: string, sessionId: string, secret: string): string {
const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL_SEC;
const payload = JSON.stringify({ sub: userId, exp, jti: sessionId });
const bodyB64 = Buffer.from(payload, 'utf8').toString('base64url');
const sig = crypto.createHmac('sha256', secret).update(bodyB64).digest('base64url');
return `${bodyB64}.${sig}`;
}
export function verifyAuthToken(token: string, secret: string): { userId: string; sessionId: string } | null {
const parts = token.split('.');
if (parts.length !== 2) return null;
const [bodyB64, sig] = parts;
const expectedSig = crypto.createHmac('sha256', secret).update(bodyB64).digest('base64url');
const a = Buffer.from(sig, 'utf8');
const b = Buffer.from(expectedSig, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
try {
const payload = JSON.parse(Buffer.from(bodyB64, 'base64url').toString('utf8')) as {
sub?: string;
exp?: number;
jti?: string;
};
if (!payload.sub || typeof payload.exp !== 'number') return null;
if (typeof payload.jti !== 'string' || payload.jti.length < 1) return null;
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
return { userId: payload.sub, sessionId: payload.jti };
} catch {
return null;
}
}
function isSessionActive(lastKeepaliveAt: unknown): boolean {
if (lastKeepaliveAt == null || lastKeepaliveAt === '') return false;
const ms = Date.parse(String(lastKeepaliveAt));
if (!Number.isFinite(ms)) return false;
return Date.now() - ms < SESSION_ACTIVE_MS;
}
type AuthSessionOk = {
ok: true;
supabase: SupabaseClient;
userId: string;
sessionId: string;
row: Record<string, unknown>;
};
type AuthSessionFail = { ok: false; status: number; error: string };
/**
* Verifies Bearer token, loads the user, and requires users.session_id === token jti.
*/
async function authenticateSession(
req: Request,
settings: Settings
): Promise<AuthSessionOk | AuthSessionFail> {
if (!authConfigured(settings)) {
return { ok: false, status: 503, error: 'Auth is not configured on the server' };
}
const token = readBearer(req);
if (!token) {
return { ok: false, status: 401, error: 'Missing Authorization: Bearer <token>' };
}
const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim());
if (!v) {
return { ok: false, status: 401, error: 'Invalid or expired token' };
}
const supabase = createSupabase(settings);
const { data, error } = await supabase
.from('users')
.select('id, session_id, username, email, cc, rc, created_at, last_logged_at')
.eq('id', v.userId)
.maybeSingle();
if (error || !data) {
return { ok: false, status: 404, error: 'User not found' };
}
const stored = typeof data.session_id === 'string' ? data.session_id : '';
if (!stored || stored !== v.sessionId) {
return { ok: false, status: 401, error: 'Invalid or expired token' };
}
return {
ok: true,
supabase,
userId: v.userId,
sessionId: v.sessionId,
row: data as Record<string, unknown>,
};
}
function readBearer(req: Request): string | null {
const h = req.headers.authorization;
if (!h || typeof h !== 'string') return null;
const m = /^Bearer\s+(.+)$/i.exec(h.trim());
return m ? m[1]!.trim() : null;
}
function readClientIp(req: Request): string | null {
const forwarded = req.headers['x-forwarded-for'];
const headerValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
if (typeof headerValue === 'string') {
const first = headerValue
.split(',')
.map((part) => part.trim())
.find((part) => part.length > 0);
if (first) return first;
}
if (typeof req.ip === 'string' && req.ip.trim().length > 0) return req.ip.trim();
if (typeof req.socket?.remoteAddress === 'string' && req.socket.remoteAddress.trim().length > 0) {
return req.socket.remoteAddress.trim();
}
return null;
}
function coerceDbUserId(id: unknown): number | null {
if (id == null) return null;
if (typeof id === 'bigint') return Number(id);
if (typeof id === 'number' && Number.isFinite(id)) return id;
if (typeof id === 'string') {
const n = parseInt(id, 10);
return Number.isNaN(n) ? null : n;
}
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;
}
/**
* Validates Bearer session token and loads the user from Supabase (id + username).
* Used by GET / and GET /cancel matchmaking.
*/
export async function authenticateMatchmakingRequest(
req: Request,
settings: Settings
): Promise<{ ok: true; user: MatchmakingUser } | { ok: false; status: number; message: string }> {
const auth = await authenticateSession(req, settings);
if (!auth.ok) {
const message = auth.status === 503 ? 'Auth not configured' : auth.error;
return { ok: false, status: auth.status, message };
}
if (typeof auth.row.username !== 'string' || auth.row.username.length < 2) {
return { ok: false, status: 404, message: 'User not found' };
}
const userId = coerceDbUserId(auth.row.id);
if (userId == null) {
return { ok: false, status: 500, message: 'Invalid user id' };
}
return { ok: true, user: { userId, username: auth.row.username } };
}
export function registerAuthRoutes(app: Express, settings: Settings): void {
app.post('/auth/register', async (req: Request, res: Response) => {
if (!authConfigured(settings)) {
res.status(503).json({ ok: false, error: 'Auth is not configured on the server' });
return;
}
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: 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 clientIp = readClientIp(req);
const sessionId = crypto.randomUUID();
const nowIso = new Date().toISOString();
const { data: row, error } = await supabase
.from('users')
.insert({
username,
email,
password: hash,
cc: 0,
rc: 0,
ip_address: clientIp,
session_id: sessionId,
last_keepalive_at: nowIso,
last_logged_at: nowIso,
})
.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 = rowToAuthUserPublic(row as Record<string, unknown>);
const token = signToken(user.id, sessionId, settings.auth_jwt_secret!.trim());
res.status(201).json({ ok: true, token, user });
});
app.post('/auth/login', async (req: Request, res: Response) => {
if (!authConfigured(settings)) {
res.status(503).json({ ok: false, error: 'Auth is not configured on the server' });
return;
}
const username = typeof req.body?.username === 'string' ? req.body.username.trim() : '';
const password = typeof req.body?.password === 'string' ? req.body.password : '';
if (username.length < USERNAME_MIN || !password) {
res.status(400).json({ ok: false, error: 'Invalid username or password' });
return;
}
const supabase = createSupabase(settings);
const { data: row, error } = await supabase
.from('users')
.select('id, username, email, password, cc, rc, created_at, session_id, last_keepalive_at')
.eq('username', username)
.maybeSingle();
if (error || !row) {
res.status(401).json({ ok: false, error: 'Invalid username or password' });
return;
}
const match = await bcrypt.compare(password, row.password);
if (!match) {
res.status(401).json({ ok: false, error: 'Invalid username or password' });
return;
}
const existingSession = typeof row.session_id === 'string' ? row.session_id : '';
if (existingSession && isSessionActive(row.last_keepalive_at)) {
res.status(409).json({
ok: false,
error: 'Already logged in on another device',
code: 'already_logged_in',
});
return;
}
const sessionId = crypto.randomUUID();
const nowIso = new Date().toISOString();
const clientIp = readClientIp(req);
await supabase
.from('users')
.update({
last_logged_at: nowIso,
ip_address: clientIp,
session_id: sessionId,
last_keepalive_at: nowIso,
})
.eq('id', row.id);
const user: AuthUserPublic = {
...rowToAuthUserPublic(row as Record<string, unknown>),
last_logged_at: nowIso,
};
const token = signToken(user.id, sessionId, settings.auth_jwt_secret!.trim());
res.json({ ok: true, token, user });
});
app.post('/auth/keepalive', async (req: Request, res: Response) => {
const auth = await authenticateSession(req, settings);
if (!auth.ok) {
res.status(auth.status).json({ ok: false, error: auth.error });
return;
}
const nowIso = new Date().toISOString();
const { error } = await auth.supabase
.from('users')
.update({ last_keepalive_at: nowIso })
.eq('id', auth.userId)
.eq('session_id', auth.sessionId);
if (error) {
res.status(500).json({ ok: false, error: error.message ?? 'Keepalive failed' });
return;
}
res.json({ ok: true });
});
app.post('/auth/logout', async (req: Request, res: Response) => {
const auth = await authenticateSession(req, settings);
if (!auth.ok) {
res.status(auth.status).json({ ok: false, error: auth.error });
return;
}
const { error } = await auth.supabase
.from('users')
.update({ session_id: null, last_keepalive_at: null })
.eq('id', auth.userId)
.eq('session_id', auth.sessionId);
if (error) {
res.status(500).json({ ok: false, error: error.message ?? 'Logout failed' });
return;
}
res.json({ ok: true });
});
/** Current user from DB (requires login token). Same behavior as GET /auth/user. */
const handleGetCurrentUser = async (req: Request, res: Response) => {
const auth = await authenticateSession(req, settings);
if (!auth.ok) {
res.status(auth.status).json({ ok: false, error: auth.error });
return;
}
res.json({ ok: true, user: rowToAuthUserPublic(auth.row) });
};
app.get('/auth/me', handleGetCurrentUser);
app.get('/auth/user', handleGetCurrentUser);
/** Public catalog for Unity UI (no auth). */
app.get('/auth/rc-packs', (_req: Request, res: Response) => {
const packs = Object.entries(RC_PACKS).map(([id, p]) => ({
id,
usd: p.usd,
rc: p.rc,
}));
res.json({ ok: true, rc_per_usd: RC_PER_USD, packs });
});
/** Client reports observed ping for a match. User id comes from login token. */
app.post('/auth/ping-report', async (req: Request, res: Response) => {
const auth = await authenticateSession(req, settings);
if (!auth.ok) {
res.status(auth.status).json({ ok: false, error: auth.error });
return;
}
const body = req.body as Record<string, unknown>;
const matchId = coerceDbUserId(body?.match_id);
const ping =
typeof body?.ping === 'number' && Number.isFinite(body.ping)
? Math.trunc(body.ping)
: typeof body?.ping === 'string' && /^-?\d+$/.test(body.ping.trim())
? parseInt(body.ping.trim(), 10)
: null;
if (matchId == null || matchId < 1) {
res.status(400).json({ ok: false, error: 'match_id must be a positive integer' });
return;
}
if (ping == null || ping < 0) {
res.status(400).json({ ok: false, error: 'ping must be a non-negative integer' });
return;
}
const userId = coerceDbUserId(auth.userId);
if (userId == null || userId < 1) {
res.status(400).json({ ok: false, error: 'Invalid token user id' });
return;
}
const ipAddress = readClientIp(req);
const { data: inserted, error } = await auth.supabase
.from('ping_reports')
.insert({
user_id: userId,
match_id: matchId,
ping,
ip_address: ipAddress,
})
.select('id, user_id, match_id, ping, ip_address, created_at')
.single();
if (error || !inserted) {
if (error?.code === '23503') {
res.status(404).json({ ok: false, error: 'Referenced user or match not found' });
return;
}
res.status(500).json({ ok: false, error: error?.message ?? 'Failed to report ping' });
return;
}
res.status(201).json({ ok: true, report: inserted });
});
/**
* Dummy IAP: adds RC for a named pack when `iap_dummy_mode` is true in settings.
* Later: same route can require a verified store receipt when dummy mode is off.
*/
app.post('/auth/purchase-rc', async (req: Request, res: Response) => {
if (!authConfigured(settings)) {
res.status(503).json({ ok: false, error: 'Auth is not configured on the server' });
return;
}
if (!isIapDummyMode(settings)) {
res.status(403).json({
ok: false,
error: 'Dummy purchases are disabled. Configure server-side purchase verification.',
});
return;
}
const auth = await authenticateSession(req, settings);
if (!auth.ok) {
res.status(auth.status).json({ ok: false, error: auth.error });
return;
}
const supabase = auth.supabase;
const packId = typeof req.body?.pack === 'string' ? req.body.pack.trim() : '';
const pack = RC_PACKS[packId];
if (!pack) {
const valid = Object.keys(RC_PACKS).join(', ');
res.status(400).json({
ok: false,
error: `Unknown pack "${packId}". Valid: ${valid}`,
});
return;
}
const buyerId = coerceDbUserId(auth.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 auth.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('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 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: 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 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: amount },
user,
});
});
}