// 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; };