From 60c84ca7767412c50278886b1b5dd793a0a1bd5e Mon Sep 17 00:00:00 2001 From: u1 Date: Tue, 6 Jan 2026 12:33:47 +0100 Subject: [PATCH] chore: initial import --- .gitignore | 5 + Dockerfile | 11 + README.md | 16 + services/api/server.mjs | 814 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 846 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 services/api/server.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7459212 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +tokens/*.json +tokens/*.yml +tokens/*.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f9967a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-slim + +WORKDIR /app + +COPY services/api/server.mjs /app/services/api/server.mjs +RUN mkdir -p /app/tokens + +ENV NODE_ENV=production +EXPOSE 8787 + +CMD ["node", "services/api/server.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..005277f --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# trade-api + +Node.js API dla projektu `trade`. + +## Uruchomienie lokalnie + +```bash +PORT=8787 HASURA_GRAPHQL_URL=http://localhost:8080/v1/graphql node services/api/server.mjs +``` + +## Docker + +```bash +docker build -t trade-api . +docker run --rm -p 8787:8787 trade-api +``` diff --git a/services/api/server.mjs b/services/api/server.mjs new file mode 100644 index 0000000..ce32308 --- /dev/null +++ b/services/api/server.mjs @@ -0,0 +1,814 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +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'); + +function readJsonFile(filePath) { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw); + } catch { + return undefined; + } +} + +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,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' + ); + + return { + port, + hasuraUrl, + hasuraAdminSecret, + apiAdminSecret, + corsOrigin, + appVersion, + buildTimestamp, + startedAt, + ticksTable, + candlesFunction, + }; +} + +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; +} + +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 []; +} + +async function requireValidToken(cfg, req, requiredScope) { + 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 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; +} + +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; + + 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/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)); + + 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; + } + + 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();