paddock-api / src /core.js
Shaurya2020's picture
Auth, scope filter, shared helpers
2c6fec4 verified
Raw History Blame Contribute Delete
5.32 kB
const crypto = require('node:crypto');
const { open } = require('../horse-racing-migrate.js');
const db = open();
const newId = p => p + '_' + crypto.randomBytes(6).toString('hex');
/* ── errors ──────────────────────────────────────────────────────────────── */
class ApiError extends Error {
constructor(status, message, detail) { super(message); this.status = status; this.detail = detail; }
}
const bad = (m, d) => { throw new ApiError(400, m, d); };
const notFound = m => { throw new ApiError(404, m); };
const forbid = m => { throw new ApiError(403, m); };
const conflict = m => { throw new ApiError(409, m); };
/** Wraps an async handler so thrown ApiErrors become clean JSON responses. */
const h = fn => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
/* ── auth ────────────────────────────────────────────────────────────────── */
// Demo token format: "admin:<id>". A real deployment verifies a signed JWT here
// and pulls id + role from the claims. Everything downstream is unchanged.
function authenticate(req, _res, next) {
const raw = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
if (!raw) return next(new ApiError(401, 'Missing bearer token.'));
const id = raw.startsWith('admin:') ? raw.slice(6) : raw;
const admin = db.prepare('SELECT id, name, role, parent_admin_id, label, status FROM admins WHERE id = ?').get(id);
if (!admin) return next(new ApiError(401, 'Token does not match a known admin.'));
if (admin.status !== 'active') return next(new ApiError(403, 'This admin is suspended.'));
req.admin = admin;
next();
}
/**
* The scope filter. This is the whole multi-tenant guarantee, and it lives in
* the WHERE clause on purpose β€” a role-2 admin who forges another label's id in
* the URL gets an empty result set from the database, not a row that
* application code then has to remember to reject.
*
* Returns { sql, params } to splice into a query:
* const s = scope(req.admin, 'e');
* db.prepare(`SELECT * FROM race_events e WHERE ${s.sql}`).all(...s.params)
*/
function scope(admin, alias = '', column = 'owner_admin_id') {
const col = alias ? `${alias}.${column}` : column;
if (admin.role === 1) return { sql: '1=1', params: [] }; // platform sees all
return { sql: `${col} = ?`, params: [admin.id] }; // white label sees its own
}
/** For writes: the row a role-2 admin creates is always owned by that admin. */
function ownerFor(admin, requested) {
if (admin.role === 2) {
if (requested && requested !== admin.id) forbid('A white label cannot create rows for another operator.');
return admin.id;
}
if (!requested) bad('owner_admin_id is required when acting as super admin.');
const target = db.prepare('SELECT id, role FROM admins WHERE id = ?').get(requested);
if (!target) bad('owner_admin_id does not match a known admin.', { owner_admin_id: requested });
return requested;
}
/** Fetch an event the caller is allowed to touch, or 404/403. */
function loadEvent(admin, eventId) {
const s = scope(admin, 'e');
const row = db.prepare(`SELECT e.* FROM race_events e WHERE e.id = ? AND ${s.sql}`).get(eventId, ...s.params);
if (!row) notFound('Race event not found.');
return row;
}
/* ── bet type rules (server side is the authority) ───────────────────────── */
const BET_TYPES = {
win: { picks: 1, ordered: false, exotic: false, takeout: 0 },
place: { picks: 1, ordered: false, exotic: false, takeout: 0 },
show: { picks: 1, ordered: false, exotic: false, takeout: 0 },
exacta: { picks: 2, ordered: true, exotic: true, takeout: 0.18 },
trifecta: { picks: 3, ordered: true, exotic: true, takeout: 0.22 },
superfecta: { picks: 4, ordered: true, exotic: true, takeout: 0.25 }
};
/** Live prices for an event's runners, from the current odds version. */
function currentOdds(eventId) {
return db.prepare(`
SELECT oe.participant_id, oe.decimal_odds, oe.previous_odds
FROM odds_entries oe
JOIN odds_versions ov ON ov.id = oe.version_id
JOIN race_events e ON e.id = ov.event_id AND e.current_odds_version = ov.version_no
WHERE ov.event_id = ?`).all(eventId);
}
function participants(eventId) {
const prices = Object.fromEntries(currentOdds(eventId).map(o => [o.participant_id, o]));
return db.prepare(`
SELECT p.*, j.name AS jockey_name, t.name AS team_name
FROM race_participants p
LEFT JOIN jockeys j ON j.id = p.jockey_id
LEFT JOIN teams t ON t.id = p.team_id
WHERE p.event_id = ? ORDER BY p.cloth_no`).all(eventId)
.map(p => ({
...p,
is_scratched: !!p.is_scratched,
decimal_odds: prices[p.id]?.decimal_odds ?? null,
previous_odds: prices[p.id]?.previous_odds ?? null,
stats: JSON.parse(p.stats_json || '{}')
}));
}
module.exports = {
db, newId, ApiError, bad, notFound, forbid, conflict, h,
authenticate, scope, ownerFor, loadEvent, BET_TYPES, currentOdds, participants
};