Files
trade-api/services/api/server.mjs

2203 lines
69 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '..', '..');
const TOKENS_DIR = path.join(PROJECT_ROOT, 'tokens');
const diagramDbCache = new Map();
const DIAGRAM_LAYOUT_REVISION_LIMIT = 120;
function readJsonFile(filePath) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
} catch {
return undefined;
}
}
function ensureParentDir(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function writeJsonFile(filePath, value) {
ensureParentDir(filePath);
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
fs.renameSync(tempPath, filePath);
}
function appendLine(filePath, line) {
ensureParentDir(filePath);
fs.appendFileSync(filePath, `${line}\n`, 'utf8');
}
function getIsoNow() {
return new Date().toISOString();
}
function base64Url(buf) {
return Buffer.from(buf)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function sha256Hex(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
function generateToken() {
return `alg_${base64Url(crypto.randomBytes(32))}`;
}
function getHeader(req, name) {
const v = req.headers[String(name).toLowerCase()];
return Array.isArray(v) ? v[0] : v;
}
function readBearerToken(req) {
const auth = getHeader(req, 'authorization');
if (auth && typeof auth === 'string') {
const m = auth.match(/^Bearer\s+(.+)$/i);
if (m && m[1]) return m[1].trim();
}
const apiKey = getHeader(req, 'x-api-key');
if (apiKey && typeof apiKey === 'string' && apiKey.trim()) return apiKey.trim();
return undefined;
}
function withCors(res, corsOrigin) {
res.setHeader('access-control-allow-origin', corsOrigin);
res.setHeader('access-control-allow-methods', 'GET,POST,PATCH,DELETE,OPTIONS');
res.setHeader(
'access-control-allow-headers',
'content-type, authorization, x-api-key, x-admin-secret'
);
}
function sendJson(res, status, body, corsOrigin) {
withCors(res, corsOrigin);
res.statusCode = status;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.end(JSON.stringify(body));
}
async function readBodyJson(req, { maxBytes }) {
const chunks = [];
let total = 0;
for await (const chunk of req) {
total += chunk.length;
if (total > maxBytes) throw new Error('payload_too_large');
chunks.push(chunk);
}
const text = Buffer.concat(chunks).toString('utf8');
if (!text.trim()) return {};
try {
return JSON.parse(text);
} catch {
throw new Error('invalid_json');
}
}
function normalizeGraphqlName(value, fallback, label) {
const v = String(value || fallback || '')
.trim()
.toLowerCase();
if (!v) return String(fallback || '');
if (!/^[a-z][a-z0-9_]*$/.test(v)) throw new Error(`Invalid ${label}: ${value}`);
return v;
}
function resolveConfig() {
const hasuraTokens = readJsonFile(path.join(TOKENS_DIR, 'hasura.json')) || {};
const apiTokens = readJsonFile(path.join(TOKENS_DIR, 'api.json')) || {};
const portRaw = process.env.PORT || process.env.API_PORT || apiTokens.port || '8787';
const port = Number.parseInt(String(portRaw), 10);
if (!Number.isInteger(port) || port <= 0) throw new Error(`Invalid PORT: ${portRaw}`);
const hasuraUrl =
process.env.HASURA_GRAPHQL_URL ||
hasuraTokens.graphqlUrl ||
hasuraTokens.apiUrl ||
'http://localhost:8080/v1/graphql';
const hasuraAdminSecret =
process.env.HASURA_ADMIN_SECRET || hasuraTokens.adminSecret || hasuraTokens.hasuraAdminSecret;
const apiAdminSecret = process.env.API_ADMIN_SECRET || apiTokens.adminSecret;
const corsOrigin = process.env.CORS_ORIGIN || apiTokens.corsOrigin || '*';
const appVersion = String(process.env.APP_VERSION || 'v1').trim() || 'v1';
const buildTimestamp = String(process.env.BUILD_TIMESTAMP || '').trim() || undefined;
const startedAt = getIsoNow();
const ticksTable = normalizeGraphqlName(process.env.TICKS_TABLE, 'drift_ticks', 'TICKS_TABLE');
const candlesFunction = normalizeGraphqlName(
process.env.CANDLES_FUNCTION,
'get_drift_candles',
'CANDLES_FUNCTION'
);
const dlobPublisherAllHealthUrl = String(
process.env.DLOB_PUBLISHER_ALL_HEALTH_URL || 'http://dlob-publisher-all:8080/health'
).trim();
const dlobPublisherAllMetricsUrl = String(
process.env.DLOB_PUBLISHER_ALL_METRICS_URL || 'http://dlob-publisher-all:9464/metrics'
).trim();
const diagramStateDir = String(process.env.DIAGRAM_STATE_DIR || path.join(PROJECT_ROOT, '.runtime')).trim();
const diagramDbFile = String(
process.env.DIAGRAM_DB_FILE || path.join(diagramStateDir, 'diagram.sqlite3')
).trim();
return {
port,
hasuraUrl,
hasuraAdminSecret,
apiAdminSecret,
corsOrigin,
appVersion,
buildTimestamp,
startedAt,
ticksTable,
candlesFunction,
dlobPublisherAllHealthUrl,
dlobPublisherAllMetricsUrl,
diagramDbFile,
};
}
async function hasuraRequest(cfg, { admin }, query, variables) {
const headers = { 'content-type': 'application/json' };
if (admin) {
if (!cfg.hasuraAdminSecret) throw new Error('Missing HASURA_ADMIN_SECRET (or tokens/hasura.json adminSecret)');
headers['x-hasura-admin-secret'] = cfg.hasuraAdminSecret;
}
const res = await fetch(cfg.hasuraUrl, {
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
});
const text = await res.text();
if (!res.ok) throw new Error(`Hasura HTTP ${res.status}: ${text}`);
let json;
try {
json = JSON.parse(text);
} catch {
throw new Error(`Hasura invalid JSON: ${text}`);
}
if (json.errors?.length) {
throw new Error(json.errors.map((e) => e.message).join(' | '));
}
return json.data;
}
async function fetchText(url, { timeoutMs = 2500 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: 'GET',
cache: 'no-store',
signal: controller.signal,
});
return { ok: res.ok, status: res.status, text: await res.text() };
} finally {
clearTimeout(timer);
}
}
function parsePrometheusLabels(raw) {
const labels = {};
if (!raw) return labels;
const re = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:\\"|[^"])*)"/g;
let match;
while ((match = re.exec(raw))) {
labels[match[1]] = match[2].replace(/\\"/g, '"');
}
return labels;
}
function parsePrometheusSamples(text) {
const samples = [];
for (const rawLine of String(text || '').split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const match = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+([^\s]+)(?:\s+(\d+))?$/);
if (!match) continue;
const value = Number(match[3]);
if (!Number.isFinite(value)) continue;
samples.push({
metric: match[1],
labels: parsePrometheusLabels(match[2] || ''),
value,
timestamp: match[4] ? Number(match[4]) : null,
});
}
return samples;
}
async function readPublisherAllRuntime(cfg) {
const runtime = {
id: 'publisher',
title: 'dlob-publisher-all',
status: 'warn',
health: {
httpOk: false,
gauge: null,
},
metrics: {
transport: 'yellowstone + websocket',
updateIntervalMs: 400,
perpMarkets: 0,
spotMarkets: 0,
totalMarkets: 0,
latestSlot: null,
},
endpoints: {
health: cfg.dlobPublisherAllHealthUrl,
metrics: cfg.dlobPublisherAllMetricsUrl,
},
errors: [],
};
try {
const health = await fetchText(cfg.dlobPublisherAllHealthUrl);
runtime.health.httpOk = health.ok && /^ok$/i.test(String(health.text || '').trim());
if (!runtime.health.httpOk) runtime.errors.push(`health_http_${health.status}`);
} catch (err) {
runtime.errors.push(`health_fetch:${String(err?.message || err)}`);
}
try {
const metricsRes = await fetchText(cfg.dlobPublisherAllMetricsUrl);
if (!metricsRes.ok) {
runtime.errors.push(`metrics_http_${metricsRes.status}`);
} else {
const samples = parsePrometheusSamples(metricsRes.text);
const healthGauge = samples.find((sample) => sample.metric === 'health_status');
const dlobSlots = samples.filter((sample) => sample.metric === 'dlob_slot');
const perpMarkets = dlobSlots.filter((sample) => sample.labels.marketType === 'perp');
const spotMarkets = dlobSlots.filter((sample) => sample.labels.marketType === 'spot');
const latestSlot = dlobSlots.reduce(
(max, sample) => (sample.value > max ? sample.value : max),
Number.NEGATIVE_INFINITY
);
runtime.health.gauge = healthGauge ? healthGauge.value : null;
runtime.metrics.perpMarkets = perpMarkets.length;
runtime.metrics.spotMarkets = spotMarkets.length;
runtime.metrics.totalMarkets = perpMarkets.length + spotMarkets.length;
runtime.metrics.latestSlot = Number.isFinite(latestSlot) ? Math.trunc(latestSlot) : null;
}
} catch (err) {
runtime.errors.push(`metrics_fetch:${String(err?.message || err)}`);
}
runtime.status =
runtime.health.httpOk &&
(runtime.health.gauge == null || runtime.health.gauge === 0) &&
runtime.metrics.totalMarkets > 0
? 'ok'
: 'warn';
return runtime;
}
async function readDiagramRuntime(cfg) {
return {
ok: true,
version: cfg.appVersion,
fetchedAt: getIsoNow(),
nodes: {
publisher: await readPublisherAllRuntime(cfg),
},
};
}
function normalizeDiagramView(value) {
return String(value || '').trim().toLowerCase() === 'ultra' ? 'ultra' : 'standard';
}
function normalizeDiagramLayout(layout) {
if (!layout || typeof layout !== 'object') return {};
const next = {};
for (const [id, rawEntry] of Object.entries(layout)) {
if (!rawEntry || typeof rawEntry !== 'object') continue;
const entry = {};
const rawPosition = rawEntry.position;
if (rawPosition && typeof rawPosition === 'object') {
const x = Number(rawPosition.x);
const y = Number(rawPosition.y);
if (Number.isFinite(x) && Number.isFinite(y)) entry.position = { x, y };
}
const rawSize = rawEntry.size;
if (rawSize && typeof rawSize === 'object') {
const width = Number(rawSize.width);
const height = Number(rawSize.height);
if (Number.isFinite(width) && Number.isFinite(height)) {
entry.size = { width, height };
}
}
if (rawEntry.parentId === null || typeof rawEntry.parentId === 'string') {
entry.parentId = rawEntry.parentId;
}
if (entry.position || entry.size || Object.prototype.hasOwnProperty.call(entry, 'parentId')) {
next[id] = entry;
}
}
return next;
}
function normalizeDiagramLayoutAction(value, fallback = 'save') {
const raw = String(value || fallback)
.trim()
.toLowerCase()
.replace(/[^a-z0-9:_-]+/g, '-')
.slice(0, 32);
return raw || fallback;
}
function getDiagramDb(cfg) {
const key = cfg.diagramDbFile;
const cached = diagramDbCache.get(key);
if (cached) return cached;
ensureParentDir(key);
const db = new DatabaseSync(key);
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS diagram_layout (
view TEXT PRIMARY KEY,
layout_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS diagram_debug_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
view TEXT NOT NULL,
event TEXT NOT NULL,
node_id TEXT,
viewport_json TEXT,
payload_json TEXT
);
CREATE TABLE IF NOT EXISTS diagram_layout_revision (
id INTEGER PRIMARY KEY AUTOINCREMENT,
view TEXT NOT NULL,
updated_at TEXT NOT NULL,
action TEXT NOT NULL,
layout_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_diagram_debug_event_ts ON diagram_debug_event(ts);
CREATE INDEX IF NOT EXISTS idx_diagram_layout_revision_view_id
ON diagram_layout_revision(view, id DESC);
`);
diagramDbCache.set(key, db);
return db;
}
function readDiagramLayoutStore(cfg) {
const db = getDiagramDb(cfg);
const rows = db.prepare('SELECT view, layout_json, updated_at FROM diagram_layout').all();
const store = {
ok: true,
version: 1,
updatedAt: undefined,
views: {
standard: {},
ultra: {},
},
};
for (const row of rows) {
const view = normalizeDiagramView(row.view);
let layout = {};
try {
layout = normalizeDiagramLayout(JSON.parse(String(row.layout_json || '{}')));
} catch {
layout = {};
}
store.views[view] = layout;
if (typeof row.updated_at === 'string' && (!store.updatedAt || row.updated_at > store.updatedAt)) {
store.updatedAt = row.updated_at;
}
}
return store;
}
function writeDiagramLayoutStore(cfg, store, views = ['standard', 'ultra']) {
const db = getDiagramDb(cfg);
const updatedAt = store.updatedAt || getIsoNow();
const upsert = db.prepare(`
INSERT INTO diagram_layout(view, layout_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(view) DO UPDATE SET
layout_json = excluded.layout_json,
updated_at = excluded.updated_at
`);
for (const rawView of views) {
const view = normalizeDiagramView(rawView);
upsert.run(
view,
JSON.stringify(normalizeDiagramLayout(store.views?.[view])),
updatedAt
);
}
}
function snapshotDiagramLayoutRevision(cfg, { view, updatedAt, layout, action }) {
const normalizedView = normalizeDiagramView(view);
const normalizedLayout = normalizeDiagramLayout(layout);
const normalizedAction = normalizeDiagramLayoutAction(action);
const ts = String(updatedAt || getIsoNow());
const json = JSON.stringify(normalizedLayout);
const db = getDiagramDb(cfg);
const last = db
.prepare(
`
SELECT id, layout_json, action
FROM diagram_layout_revision
WHERE view = ?
ORDER BY id DESC
LIMIT 1
`
)
.get(normalizedView);
if (last && String(last.layout_json || '') === json && String(last.action || '') === normalizedAction) {
return Number(last.id) || null;
}
db.prepare(
`
INSERT INTO diagram_layout_revision(view, updated_at, action, layout_json)
VALUES (?, ?, ?, ?)
`
).run(normalizedView, ts, normalizedAction, json);
const revisionId = Number(db.prepare('SELECT last_insert_rowid() AS id').get()?.id);
db.prepare(
`
DELETE FROM diagram_layout_revision
WHERE view = ?
AND id NOT IN (
SELECT id
FROM diagram_layout_revision
WHERE view = ?
ORDER BY id DESC
LIMIT ?
)
`
).run(normalizedView, normalizedView, DIAGRAM_LAYOUT_REVISION_LIMIT);
return Number.isFinite(revisionId) ? revisionId : null;
}
function readCurrentDiagramLayoutRevisionId(cfg, view) {
const row = getDiagramDb(cfg)
.prepare(
`
SELECT id
FROM diagram_layout_revision
WHERE view = ?
ORDER BY id DESC
LIMIT 1
`
)
.get(normalizeDiagramView(view));
const id = Number(row?.id);
return Number.isFinite(id) ? id : null;
}
function readDiagramLayoutHistory(cfg, view, limit = 12) {
const normalizedView = normalizeDiagramView(view);
const clampedLimit = Math.max(1, Math.min(40, Number.parseInt(String(limit || 12), 10) || 12));
const rows = getDiagramDb(cfg)
.prepare(
`
SELECT id, updated_at, action, layout_json
FROM diagram_layout_revision
WHERE view = ?
ORDER BY id DESC
LIMIT ?
`
)
.all(normalizedView, clampedLimit);
return rows.map((row) => {
let layout = {};
try {
layout = normalizeDiagramLayout(JSON.parse(String(row.layout_json || '{}')));
} catch {
layout = {};
}
return {
id: Number(row.id),
view: normalizedView,
updatedAt: String(row.updated_at || ''),
action: normalizeDiagramLayoutAction(row.action, 'save'),
entryCount: Object.keys(layout).length,
};
});
}
function readDiagramLayoutRevision(cfg, view, revisionId) {
const normalizedView = normalizeDiagramView(view);
const normalizedRevisionId = Number.parseInt(String(revisionId || ''), 10);
if (!Number.isInteger(normalizedRevisionId) || normalizedRevisionId <= 0) return null;
const row = getDiagramDb(cfg)
.prepare(
`
SELECT id, updated_at, action, layout_json
FROM diagram_layout_revision
WHERE view = ? AND id = ?
LIMIT 1
`
)
.get(normalizedView, normalizedRevisionId);
if (!row) return null;
let layout = {};
try {
layout = normalizeDiagramLayout(JSON.parse(String(row.layout_json || '{}')));
} catch {
layout = {};
}
return {
id: Number(row.id),
view: normalizedView,
updatedAt: String(row.updated_at || ''),
action: normalizeDiagramLayoutAction(row.action, 'save'),
entryCount: Object.keys(layout).length,
layout,
};
}
function appendDiagramDebugEvent(cfg, event) {
const db = getDiagramDb(cfg);
db.prepare(`
INSERT INTO diagram_debug_event(ts, view, event, node_id, viewport_json, payload_json)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
String(event.ts || getIsoNow()),
normalizeDiagramView(event.view),
String(event.event || 'unknown').slice(0, 120),
event.nodeId == null ? null : String(event.nodeId),
event.viewport ? JSON.stringify(event.viewport) : null,
event.payload ? JSON.stringify(event.payload) : null
);
}
function normalizeScopes(value) {
if (!value) return [];
if (Array.isArray(value)) return value.map((v) => String(v)).filter(Boolean);
if (typeof value === 'string') {
return value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
return [];
}
function errorMessage(err) {
return String(err?.message || err || '');
}
function isMissingHasuraField(err, fieldName) {
const message = errorMessage(err);
return (
message.includes(`field '${fieldName}' not found in type: 'query_root'`) ||
message.includes(`field '${fieldName}' not found in type: 'mutation_root'`)
);
}
function isBotControlPlaneSchemaMissing(err) {
return ['bot_config', 'bot_config_by_pk', 'bot_state_by_pk', 'bot_events'].some((fieldName) =>
isMissingHasuraField(err, fieldName)
);
}
async function requireValidToken(cfg, req, requiredScope) {
if (isAdmin(cfg, req)) {
return { ok: true, token: { id: 'admin', name: 'admin' } };
}
const token = readBearerToken(req);
if (!token) return { ok: false, status: 401, error: 'missing_token' };
const hash = sha256Hex(token);
const query = `
query ValidToken($hash: String!) {
api_tokens(where: {token_hash: {_eq: $hash}, revoked_at: {_is_null: true}}, limit: 1) {
id
name
scopes
}
}
`;
let data;
try {
data = await hasuraRequest(cfg, { admin: true }, query, { hash });
} catch (err) {
return { ok: false, status: 500, error: String(err?.message || err) };
}
const row = data?.api_tokens?.[0];
if (!row?.id) return { ok: false, status: 401, error: 'invalid_or_revoked_token' };
const scopes = normalizeScopes(row.scopes);
if (requiredScope && !scopes.includes(requiredScope)) {
return { ok: false, status: 403, error: 'missing_scope' };
}
// best-effort touch
const touch = `
mutation TouchToken($id: uuid!, $ts: timestamptz!) {
update_api_tokens_by_pk(pk_columns: {id: $id}, _set: {last_used_at: $ts}) { id }
}
`;
hasuraRequest(cfg, { admin: true }, touch, { id: row.id, ts: new Date().toISOString() }).catch(() => {});
return { ok: true, token: { id: row.id, name: row.name } };
}
function toNumericString(value, fieldName) {
if (value == null) throw new Error(`invalid_${fieldName}`);
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new Error(`invalid_${fieldName}`);
return String(value);
}
if (typeof value === 'string') {
const s = value.trim();
if (!s) throw new Error(`invalid_${fieldName}`);
const n = Number(s);
if (!Number.isFinite(n)) throw new Error(`invalid_${fieldName}`);
return s;
}
// best-effort: allow bigint-like or BN-like objects
if (typeof value?.toString === 'function') {
const s = String(value.toString()).trim();
if (!s) throw new Error(`invalid_${fieldName}`);
const n = Number(s);
if (!Number.isFinite(n)) throw new Error(`invalid_${fieldName}`);
return s;
}
throw new Error(`invalid_${fieldName}`);
}
function normalizeTick(input, tokenInfo) {
const ts = (input?.ts || input?.timestamp || getIsoNow())?.toString?.();
const market_index = input?.market_index ?? input?.marketIndex;
const symbol = input?.symbol;
const oracle_price = input?.oracle_price ?? input?.oraclePrice ?? input?.price;
const mark_price = input?.mark_price ?? input?.markPrice ?? input?.mark;
const oracle_slot = input?.oracle_slot ?? input?.oracleSlot ?? input?.slot;
const source = (input?.source || 'api')?.toString?.();
const raw = input?.raw && typeof input.raw === 'object' ? input.raw : undefined;
if (!ts || Number.isNaN(Date.parse(ts))) throw new Error('invalid_ts');
if (!Number.isInteger(market_index)) throw new Error('invalid_market_index');
if (typeof symbol !== 'string' || !symbol.trim()) throw new Error('invalid_symbol');
const oracleStr = toNumericString(oracle_price, 'oracle_price');
const markStr = mark_price == null ? undefined : toNumericString(mark_price, 'mark_price');
const slotNum =
oracle_slot == null ? undefined : Number.isFinite(Number(oracle_slot)) ? Number(oracle_slot) : undefined;
const mergedRaw =
raw || tokenInfo
? {
...(raw || {}),
ingestedBy: tokenInfo ? { tokenId: tokenInfo.id, name: tokenInfo.name } : undefined,
}
: undefined;
return {
ts,
market_index,
symbol: symbol.trim(),
// Postgres columns are NUMERIC; Hasura `numeric` scalar returns strings and expects string inputs.
oracle_price: oracleStr,
mark_price: markStr,
oracle_slot: slotNum,
source,
raw: mergedRaw,
};
}
async function insertTick(cfg, tick) {
const table = cfg.ticksTable;
const insertField = `insert_${table}_one`;
const mutation = `
mutation InsertTick($object: ${table}_insert_input!) {
${insertField}(object: $object) {
id
}
}
`;
const data = await hasuraRequest(cfg, { admin: true }, mutation, { object: tick });
return data?.[insertField]?.id;
}
async function createApiToken(cfg, name, scopes, meta) {
const mutation = `
mutation CreateToken($name: String!, $hash: String!, $scopes: [String!]!, $meta: jsonb) {
insert_api_tokens_one(object: {name: $name, token_hash: $hash, scopes: $scopes, meta: $meta}) {
id
name
created_at
}
}
`;
for (let attempt = 0; attempt < 5; attempt++) {
const token = generateToken();
const hash = sha256Hex(token);
try {
const data = await hasuraRequest(cfg, { admin: true }, mutation, { name, hash, scopes, meta });
const row = data?.insert_api_tokens_one;
if (!row?.id) throw new Error('token_insert_failed');
return { token, row };
} catch (err) {
const msg = String(err?.message || err);
const isUniqueConflict = msg.toLowerCase().includes('unique') || msg.toLowerCase().includes('constraint');
if (!isUniqueConflict) throw err;
}
}
throw new Error('token_generation_failed');
}
async function revokeApiToken(cfg, id) {
const mutation = `
mutation RevokeToken($id: uuid!, $ts: timestamptz!) {
update_api_tokens_by_pk(pk_columns: {id: $id}, _set: {revoked_at: $ts}) {
id
revoked_at
}
}
`;
const data = await hasuraRequest(cfg, { admin: true }, mutation, { id, ts: new Date().toISOString() });
return data?.update_api_tokens_by_pk?.id;
}
function clampInt(value, min, max) {
const n = Number.parseInt(String(value), 10);
if (!Number.isFinite(n) || !Number.isInteger(n)) return min;
return Math.min(max, Math.max(min, n));
}
function clampNumber(value, min, max, fallback) {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function parseNumeric(value) {
if (value == null) return null;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
const s = value.trim();
if (!s) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
return null;
}
function tsToUnixSeconds(value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string') return null;
const ms = Date.parse(value);
if (!Number.isFinite(ms)) return null;
return Math.floor(ms / 1000);
}
function flowFromDelta(delta) {
if (delta > 0) return { up: 1, down: 0, flat: 0 };
if (delta < 0) return { up: 0, down: 1, flat: 0 };
return { up: 0, down: 0, flat: 1 };
}
function computeCandleFlowFromTicks({ candle, bucketSeconds, points, nowSec, isCurrent }) {
const start = candle.time;
const end = start + bucketSeconds;
const progressEnd = isCurrent ? Math.min(end, Math.max(start, nowSec)) : end;
const totalWindow = progressEnd - start;
if (!(totalWindow > 0)) return flowFromDelta(candle.close - candle.open);
let up = 0;
let down = 0;
let flat = 0;
let prevT = start;
let prevP = candle.open;
for (const pt of points || []) {
const t = clampNumber(pt.t, prevT, progressEnd, prevT);
const dt = t - prevT;
if (dt > 0) {
const delta = pt.p - prevP;
if (delta > 0) up += dt;
else if (delta < 0) down += dt;
else flat += dt;
prevT = t;
}
prevP = pt.p;
if (prevT >= progressEnd) break;
}
const tail = progressEnd - prevT;
if (tail > 0) flat += tail;
const sum = up + down + flat;
if (!(sum > 0)) return flowFromDelta(candle.close - candle.open);
return { up: up / sum, down: down / sum, flat: flat / sum };
}
function computeCandleFlowRowsFromTicks({ candle, bucketSeconds, points, rows, nowSec, isCurrent }) {
const start = candle.time;
const end = start + bucketSeconds;
const progressEnd = isCurrent ? Math.min(end, Math.max(start, nowSec)) : end;
const totalWindow = progressEnd - start;
if (!(totalWindow > 0)) {
const overall = candle.close - candle.open;
const dir = overall > 0 ? 1 : overall < 0 ? -1 : 0;
return new Array(rows).fill(dir);
}
const rowDirs = new Array(rows).fill(0);
const pts = [{ t: start, p: candle.open }, ...(points || []), { t: progressEnd, p: candle.close }];
for (let i = 1; i < pts.length; i += 1) {
const a = pts[i - 1];
const b = pts[i];
const t1 = clampNumber(a.t, start, progressEnd, start);
const t2 = clampNumber(b.t, start, progressEnd, start);
if (!(t2 > t1)) continue;
const delta = b.p - a.p;
const dir = delta > 0 ? 1 : delta < 0 ? -1 : 0;
const from = Math.max(0, Math.min(rows - 1, Math.floor(((t1 - start) / bucketSeconds) * rows)));
const to = Math.max(0, Math.min(rows - 1, Math.floor(((t2 - start) / bucketSeconds) * rows)));
for (let r = from; r <= to; r += 1) rowDirs[r] = dir;
}
return rowDirs;
}
function computeCandleFlowSlicesFromTicks({ candle, bucketSeconds, points, rows, nowSec, isCurrent }) {
const start = candle.time;
const end = start + bucketSeconds;
const progressEnd = isCurrent ? Math.min(end, Math.max(start, nowSec)) : end;
const dirs = new Array(rows).fill(0);
const moves = new Array(rows).fill(0);
const totalWindow = progressEnd - start;
if (!(totalWindow > 0)) {
const overall = candle.close - candle.open;
const dir = overall > 0 ? 1 : overall < 0 ? -1 : 0;
dirs.fill(dir);
return { dirs, moves };
}
const pts = [...(points || []), { t: progressEnd, p: candle.close }];
let idx = 0;
let lastP = candle.open;
for (let r = 0; r < rows; r += 1) {
const sliceStart = start + (r / rows) * bucketSeconds;
if (!(sliceStart < progressEnd)) break;
const sliceEnd = Math.min(progressEnd, start + ((r + 1) / rows) * bucketSeconds);
while (idx < pts.length && pts[idx].t <= sliceStart) {
lastP = pts[idx].p;
idx += 1;
}
const pStart = lastP;
while (idx < pts.length && pts[idx].t <= sliceEnd) {
lastP = pts[idx].p;
idx += 1;
}
const pEnd = lastP;
const delta = pEnd - pStart;
const dir = delta > 0 ? 1 : delta < 0 ? -1 : 0;
const move = Math.abs(delta);
dirs[r] = dir;
moves[r] = Number.isFinite(move) ? move : 0;
}
return { dirs, moves };
}
function computeCandleFlowMovesFromTicks({ candle, bucketSeconds, points, rows, nowSec, isCurrent }) {
const start = candle.time;
const end = start + bucketSeconds;
const progressEnd = isCurrent ? Math.min(end, Math.max(start, nowSec)) : end;
const totalWindow = progressEnd - start;
const out = new Array(rows).fill(0);
if (!(totalWindow > 0)) return out;
const pts = [...(points || []), { t: progressEnd, p: candle.close }];
let idx = 0;
let lastP = candle.open;
for (let r = 0; r < rows; r += 1) {
const sliceStart = start + (r / rows) * bucketSeconds;
if (!(sliceStart < progressEnd)) break;
const sliceEnd = Math.min(progressEnd, start + ((r + 1) / rows) * bucketSeconds);
while (idx < pts.length && pts[idx].t <= sliceStart) {
lastP = pts[idx].p;
idx += 1;
}
const pStart = lastP;
while (idx < pts.length && pts[idx].t <= sliceEnd) {
lastP = pts[idx].p;
idx += 1;
}
const pEnd = lastP;
const move = Math.abs(pEnd - pStart);
out[r] = Number.isFinite(move) ? move : 0;
}
return out;
}
function parseTimeframeToSeconds(tf) {
const v = String(tf || '').trim().toLowerCase();
if (!v) return 60;
const m = v.match(/^(\d+)(s|m|h|d)$/);
if (!m) throw new Error(`invalid_tf`);
const num = Number.parseInt(m[1], 10);
if (!Number.isInteger(num) || num <= 0) throw new Error(`invalid_tf`);
const unit = m[2];
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
return num * mult;
}
function mean(values) {
if (!values.length) return 0;
return values.reduce((a, b) => a + b, 0) / values.length;
}
function stddev(values) {
if (!values.length) return 0;
const m = mean(values);
const v = values.reduce((acc, x) => acc + (x - m) * (x - m), 0) / values.length;
return Math.sqrt(v);
}
function sma(values, period) {
if (period <= 0) throw new Error('period must be > 0');
const out = new Array(values.length).fill(null);
let sum = 0;
for (let i = 0; i < values.length; i++) {
sum += values[i];
if (i >= period) sum -= values[i - period];
if (i >= period - 1) out[i] = sum / period;
}
return out;
}
function ema(values, period) {
if (period <= 0) throw new Error('period must be > 0');
const out = new Array(values.length).fill(null);
const k = 2 / (period + 1);
if (values.length < period) return out;
const first = mean(values.slice(0, period));
out[period - 1] = first;
let prev = first;
for (let i = period; i < values.length; i++) {
const next = values[i] * k + prev * (1 - k);
out[i] = next;
prev = next;
}
return out;
}
function rsi(values, period) {
if (period <= 0) throw new Error('period must be > 0');
const out = new Array(values.length).fill(null);
if (values.length <= period) return out;
let gains = 0;
let losses = 0;
for (let i = 1; i <= period; i++) {
const change = values[i] - values[i - 1];
if (change >= 0) gains += change;
else losses -= change;
}
let avgGain = gains / period;
let avgLoss = losses / period;
const rs = avgLoss === 0 ? Number.POSITIVE_INFINITY : avgGain / avgLoss;
out[period] = 100 - 100 / (1 + rs);
for (let i = period + 1; i < values.length; i++) {
const change = values[i] - values[i - 1];
const gain = Math.max(change, 0);
const loss = Math.max(-change, 0);
avgGain = (avgGain * (period - 1) + gain) / period;
avgLoss = (avgLoss * (period - 1) + loss) / period;
const rs2 = avgLoss === 0 ? Number.POSITIVE_INFINITY : avgGain / avgLoss;
out[i] = 100 - 100 / (1 + rs2);
}
return out;
}
function bollingerBands(values, period, stdDevMult) {
if (period <= 0) throw new Error('period must be > 0');
const upper = new Array(values.length).fill(null);
const lower = new Array(values.length).fill(null);
const mid = sma(values, period);
for (let i = period - 1; i < values.length; i++) {
const window = values.slice(i - period + 1, i + 1);
const sd = stddev(window);
const m = mid[i];
if (m == null) continue;
upper[i] = m + stdDevMult * sd;
lower[i] = m - stdDevMult * sd;
}
return { upper, lower, mid };
}
function macd(values, fastPeriod = 12, slowPeriod = 26, signalPeriod = 9) {
const fast = ema(values, fastPeriod);
const slow = ema(values, slowPeriod);
const macdLine = values.map((_, i) => {
const f = fast[i];
const s = slow[i];
return f == null || s == null ? null : f - s;
});
const signal = new Array(values.length).fill(null);
const k = 2 / (signalPeriod + 1);
let seeded = false;
let prev = 0;
const buf = [];
for (let i = 0; i < macdLine.length; i++) {
const v = macdLine[i];
if (v == null) continue;
if (!seeded) {
buf.push(v);
if (buf.length === signalPeriod) {
const first = mean(buf);
signal[i] = first;
prev = first;
seeded = true;
}
continue;
}
const next = v * k + prev * (1 - k);
signal[i] = next;
prev = next;
}
return { macd: macdLine, signal };
}
function toSeries(times, values) {
return times.map((t, i) => ({ time: t, value: values[i] ?? null }));
}
function isAdmin(cfg, req) {
if (!cfg.apiAdminSecret) return false;
const provided = getHeader(req, 'x-admin-secret');
return typeof provided === 'string' && provided === cfg.apiAdminSecret;
}
function isUuidLike(value) {
const s = String(value || '').trim();
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
}
function normalizeBotMode(value) {
const v = String(value || '')
.trim()
.toLowerCase();
if (!v) return undefined;
if (!['off', 'observe', 'trade', 'panic'].includes(v)) throw new Error(`invalid_mode: ${value}`);
return v;
}
async function handler(cfg, req, res) {
if (req.method === 'OPTIONS') {
withCors(res, cfg.corsOrigin);
res.statusCode = 204;
res.end();
return;
}
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const pathname = url.pathname;
const segments = pathname.split('/').filter(Boolean);
if (req.method === 'GET' && pathname === '/healthz') {
sendJson(
res,
200,
{
ok: true,
version: cfg.appVersion,
buildTimestamp: cfg.buildTimestamp,
startedAt: cfg.startedAt,
ticksTable: cfg.ticksTable,
candlesFunction: cfg.candlesFunction,
},
cfg.corsOrigin
);
return;
}
if (req.method === 'GET' && pathname === '/v1/runtime/diagram') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
try {
const runtime = await readDiagramRuntime(cfg);
sendJson(res, 200, runtime, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
if (req.method === 'GET' && pathname === '/v1/diagram/layout') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
try {
const view = normalizeDiagramView(url.searchParams.get('view'));
const store = readDiagramLayoutStore(cfg);
sendJson(
res,
200,
{
ok: true,
view,
updatedAt: store.updatedAt,
layout: store.views[view] || {},
currentRevisionId: readCurrentDiagramLayoutRevisionId(cfg, view),
},
cfg.corsOrigin
);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
if (req.method === 'PATCH' && pathname === '/v1/diagram/layout') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
try {
const body = await readBodyJson(req, { maxBytes: 512 * 1024 });
const view = normalizeDiagramView(body.view || url.searchParams.get('view'));
const layout = normalizeDiagramLayout(body.layout);
const action = normalizeDiagramLayoutAction(body.action, 'save');
const updatedAt = getIsoNow();
const store = readDiagramLayoutStore(cfg);
store.updatedAt = updatedAt;
store.views[view] = layout;
writeDiagramLayoutStore(cfg, store, [view]);
const revisionId = snapshotDiagramLayoutRevision(cfg, { view, updatedAt, layout, action });
sendJson(res, 200, { ok: true, view, updatedAt, layout, revisionId }, cfg.corsOrigin);
} catch (err) {
const msg = String(err?.message || err);
const status = msg === 'payload_too_large' ? 413 : msg === 'invalid_json' ? 400 : 500;
sendJson(res, status, { ok: false, error: msg }, cfg.corsOrigin);
}
return;
}
if (req.method === 'DELETE' && pathname === '/v1/diagram/layout') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
try {
const view = normalizeDiagramView(url.searchParams.get('view'));
const updatedAt = getIsoNow();
const store = readDiagramLayoutStore(cfg);
store.updatedAt = updatedAt;
store.views[view] = {};
writeDiagramLayoutStore(cfg, store, [view]);
const revisionId = snapshotDiagramLayoutRevision(cfg, {
view,
updatedAt,
layout: {},
action: 'reset',
});
sendJson(res, 200, { ok: true, view, updatedAt, revisionId }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
if (req.method === 'GET' && pathname === '/v1/diagram/layout/history') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
try {
const view = normalizeDiagramView(url.searchParams.get('view'));
const revisions = readDiagramLayoutHistory(cfg, view, url.searchParams.get('limit'));
sendJson(
res,
200,
{
ok: true,
view,
currentRevisionId: readCurrentDiagramLayoutRevisionId(cfg, view),
revisions,
},
cfg.corsOrigin
);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
if (req.method === 'POST' && pathname === '/v1/diagram/layout/restore') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
try {
const body = await readBodyJson(req, { maxBytes: 64 * 1024 });
const view = normalizeDiagramView(body.view || url.searchParams.get('view'));
const revisionId = Number.parseInt(String(body.revisionId || ''), 10);
if (!Number.isInteger(revisionId) || revisionId <= 0) {
sendJson(res, 400, { ok: false, error: 'invalid_revision_id' }, cfg.corsOrigin);
return;
}
const revision = readDiagramLayoutRevision(cfg, view, revisionId);
if (!revision) {
sendJson(res, 404, { ok: false, error: 'revision_not_found' }, cfg.corsOrigin);
return;
}
const updatedAt = getIsoNow();
const store = readDiagramLayoutStore(cfg);
store.updatedAt = updatedAt;
store.views[view] = revision.layout;
writeDiagramLayoutStore(cfg, store, [view]);
const restoredRevisionId = snapshotDiagramLayoutRevision(cfg, {
view,
updatedAt,
layout: revision.layout,
action: 'restore',
});
sendJson(
res,
200,
{
ok: true,
view,
updatedAt,
layout: revision.layout,
revisionId: restoredRevisionId,
restoredFromRevisionId: revisionId,
},
cfg.corsOrigin
);
} catch (err) {
const msg = String(err?.message || err);
const status = msg === 'payload_too_large' ? 413 : msg === 'invalid_json' ? 400 : 500;
sendJson(res, status, { ok: false, error: msg }, cfg.corsOrigin);
}
return;
}
if (req.method === 'POST' && pathname === '/v1/diagram/debug-event') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
try {
const body = await readBodyJson(req, { maxBytes: 64 * 1024 });
const event = {
ts: getIsoNow(),
view: normalizeDiagramView(body.view),
event: String(body.event || 'unknown').slice(0, 120),
nodeId: body.nodeId == null ? undefined : String(body.nodeId),
viewport:
body.viewport && typeof body.viewport === 'object'
? {
x: Number.isFinite(Number(body.viewport.x)) ? Number(body.viewport.x) : undefined,
y: Number.isFinite(Number(body.viewport.y)) ? Number(body.viewport.y) : undefined,
zoom: Number.isFinite(Number(body.viewport.zoom)) ? Number(body.viewport.zoom) : undefined,
}
: undefined,
payload: body.payload && typeof body.payload === 'object' ? body.payload : undefined,
};
console.log('[diagram-debug]', JSON.stringify(event));
appendDiagramDebugEvent(cfg, event);
sendJson(res, 200, { ok: true, loggedAt: event.ts }, cfg.corsOrigin);
} catch (err) {
const msg = String(err?.message || err);
const status = msg === 'payload_too_large' ? 413 : msg === 'invalid_json' ? 400 : 500;
sendJson(res, status, { ok: false, error: msg }, cfg.corsOrigin);
}
return;
}
if (req.method === 'GET' && pathname === '/v1/chart') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
const symbol = (url.searchParams.get('symbol') || '').trim();
const source = (url.searchParams.get('source') || '').trim();
const tf = (url.searchParams.get('tf') || url.searchParams.get('timeframe') || '1m').trim();
const limit = clampInt(url.searchParams.get('limit') || '300', 10, 2000);
if (!symbol) {
sendJson(res, 400, { ok: false, error: 'missing_symbol' }, cfg.corsOrigin);
return;
}
let bucketSeconds;
try {
bucketSeconds = parseTimeframeToSeconds(tf);
} catch {
sendJson(res, 400, { ok: false, error: 'invalid_tf' }, cfg.corsOrigin);
return;
}
const fn = cfg.candlesFunction;
const query = `
query Candles($symbol: String!, $bucket: Int!, $limit: Int!, $source: String) {
${fn}(args: { p_symbol: $symbol, p_bucket_seconds: $bucket, p_limit: $limit, p_source: $source }) {
bucket
open
high
low
close
oracle_close
ticks
}
}
`;
try {
const data = await hasuraRequest(cfg, { admin: true }, query, {
symbol,
bucket: bucketSeconds,
limit,
source: source || null,
});
const rows = data?.[fn] || [];
const candles = rows
.slice()
.reverse()
.map((r) => {
const time = Math.floor(Date.parse(r.bucket) / 1000);
const open = Number(r.open);
const high = Number(r.high);
const low = Number(r.low);
const close = Number(r.close);
const oracle = r.oracle_close == null ? null : Number(r.oracle_close);
const volume = Number(r.ticks || 0);
return { time, open, high, low, close, volume, oracle };
})
.filter((c) => Number.isFinite(c.time) && Number.isFinite(c.open) && Number.isFinite(c.close));
// Flow = share of time spent moving up/down/flat inside each bucket.
// Used by the UI to render stacked volume bars describing microstructure.
const nowSec = Math.floor(Date.now() / 1000);
const windowSeconds = bucketSeconds * candles.length;
const canComputeFlow = candles.length > 0 && windowSeconds > 0 && windowSeconds <= 86_400; // cap at 24h
if (canComputeFlow) {
const firstStart = candles[0].time;
const lastStart = candles[candles.length - 1].time;
const fromIso = new Date(firstStart * 1000).toISOString();
const toIso = new Date((lastStart + bucketSeconds) * 1000).toISOString();
const table = cfg.ticksTable;
const visibleStarts = new Set(candles.map((c) => c.time));
const pointsByCandle = new Map();
const maxTicks = Math.min(200_000, Math.max(5_000, windowSeconds * 20));
const wantSource = Boolean(source && source.trim());
const vars = wantSource
? { symbol, from: fromIso, to: toIso, limit: maxTicks, source: source.trim() }
: { symbol, from: fromIso, to: toIso, limit: maxTicks };
const query = `
query FlowTicks($symbol: String!, $from: timestamptz!, $to: timestamptz!, $limit: Int!${wantSource ? ', $source: String!' : ''}) {
${table}(
where: {symbol: {_eq: $symbol}, ts: {_gte: $from, _lt: $to}${wantSource ? ', source: {_eq: $source}' : ''}}
order_by: {ts: asc}
limit: $limit
) {
ts
oracle_price
mark_price
}
}
`;
try {
const tickData = await hasuraRequest(cfg, { admin: true }, query, vars);
const ticks = tickData?.[table] || [];
for (const r of ticks) {
const t = tsToUnixSeconds(r.ts);
if (t == null) continue;
const p = parseNumeric(r.mark_price) ?? parseNumeric(r.oracle_price);
if (p == null) continue;
const idx = Math.floor((t - firstStart) / bucketSeconds);
const start = firstStart + idx * bucketSeconds;
if (!visibleStarts.has(start)) continue;
const list = pointsByCandle.get(start) || [];
list.push({ t, p });
pointsByCandle.set(start, list);
}
const lastCandleTime = candles[candles.length - 1]?.time ?? null;
for (const c of candles) {
const pts = pointsByCandle.get(c.time) || [];
const isCurrent = lastCandleTime != null && c.time === lastCandleTime;
c.flow = computeCandleFlowFromTicks({ candle: c, bucketSeconds, points: pts, nowSec, isCurrent });
const rows = Math.min(60, Math.max(12, Math.floor(bucketSeconds)));
const slices = computeCandleFlowSlicesFromTicks({
candle: c,
bucketSeconds,
points: pts,
rows,
nowSec,
isCurrent,
});
c.flowRows = slices.dirs;
c.flowMoves = slices.moves;
}
} catch {
for (const c of candles) {
const fallback = flowFromDelta(c.close - c.open);
c.flow = fallback;
const rows = Math.min(60, Math.max(12, Math.floor(bucketSeconds)));
const dir = fallback.up ? 1 : fallback.down ? -1 : 0;
c.flowRows = new Array(rows).fill(dir);
c.flowMoves = new Array(rows).fill(0);
}
}
} else {
for (const c of candles) {
const fallback = flowFromDelta(c.close - c.open);
c.flow = fallback;
const rows = Math.min(60, Math.max(12, Math.floor(bucketSeconds)));
const dir = fallback.up ? 1 : fallback.down ? -1 : 0;
c.flowRows = new Array(rows).fill(dir);
c.flowMoves = new Array(rows).fill(0);
}
}
const times = candles.map((c) => c.time);
const closes = candles.map((c) => c.close);
const oracleSeries = toSeries(times, candles.map((c) => (c.oracle == null ? null : c.oracle)));
const sma20 = toSeries(times, sma(closes, 20));
const ema20 = toSeries(times, ema(closes, 20));
const bb = bollingerBands(closes, 20, 2);
const bbUpper = toSeries(times, bb.upper);
const bbLower = toSeries(times, bb.lower);
const bbMid = toSeries(times, bb.mid);
const rsi14 = toSeries(times, rsi(closes, 14));
const macdOut = macd(closes, 12, 26, 9);
const macdLine = toSeries(times, macdOut.macd);
const macdSignal = toSeries(times, macdOut.signal);
sendJson(
res,
200,
{
ok: true,
version: cfg.appVersion,
buildTimestamp: cfg.buildTimestamp,
ticksTable: cfg.ticksTable,
candlesFunction: cfg.candlesFunction,
symbol,
source: source || null,
tf,
bucketSeconds,
candles,
indicators: {
oracle: oracleSeries,
sma20,
ema20,
bb20: { upper: bbUpper, lower: bbLower, mid: bbMid },
rsi14,
macd: { macd: macdLine, signal: macdSignal },
},
},
cfg.corsOrigin
);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
if (req.method === 'POST' && pathname === '/v1/ingest/tick') {
const auth = await requireValidToken(cfg, req, 'write');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
let body;
try {
body = await readBodyJson(req, { maxBytes: 1024 * 1024 });
} catch (err) {
const msg = String(err?.message || err);
if (msg === 'payload_too_large') {
sendJson(res, 413, { ok: false, error: 'payload_too_large' }, cfg.corsOrigin);
return;
}
sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin);
return;
}
let tick;
try {
tick = normalizeTick(body, auth.token);
} catch (err) {
sendJson(res, 400, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
return;
}
try {
const id = await insertTick(cfg, tick);
sendJson(res, 200, { ok: true, id }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
if (req.method === 'GET' && pathname === '/v1/ticks') {
const auth = await requireValidToken(cfg, req, 'read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
const symbol = url.searchParams.get('symbol') || '';
const source = url.searchParams.get('source');
const limitRaw = url.searchParams.get('limit') || '1000';
const limit = Math.min(5000, Math.max(1, Number.parseInt(limitRaw, 10) || 1000));
const from = url.searchParams.get('from');
const to = url.searchParams.get('to');
if (!symbol.trim()) {
sendJson(res, 400, { ok: false, error: 'missing_symbol' }, cfg.corsOrigin);
return;
}
const where = { symbol: { _eq: symbol.trim() } };
if (source && source.trim()) where.source = { _eq: source.trim() };
if (from || to) {
where.ts = {};
if (from) where.ts._gte = from;
if (to) where.ts._lte = to;
}
const table = cfg.ticksTable;
const query = `
query Ticks($where: ${table}_bool_exp!, $limit: Int!) {
${table}(where: $where, order_by: {ts: desc}, limit: $limit) {
ts
market_index
symbol
oracle_price
mark_price
oracle_slot
source
}
}
`;
try {
const data = await hasuraRequest(cfg, { admin: true }, query, { where, limit });
const ticks = (data?.[table] || []).slice().reverse();
sendJson(res, 200, { ok: true, version: cfg.appVersion, ticksTable: cfg.ticksTable, ticks }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
// Bot control-plane (store desired state in DB; executor reads from DB).
// Endpoints intentionally DO NOT "sign & send tx".
if (segments[0] === 'v1' && segments[1] === 'bots') {
// GET /v1/bots
if (req.method === 'GET' && segments.length === 2) {
const auth = await requireValidToken(cfg, req, 'bots:read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
const limit = clampInt(url.searchParams.get('limit') || '100', 1, 500);
const offset = clampInt(url.searchParams.get('offset') || '0', 0, 50_000);
const query = `
query Bots($limit: Int!, $offset: Int!) {
bot_config(order_by: {updated_at: desc}, limit: $limit, offset: $offset) {
id
name
market_name
market_type
mode
kill_switch
params
created_at
updated_at
}
}
`;
try {
const data = await hasuraRequest(cfg, { admin: true }, query, { limit, offset });
sendJson(res, 200, { ok: true, bots: data?.bot_config || [] }, cfg.corsOrigin);
} catch (err) {
if (isBotControlPlaneSchemaMissing(err)) {
sendJson(
res,
200,
{
ok: true,
bots: [],
warning: 'bot_control_plane_schema_missing',
},
cfg.corsOrigin
);
return;
}
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
// POST /v1/bots (create)
if (req.method === 'POST' && segments.length === 2) {
const auth = await requireValidToken(cfg, req, 'bots:write');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
let body;
try {
body = await readBodyJson(req, { maxBytes: 1024 * 1024 });
} catch {
sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin);
return;
}
const name = (body?.name || '')?.toString?.().trim();
const market_name = (body?.market_name || body?.marketName || '')?.toString?.().trim();
const market_type = (body?.market_type || body?.marketType || 'perp')?.toString?.().trim() || 'perp';
const kill_switch = Boolean(body?.kill_switch ?? body?.killSwitch ?? false);
let mode;
try {
mode = normalizeBotMode(body?.mode) || 'off';
} catch (err) {
sendJson(res, 400, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
return;
}
if (!name) {
sendJson(res, 400, { ok: false, error: 'missing_name' }, cfg.corsOrigin);
return;
}
if (!market_name) {
sendJson(res, 400, { ok: false, error: 'missing_market_name' }, cfg.corsOrigin);
return;
}
const now = new Date().toISOString();
const object = {
name,
market_name,
market_type,
mode,
kill_switch,
params: body?.params && typeof body.params === 'object' ? body.params : null,
updated_at: now,
};
const mutation = `
mutation CreateBot($object: bot_config_insert_input!) {
insert_bot_config_one(object: $object) {
id
name
market_name
market_type
mode
kill_switch
created_at
updated_at
}
}
`;
try {
const data = await hasuraRequest(cfg, { admin: true }, mutation, { object });
const row = data?.insert_bot_config_one;
if (!row?.id) throw new Error('bot_create_failed');
sendJson(res, 200, { ok: true, bot: row }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
// Endpoints with bot id.
const botId = segments[2];
if (segments.length >= 3 && !isUuidLike(botId)) {
sendJson(res, 400, { ok: false, error: 'invalid_bot_id' }, cfg.corsOrigin);
return;
}
// GET /v1/bots/:id
if (req.method === 'GET' && segments.length === 3) {
const auth = await requireValidToken(cfg, req, 'bots:read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
const query = `
query Bot($id: uuid!) {
bot_config_by_pk(id: $id) {
id
name
market_name
market_type
mode
kill_switch
params
created_at
updated_at
}
}
`;
try {
const data = await hasuraRequest(cfg, { admin: true }, query, { id: botId });
const bot = data?.bot_config_by_pk;
if (!bot?.id) {
sendJson(res, 404, { ok: false, error: 'bot_not_found' }, cfg.corsOrigin);
return;
}
sendJson(res, 200, { ok: true, bot }, cfg.corsOrigin);
} catch (err) {
if (isBotControlPlaneSchemaMissing(err)) {
sendJson(res, 404, { ok: false, error: 'bot_control_plane_schema_missing' }, cfg.corsOrigin);
return;
}
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
// PATCH /v1/bots/:id (partial update) (POST also supported for non-rest clients)
if ((req.method === 'POST' || req.method === 'PATCH') && segments.length === 3) {
const auth = await requireValidToken(cfg, req, 'bots:write');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
let body;
try {
body = await readBodyJson(req, { maxBytes: 1024 * 1024 });
} catch {
sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin);
return;
}
const set = {};
if (body?.name != null) set.name = String(body.name).trim();
if (body?.market_name != null || body?.marketName != null) {
set.market_name = String(body.market_name ?? body.marketName).trim();
}
if (body?.market_type != null || body?.marketType != null) {
set.market_type = String(body.market_type ?? body.marketType).trim() || 'perp';
}
if (body?.mode != null) {
try {
set.mode = normalizeBotMode(body.mode);
} catch (err) {
sendJson(res, 400, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
return;
}
}
if (body?.kill_switch != null || body?.killSwitch != null) {
set.kill_switch = Boolean(body.kill_switch ?? body.killSwitch);
}
if (body?.params != null) {
set.params = body.params && typeof body.params === 'object' ? body.params : null;
}
// Clean up empty strings.
if (typeof set.name === 'string' && !set.name) delete set.name;
if (typeof set.market_name === 'string' && !set.market_name) delete set.market_name;
if (!Object.keys(set).length) {
sendJson(res, 400, { ok: false, error: 'no_changes' }, cfg.corsOrigin);
return;
}
set.updated_at = new Date().toISOString();
const mutation = `
mutation UpdateBot($id: uuid!, $set: bot_config_set_input!) {
update_bot_config_by_pk(pk_columns: {id: $id}, _set: $set) {
id
name
market_name
market_type
mode
kill_switch
params
created_at
updated_at
}
}
`;
try {
const data = await hasuraRequest(cfg, { admin: true }, mutation, { id: botId, set });
const bot = data?.update_bot_config_by_pk;
if (!bot?.id) {
sendJson(res, 404, { ok: false, error: 'bot_not_found' }, cfg.corsOrigin);
return;
}
sendJson(res, 200, { ok: true, bot }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
// POST /v1/bots/:id/mode
if (req.method === 'POST' && segments.length === 4 && segments[3] === 'mode') {
const auth = await requireValidToken(cfg, req, 'bots:write');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
let body;
try {
body = await readBodyJson(req, { maxBytes: 1024 * 1024 });
} catch {
sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin);
return;
}
let mode;
try {
mode = normalizeBotMode(body?.mode);
} catch (err) {
sendJson(res, 400, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
return;
}
if (!mode) {
sendJson(res, 400, { ok: false, error: 'missing_mode' }, cfg.corsOrigin);
return;
}
const mutation = `
mutation SetMode($id: uuid!, $mode: String!, $ts: timestamptz!) {
update_bot_config_by_pk(pk_columns: {id: $id}, _set: {mode: $mode, updated_at: $ts}) {
id
mode
updated_at
}
}
`;
try {
const ts = new Date().toISOString();
const data = await hasuraRequest(cfg, { admin: true }, mutation, { id: botId, mode, ts });
const bot = data?.update_bot_config_by_pk;
if (!bot?.id) {
sendJson(res, 404, { ok: false, error: 'bot_not_found' }, cfg.corsOrigin);
return;
}
sendJson(res, 200, { ok: true, bot }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
// POST /v1/bots/:id/kill-switch
if (req.method === 'POST' && segments.length === 4 && segments[3] === 'kill-switch') {
const auth = await requireValidToken(cfg, req, 'bots:write');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
let body;
try {
body = await readBodyJson(req, { maxBytes: 1024 * 1024 });
} catch {
sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin);
return;
}
const enabled = body?.enabled;
if (typeof enabled !== 'boolean') {
sendJson(res, 400, { ok: false, error: 'missing_enabled' }, cfg.corsOrigin);
return;
}
const mutation = `
mutation SetKillSwitch($id: uuid!, $kill: Boolean!, $ts: timestamptz!) {
update_bot_config_by_pk(pk_columns: {id: $id}, _set: {kill_switch: $kill, updated_at: $ts}) {
id
kill_switch
updated_at
}
}
`;
try {
const ts = new Date().toISOString();
const data = await hasuraRequest(cfg, { admin: true }, mutation, { id: botId, kill: enabled, ts });
const bot = data?.update_bot_config_by_pk;
if (!bot?.id) {
sendJson(res, 404, { ok: false, error: 'bot_not_found' }, cfg.corsOrigin);
return;
}
sendJson(res, 200, { ok: true, bot }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
// GET /v1/bots/:id/state
if (req.method === 'GET' && segments.length === 4 && segments[3] === 'state') {
const auth = await requireValidToken(cfg, req, 'bots:read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
const query = `
query BotState($botId: uuid!) {
bot_state_by_pk(bot_id: $botId) {
bot_id
last_heartbeat_at
last_action_at
last_error
state
updated_at
}
}
`;
try {
const data = await hasuraRequest(cfg, { admin: true }, query, { botId });
sendJson(res, 200, { ok: true, state: data?.bot_state_by_pk ?? null }, cfg.corsOrigin);
} catch (err) {
if (isBotControlPlaneSchemaMissing(err)) {
sendJson(res, 200, { ok: true, state: null, warning: 'bot_control_plane_schema_missing' }, cfg.corsOrigin);
return;
}
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
// GET /v1/bots/:id/events
if (req.method === 'GET' && segments.length === 4 && segments[3] === 'events') {
const auth = await requireValidToken(cfg, req, 'bots:read');
if (!auth.ok) {
sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin);
return;
}
const limit = clampInt(url.searchParams.get('limit') || '200', 1, 1000);
const query = `
query BotEvents($botId: uuid!, $limit: Int!) {
bot_events(where: {bot_id: {_eq: $botId}}, order_by: {ts: desc}, limit: $limit) {
id
ts
type
payload
}
}
`;
try {
const data = await hasuraRequest(cfg, { admin: true }, query, { botId, limit });
sendJson(res, 200, { ok: true, events: data?.bot_events || [] }, cfg.corsOrigin);
} catch (err) {
if (isBotControlPlaneSchemaMissing(err)) {
sendJson(res, 200, { ok: true, events: [], warning: 'bot_control_plane_schema_missing' }, cfg.corsOrigin);
return;
}
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
}
if (req.method === 'POST' && pathname === '/v1/admin/tokens') {
if (!isAdmin(cfg, req)) {
sendJson(res, 401, { ok: false, error: 'admin_unauthorized' }, cfg.corsOrigin);
return;
}
let body;
try {
body = await readBodyJson(req, { maxBytes: 1024 * 1024 });
} catch {
sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin);
return;
}
const name = (body?.name || 'algo')?.toString?.().trim();
if (!name) {
sendJson(res, 400, { ok: false, error: 'missing_name' }, cfg.corsOrigin);
return;
}
const scopes = normalizeScopes(body?.scopes);
const resolvedScopes = scopes.length ? scopes : ['write'];
try {
const { token, row } = await createApiToken(cfg, name, resolvedScopes, body?.meta);
sendJson(res, 200, { ok: true, token, id: row.id, name: row.name, created_at: row.created_at }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
if (req.method === 'POST' && pathname === '/v1/admin/tokens/revoke') {
if (!isAdmin(cfg, req)) {
sendJson(res, 401, { ok: false, error: 'admin_unauthorized' }, cfg.corsOrigin);
return;
}
let body;
try {
body = await readBodyJson(req, { maxBytes: 1024 * 1024 });
} catch {
sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin);
return;
}
const id = (body?.id || '')?.toString?.().trim();
if (!id) {
sendJson(res, 400, { ok: false, error: 'missing_id' }, cfg.corsOrigin);
return;
}
try {
const revokedId = await revokeApiToken(cfg, id);
sendJson(res, 200, { ok: true, id: revokedId }, cfg.corsOrigin);
} catch (err) {
sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin);
}
return;
}
sendJson(res, 404, { ok: false, error: 'not_found' }, cfg.corsOrigin);
}
function main() {
const cfg = resolveConfig();
const server = http.createServer((req, res) => void handler(cfg, req, res));
server.listen(cfg.port, () => {
console.log(
JSON.stringify(
{
service: 'trade-api',
version: cfg.appVersion,
buildTimestamp: cfg.buildTimestamp,
port: cfg.port,
hasuraUrl: cfg.hasuraUrl,
ticksTable: cfg.ticksTable,
candlesFunction: cfg.candlesFunction,
hasuraAdminSecret: cfg.hasuraAdminSecret ? '***' : undefined,
apiAdminSecret: cfg.apiAdminSecret ? '***' : undefined,
},
null,
2
)
);
});
}
main();