819 lines
32 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}
|