Files
2026-10-29 15:24:06 +01:00

505 lines
23 KiB
JavaScript

// Generated, do not edit. sha256:30f785c6890a03397971aad68496979bd9657deab8cb4a7a61fd0569a7cf87a8
import { randomUUID, createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readSecretsStore, writeSecretsStore } from './keys.mjs';
import { installDir } from './portfolios.mjs';
import { parseLicense, verifySignature } from './license.mjs';
import { recordIntegrationCall, countServiceCall } from './data/integration-log.js';
import { CONFIG } from '../shared/config.js';
import { AppError, fail } from '../shared/app-error.js';
const LEGACY_STORE_KEY = 'market_service';
const RETRY_LATER = new Set([
'marketService.unreachable', 'marketService.timeout', 'marketService.unavailable',
'marketService.monthUnreachable', 'marketService.tooManyRequests',
]);
export const isRetryLater = (e) => !!e && RETRY_LATER.has(e.key);
function normalizeUrl(input) {
const url = String(input || '').trim().replace(/\/+$/, '');
if (!/^https?:\/\/[^/\s]+/i.test(url)) {
fail('marketService.urlScheme', null, 'The address of the service has to start with http:// or https://.');
}
return url;
}
const settingsFile = () => join(installDir(), CONFIG.MARKET_SERVICE.SETTINGS_FILE_NAME);
async function writeSettings(settings) {
await mkdir(installDir(), { recursive: true });
await writeFile(settingsFile(), `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
}
async function readSettings() {
let own = null;
try { own = JSON.parse(await readFile(settingsFile(), 'utf8')); }
catch (e) { if (e.code !== 'ENOENT') throw e; }
const secrets = await readSecretsStore();
const old = secrets[LEGACY_STORE_KEY];
if (old) {
if (!own) {
own = old.token ? { token: old.token } : {};
await writeSettings(own);
}
delete secrets[LEGACY_STORE_KEY];
await writeSecretsStore(secrets);
}
return own ?? {};
}
function serviceUrl() {
const override = process.env.ASSETAPP_SERVICE_URL;
return override ? normalizeUrl(override) : CONFIG.MARKET_SERVICE.URL;
}
const FREE_LICENSE = join(dirname(fileURLToPath(import.meta.url)), CONFIG.LICENSE.FREE_FILE_NAME);
function registrationLicense() {
for (const file of [join(installDir(), CONFIG.LICENSE.FILE_NAME), FREE_LICENSE]) {
let text;
try { text = readFileSync(file, 'utf8'); } catch { continue; }
try { if (verifySignature(parseLicense(text))) return text; } catch { }
}
return null;
}
const hashOf = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
export async function marketServiceStatus() {
const cur = await readSettings();
return {
url: serviceUrl(),
override: !!process.env.ASSETAPP_SERVICE_URL,
token_set: !!cur.token,
token_source: !cur.token ? null : cur.licenseHash ? 'license' : 'manual',
tier: cur.tier ?? null,
can_register: registrationLicense() !== null,
};
}
const SUPPORT_REFUSALS = {
'support.empty': 'marketService.supportEmpty',
'support.tooLong': 'marketService.supportTooLong',
'support.attachmentEmpty': 'marketService.supportAttachmentType',
'support.attachmentType': 'marketService.supportAttachmentType',
'support.attachmentTooLarge': 'marketService.supportAttachmentTooLarge',
'support.tooMany': 'marketService.supportTooMany',
};
const SUPPORT_PARAMS = {
'marketService.supportTooLong': { max: CONFIG.LICENSE.SUPPORT.MAX_TEXT },
'marketService.supportAttachmentTooLarge': { mb: CONFIG.LICENSE.SUPPORT.MAX_ATTACHMENT_BYTES / 1024 / 1024 },
};
function serviceError(status, json, headers) {
const key = json && typeof json.key === 'string' ? json.key : null;
const reason = (json && typeof json.error === 'string' && json.error) || `HTTP ${status}`;
if (status === 401 && key === 'api.badAuth') {
return new AppError('marketService.signatureRefused', { reason }, `The service refuses the signature: ${reason}`);
}
if (status === 401) {
return new AppError('marketService.unauthorized', null, 'The service refuses the token: it is missing, wrong or revoked.');
}
if (status === 503 && key === 'api.salesClosed') {
return new AppError('marketService.salesClosed', null, 'Nothing is for sale right now.');
}
if (status === 503 && key === 'api.checkoutUnavailable') {
return new AppError('marketService.checkoutUnavailable', null, 'The checkout of the service cannot make an invoice now; try again later.');
}
if (status === 429 && key === 'api.tooManyOrders') {
return new AppError('marketService.tooManyOrders', { reason }, `The service takes no more orders for this key now: ${reason}`);
}
if (status === 403 && key === 'api.licenseRevoked') {
return new AppError('marketService.licenseRevoked', null, 'The service has revoked this licence and gives it no token.');
}
if (status === 400 && key === 'api.badLicense') {
return new AppError('marketService.badLicense', { reason }, `The service does not accept this licence: ${reason}`);
}
if (status === 404 && key === 'api.unknownOrder') {
return new AppError('marketService.unknownOrder', null, 'The service does not know this order (any more).');
}
const support = SUPPORT_REFUSALS[key];
if (support) return new AppError(support, SUPPORT_PARAMS[support] ?? null, `The service refuses the message: ${reason}`);
if (status === 404 && key === 'api.unknownAttachment') {
return new AppError('marketService.unknownAttachment', null, 'The service does not know this screenshot.');
}
if (status === 429) {
const s = Number(headers?.get?.('retry-after')) || 60;
return new AppError('marketService.tooManyRequests', { s }, `Too many requests to the service; try again in ${s}s.`);
}
if (status === 503 && key === 'api.monthUnreachable') {
const month = (json && json.month) || '?';
return new AppError('marketService.monthUnreachable', { month },
`The service cannot read month ${month} right now; try again later.`);
}
if (status === 503) {
return new AppError('marketService.unavailable', null, 'The service is not ready (it may be starting); try again later.');
}
return new AppError('marketService.rejected', { status, key: key || '-', reason },
`The service refused the request (HTTP ${status}, ${key || 'no key'}): ${reason}`);
}
function describeBody(res, text) {
const type = String(res.headers?.get?.('content-type') || 'no type').split(';')[0].trim();
return `no JSON (${type}, ${Buffer.byteLength(text || '', 'utf8')} bytes)`;
}
const appJournal = {
failure: (entry, secrets) => recordIntegrationCall(entry, { secrets }),
count: (c) => countServiceCall(c),
};
const COUNTED = { '/v1/spot': 'spot', '/v1/series': 'daily_history' };
export function createMarketClient({
fetch: doFetch = (...a) => globalThis.fetch(...a),
config = readConfig,
renewOn401 = false,
timeoutMs = CONFIG.MARKET_SERVICE.REQUEST_TIMEOUT_MS,
now = Date.now,
journal = appJournal,
requestId: newRequestId = randomUUID,
} = {}) {
async function note(fn) {
if (!journal) return;
try { await fn(); } catch { }
}
const subjectOf = (params) => (Array.isArray(params?.instruments) ? String(params.instruments.length)
: typeof params?.instrument === 'string' ? params.instrument : null);
async function exchange(path, params = null, opts = {}) {
try {
return await exchangeOnce(path, params, opts, false);
} catch (e) {
const tokenCall = (opts.auth ?? true) && !opts.signature;
if (!(renewOn401 && tokenCall && e?.key === 'marketService.unauthorized')) throw e;
return exchangeOnce(path, params, opts, true);
}
}
async function exchangeOnce(path, params, { auth = true, signature = null, rawBody = null } = {}, renew) {
if (signature) auth = false;
const cfg = await config({ needToken: auth, renew });
const id = newRequestId();
const entry = { target: 'service', operation: path, subject: subjectOf(params), requestId: id, occurredAt: now() };
if (auth && !cfg.token) {
await note(() => journal.failure({ ...entry, outcome: 'refused', errorKey: 'marketService.noToken', requestId: null }, []));
fail('marketService.noToken', null, 'No token set for the market data service.');
}
const u = new URL(cfg.url + path);
const body = Object.fromEntries(Object.entries(params || {}).filter(([, v]) => v !== null && v !== undefined));
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
const t0 = performance.now();
let status = null;
let detail = null;
try {
let res;
try {
res = await doFetch(u.toString(), auth
? {
method: 'POST',
headers: {
Authorization: `Bearer ${cfg.token}`, Accept: 'application/json', 'Content-Type': 'application/json',
'X-Request-Id': id,
},
body: JSON.stringify(body),
signal: ctl.signal,
}
: {
method: rawBody !== null ? 'POST' : 'GET',
headers: {
Accept: 'application/json', 'X-Request-Id': id,
...(signature ? { Authorization: signature } : {}),
...(rawBody !== null ? { 'Content-Type': 'application/json' } : {}),
},
...(rawBody !== null ? { body: rawBody } : {}),
signal: ctl.signal,
});
} catch (e) {
if (ctl.signal.aborted) {
const s = timeoutMs / 1000;
throw new AppError('marketService.timeout', { s }, `The service did not answer within ${s}s.`);
}
const reason = e?.cause?.code || e?.cause?.message || e?.message || String(e);
detail = reason;
throw new AppError('marketService.unreachable', { url: cfg.url, reason },
`The service at ${cfg.url} is unreachable: ${reason}.`);
}
status = res.status;
const text = await res.text().catch(() => '');
let json = null;
try { json = JSON.parse(text); } catch { detail = describeBody(res, text); }
const back = res.headers?.get?.('x-request-id');
if (back && back !== id) detail = [detail, `service request id ${back}`].filter(Boolean).join('; ');
if (!res.ok) {
if (json && typeof json.error === 'string') detail = [json.error, detail].filter(Boolean).join('; ');
throw serviceError(res.status, json, res.headers);
}
if (!json || typeof json !== 'object') {
detail ??= 'not an object';
fail('marketService.badAnswer', { url: cfg.url }, `The answer from ${cfg.url} is not the API of the service.`);
}
if (COUNTED[path]) await note(() => journal.count({ capability: COUNTED[path], ok: true, latencyMs: performance.now() - t0 }));
return { json, requestId: id };
} catch (e) {
e.requestId ??= id;
const latencyMs = performance.now() - t0;
const serviceKey = e?.params?.key && e.params.key !== '-' ? ` ${e.params.key}` : '';
await note(() => journal.failure({
...entry, outcome: 'fail', httpStatus: status, errorKey: `${e?.key || 'unknown'}${serviceKey}`,
detail: detail ?? e?.params?.reason ?? null, latencyMs,
}, [cfg.token, signature].filter(Boolean)));
if (COUNTED[path]) await note(() => journal.count({ capability: COUNTED[path], ok: false, latencyMs }));
throw e;
} finally {
clearTimeout(timer);
}
}
const request = async (...a) => (await exchange(...a)).json;
const noteEmpty = (path, params, requestId) => note(() => journal.failure({
target: 'service', operation: path, subject: subjectOf(params), outcome: 'empty', httpStatus: 200,
requestId, occurredAt: now(),
}, []));
async function instruments() {
const json = await request('/v1/instruments');
if (!Array.isArray(json.instruments)) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The register in the answer is not a list.');
}
return json.instruments;
}
async function testConnection() {
const { url } = await config();
const health = await request('/v1/health', null, { auth: false });
if (health.ok !== true || health.app !== CONFIG.MARKET_SERVICE.APP_NAME) {
fail('marketService.badAnswer', { url }, `The answer from ${url} is not the API of the service.`);
}
const list = await instruments();
return { url, app: health.app, version: health.version || null, instruments: list.length };
}
let cached = null;
async function register() {
const { url } = await config();
if (cached && cached.url === url && now() - cached.at < CONFIG.MARKET_SERVICE.REGISTER_TTL_MS) return cached.list;
const list = await instruments();
cached = { url, at: now(), list };
return list;
}
async function series({ instrument, quote, resolution = 'minute', from, to }) {
const points = [];
let meta = null;
let cursor = from;
let rid = null;
for (let page = 0; page < CONFIG.MARKET_SERVICE.MAX_SERIES_PAGES; page++) {
const { json, requestId } = await exchange('/v1/series', { instrument, quote, resolution, from: cursor, to });
rid = requestId;
const cols = Array.isArray(json.columns) ? json.columns : [];
const ti = cols.indexOf('t');
const pi = cols.includes('price') ? cols.indexOf('price') : cols.indexOf('close');
const si = cols.indexOf('source');
if (ti < 0 || pi < 0 || !Array.isArray(json.rows) || typeof json.currency !== 'string' || !json.currency) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The series in the answer has no time, price or currency.');
}
if (meta && json.currency !== meta.currency) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The currency changed between two pages of one series.');
}
meta ??= { instrument, currency: json.currency, source: json.source || null, sourceLabel: json.sourceLabel || null,
resolution: json.resolution || resolution, sourceLabels: {} };
if (json.sourceLabels && typeof json.sourceLabels === 'object') {
for (const [key, label] of Object.entries(json.sourceLabels)) if (typeof label === 'string' && label) meta.sourceLabels[key] = label;
}
for (const r of json.rows) {
const t = Number(r?.[ti]);
const p = Number(r?.[pi]);
if (!(Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0)) continue;
const src = si >= 0 && typeof r[si] === 'string' && r[si] ? r[si] : null;
points.push(src ? [t, p, src] : [t, p]);
}
if (json.next == null || !(Number(json.next) > cursor)) break;
cursor = Number(json.next);
}
if (!points.length) await noteEmpty('/v1/series', { instrument }, rid);
return { ...meta, points };
}
async function history({ instrument, quote, from, to }) {
const points = [];
let meta = null;
let cursor = from;
let rid = null;
for (let page = 0; page < CONFIG.MARKET_SERVICE.MAX_SERIES_PAGES; page++) {
const { json, requestId } = await exchange('/v1/series', { instrument, quote, kind: 'history', from: cursor, to });
rid = requestId;
const cols = Array.isArray(json.columns) ? json.columns : [];
const [ti, pi, ri, si] = ['t', 'price', 'resolution', 'source'].map(c => cols.indexOf(c));
if (ti < 0 || pi < 0 || ri < 0 || !Array.isArray(json.rows) || typeof json.currency !== 'string' || !json.currency) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The history in the answer has no time, price, resolution or currency.');
}
if (meta && json.currency !== meta.currency) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The currency changed between two pages of one series.');
}
meta ??= { instrument, currency: json.currency, source: json.source || null, sourceLabels: {} };
if (json.sourceLabels && typeof json.sourceLabels === 'object') {
for (const [key, label] of Object.entries(json.sourceLabels)) if (typeof label === 'string' && label) meta.sourceLabels[key] = label;
}
for (const r of json.rows) {
const t = Number(r?.[ti]);
const p = Number(r?.[pi]);
if (!(Number.isFinite(t) && t > 0 && Number.isFinite(p) && p > 0)) continue;
const src = si >= 0 && typeof r[si] === 'string' && r[si] ? r[si] : null;
points.push(src ? [t, p, r[ri], src] : [t, p, r[ri]]);
}
if (json.next == null || !(Number(json.next) > cursor)) break;
cursor = Number(json.next);
}
if (!points.length) await noteEmpty('/v1/series', { instrument }, rid);
return { ...meta, points };
}
let spotCache = null;
async function spots(keys, quote) {
const { url } = await config();
const pakket = [...new Set(keys)].sort();
const sleutel = `${url}|${quote}|${pakket.join(',')}`;
if (spotCache && spotCache.sleutel === sleutel && now() - spotCache.at < CONFIG.MARKET_SERVICE.SPOT_TTL_MS) {
return spotCache.result;
}
const result = { spot: [], missing: [], unreachable: [] };
const max = CONFIG.MARKET_SERVICE.MAX_INSTRUMENTS_PER_SPOT;
let rid = null;
for (let i = 0; i < pakket.length; i += max) {
const { json, requestId } = await exchange('/v1/spot', { instruments: pakket.slice(i, i + max), quote });
rid = requestId;
if (!Array.isArray(json.spot)) {
fail('marketService.badAnswer', { url }, 'The spot answer has no list of prices.');
}
for (const s of json.spot) {
const price = Number(s?.price);
if (typeof s?.instrument !== 'string' || !Number.isFinite(price) || price <= 0 || typeof s.currency !== 'string' || !s.currency) continue;
result.spot.push({
instrument: s.instrument, price, currency: s.currency,
observedAt: Number(s.observedAt) || null, source: s.source || null, sourceLabel: s.sourceLabel || null,
});
}
if (Array.isArray(json.missing)) result.missing.push(...json.missing);
if (Array.isArray(json.unreachable)) result.unreachable.push(...json.unreachable);
}
if (pakket.length && !result.spot.length) await noteEmpty('/v1/spot', { instruments: pakket }, rid);
spotCache = { sleutel, at: now(), result };
return result;
}
async function buyerLicenses(signature) {
const json = await request('/v1/license', null, { signature });
if (!Array.isArray(json.licenses)) {
fail('marketService.badAnswer', { url: (await config()).url }, 'The licences in the answer are not a list.');
}
return json.licenses;
}
async function sale() {
const json = await request('/v1/sale', null, { auth: false });
if (typeof json.open !== 'boolean') fail('marketService.badAnswer', { url: (await config()).url }, 'The sale in the answer has no open state.');
return { open: json.open, priceSat: Number.isInteger(json.priceSat) ? json.priceSat : null, major: json.major ?? null };
}
async function placeOrder(signature, rawBody) {
const json = await request('/v1/orders', null, { signature, rawBody });
if (!json.order || typeof json.order.id !== 'string') fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no order.');
return { order: json.order, readKey: typeof json.readKey === 'string' ? json.readKey : null };
}
async function orderStatus(orderId, readKey) {
const json = await request(`/v1/orders/${orderId}`, null, { signature: `Order ${readKey}` });
if (!json.order) fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no order.');
return { order: json.order, license: json.license ?? null };
}
async function supportMessages(signature) {
const json = await request('/v1/support/messages', null, { signature });
if (!Array.isArray(json.messages)) fail('marketService.badAnswer', { url: (await config()).url }, 'The messages in the answer are not a list.');
return json.messages;
}
async function supportSend(signature, rawBody) {
const json = await request('/v1/support/messages', null, { signature, rawBody });
if (!json.message || typeof json.message !== 'object') fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no message.');
return json.message;
}
async function supportAttachment(signature, id) {
const json = await request(`/v1/support/attachments/${id}`, null, { signature });
if (typeof json.mime !== 'string' || typeof json.data !== 'string') {
fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no screenshot.');
}
return { mime: json.mime, data: json.data };
}
async function registerLicense(text) {
const json = await request('/v1/license/register', null, { auth: false, rawBody: text });
if (typeof json.token !== 'string' || !json.token) fail('marketService.badAnswer', { url: (await config()).url }, 'The answer holds no token.');
return { token: json.token, tier: typeof json.tier === 'string' ? json.tier : null };
}
return {
instruments, register, series, history, spots, testConnection, buyerLicenses, sale, placeOrder, orderStatus,
supportMessages, supportSend, supportAttachment, registerLicense,
};
}
let registering = null;
async function tokenFor({ renew = false } = {}) {
const cur = await readSettings();
const license = registrationLicense();
const hash = license ? hashOf(license) : null;
if (cur.token && !renew && (!cur.licenseHash || !license || cur.licenseHash === hash)) return cur.token;
if (!license) return renew ? '' : (cur.token || '');
registering ??= (async () => {
try {
const { token, tier } = await appClient.registerLicense(license);
await writeSettings({ token, tier, licenseHash: hash });
return token;
} finally {
registering = null;
}
})();
return registering;
}
async function readConfig({ needToken = false, renew = false } = {}) {
return { url: serviceUrl(), token: needToken ? await tokenFor({ renew }) : '' };
}
const appClient = createMarketClient({ renewOn401: true });
export const testMarketService = () => appClient.testConnection();
export const marketRegister = () => appClient.register();
export const marketSeries = (opts) => appClient.series(opts);
export const marketHistory = (opts) => appClient.history(opts);
export const marketSpots = (keys, quote) => appClient.spots(keys, quote);
export const marketBuyerLicenses = (signature) => appClient.buyerLicenses(signature);
export const marketSale = () => appClient.sale();
export const marketPlaceOrder = (signature, rawBody) => appClient.placeOrder(signature, rawBody);
export const marketOrderStatus = (orderId, readKey) => appClient.orderStatus(orderId, readKey);
export const marketSupportMessages = (signature) => appClient.supportMessages(signature);
export const marketSupportSend = (signature, rawBody) => appClient.supportSend(signature, rawBody);
export const marketSupportAttachment = (signature, id) => appClient.supportAttachment(signature, id);
export const marketServiceUrl = async () => (await readConfig()).url;
export const marketConfigured = async () => {
const st = await marketServiceStatus();
return st.token_set || st.can_register;
};