UNPKG

@bsv/overlay-express

Version:
1,106 lines (1,105 loc) 102 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const express_1 = __importDefault(require("express")); const body_parser_1 = __importDefault(require("body-parser")); const overlay_1 = require("@bsv/overlay"); const sdk_1 = require("@bsv/sdk"); const knex_1 = __importDefault(require("knex")); const mongodb_1 = require("mongodb"); const makeUserInterface_js_1 = __importDefault(require("./makeUserInterface.js")); const DiscoveryServices = __importStar(require("@bsv/overlay-discovery-services")); const chalk_1 = __importDefault(require("chalk")); const node_util_1 = __importDefault(require("node:util")); const uuid_1 = require("uuid"); const JanitorService_js_1 = require("./JanitorService.js"); const BanService_js_1 = require("./BanService.js"); const BanAwareLookupWrapper_js_1 = require("./BanAwareLookupWrapper.js"); const BanAwareTopicManager_js_1 = require("./BanAwareTopicManager.js"); const BanAwareDiscoveryStorage_js_1 = require("./BanAwareDiscoveryStorage.js"); const ReorgStream_js_1 = require("./ReorgStream.js"); const wallet_toolbox_client_1 = require("@bsv/wallet-toolbox-client"); const auth_express_middleware_1 = require("@bsv/auth-express-middleware"); const ArcadeProvider_js_1 = require("./ArcadeProvider.js"); const ProviderChainBroadcaster_js_1 = require("./ProviderChainBroadcaster.js"); const ChaintracksProvider_js_1 = require("./ChaintracksProvider.js"); /** * In-memory migration source for Knex migrations. * Allows running migrations defined in code rather than files. */ class InMemoryMigrationSource { constructor(migrations) { this.migrations = migrations; } /** * Gets the list of migrations. * @param loadExtensions - Array of file extensions to filter by (not used here) * @returns Promise resolving to the array of migrations */ async getMigrations(loadExtensions) { return this.migrations; } /** * Gets the name of a migration. * @param migration - The migration object * @returns The name of the migration */ getMigrationName(migration) { return typeof migration.name === 'string' ? migration.name : `Migration at index ${this.migrations.indexOf(migration)}`; } /** * Gets the migration object. * @param migration - The migration object * @returns Promise resolving to the migration object */ async getMigration(migration) { return await Promise.resolve(migration); } } function parseTopicsHeader(header) { const value = header.trim(); const parsed = value.startsWith('[') ? JSON.parse(value) : value.split(',').map(topic => topic.trim()); if (!Array.isArray(parsed) || parsed.some(topic => typeof topic !== 'string' || topic.length === 0)) { throw new TypeError('Invalid x-topics header: expected a comma-separated list or JSON string array'); } return parsed; } /** * OverlayExpress class provides an Express-based server for hosting Overlay Services. * It allows configuration of various components like databases, topic managers, and lookup services. * It encapsulates an Express application and provides methods to start the server. */ class OverlayExpress { /** * Constructs an instance of OverlayExpress. * @param name - The name of the service * @param privateKey - Private key used for signing advertisements * @param advertisableFQDN - The fully qualified domain name where this service is available. Does not include "https://". * @param adminToken - Optional. An administrative Bearer token used to protect admin routes. * If not provided, a random token will be generated at runtime. */ constructor(name, privateKey, advertisableFQDN, adminToken) { this.name = name; this.privateKey = privateKey; this.advertisableFQDN = advertisableFQDN; // Server port this.port = 3000; // Logger (defaults to console) this.logger = console; // Knex migrations to run this.migrationsToRun = []; // Network ('main' or 'test') this.network = 'main'; // If no custom ChainTracker is configured, default is a WhatsOnChain instance // (We keep a property for it, so we can pass it to Engine) this.chainTracker = new sdk_1.WhatsOnChain(this.network); // Configured Topic Managers this.managers = {}; // Configured Lookup Services this.services = {}; // Enable GASP Sync // (We allow an on/off toggle, but also can do advanced custom sync config below) this.enableGASPSync = true; // Enable BRC-136 BASM sync. Off by default; endpoints remain available when // storage supports anchors. this.enableBASMSync = false; // Opt-in unproven eviction default threshold in blocks. this.unprovenEvictionBlocks = 144; // How often (ms) to poll the chain tip and extend each topic's BASM anchor // chain with empty anchors, so the cumulative TAC advances "after each new // block" per BRC-136. Set to 0 to disable polling (startup extension still runs). this.basmBlockPollIntervalMs = 10 * 60 * 1000; // How often (ms) to refresh proofs for old unproven rows and then evict rows // that still have no proof. Set to 0 to disable background maintenance. this.unprovenMaintenanceIntervalMs = 0; // Depth (in blocks from the tip) for the reorg revalidation sweep. this.reorgScanDepth = 3; // ARC API Key this.arcApiKey = undefined; // Optional ARC callback token for /arc-ingest notifications this.arcCallbackToken = undefined; // Optional Arcade URL/API key used for propagation, proof refresh, and // go-chaintracks header/reorg access when available. this.arcadeUrl = undefined; this.arcadeApiKey = undefined; this.arcadeDeploymentId = undefined; this.arcadeChaintracksApiPrefix = '/chaintracks/v2'; // Verbose request logging this.verboseRequestLogging = false; // Web UI configuration this.webUIConfig = {}; // Additional advanced engine config (these map to Engine constructor parameters). // Default to undefined or default values that are used in the Engine if not specified. this.engineConfig = {}; // Configuration for the janitor service this.janitorConfig = { requestTimeoutMs: 10000, // 10 seconds hostDownRevokeScore: 3, autoBanOnRemoval: true }; // Health endpoint configuration this.healthConfig = { includeDetails: true, timeoutMs: 5000 }; // Extra application-specific health checks this.healthChecks = []; // Lifecycle marker for readiness/liveness reporting this.isListening = false; this.app = (0, express_1.default)(); this.logger.log(chalk_1.default.green.bold(`${name} constructed`)); this.adminToken = adminToken !== null && adminToken !== void 0 ? adminToken : (0, uuid_1.v4)(); // generate random if not provided } /** * Returns the current admin token in case you need to programmatically retrieve or display it. */ getAdminToken() { return this.adminToken; } /** * Configures the port on which the server will listen. * @param port - The port number */ configurePort(port) { this.port = port; this.logger.log(chalk_1.default.blue(`Server port set to ${port}`)); } /** * Configures the web user interface * @param config - Web UI configuration options */ configureWebUI(config) { this.webUIConfig = config; this.logger.log(chalk_1.default.blue('Web UI has been configured.')); } /** * Configures the janitor service parameters * @param config - Janitor configuration options * - requestTimeoutMs: Timeout for health check requests (default: 10000ms) * - hostDownRevokeScore: Number of consecutive failures before deleting output (default: 3) * - autoBanOnRemoval: Whether to auto-ban domains when removed by janitor (default: true) */ configureJanitor(config) { this.janitorConfig = { ...this.janitorConfig, ...config }; this.logger.log(chalk_1.default.blue('Janitor service has been configured.')); } /** * Configures health-report behavior. */ configureHealth(config) { this.healthConfig = { ...this.healthConfig, ...config }; this.logger.log(chalk_1.default.blue('Health reporting has been configured.')); } /** * Registers an application-specific health check. */ registerHealthCheck(definition) { this.healthChecks = this.healthChecks.filter(check => check.name !== definition.name); this.healthChecks.push({ scope: 'ready', critical: false, ...definition }); this.logger.log(chalk_1.default.blue(`Registered health check ${definition.name}`)); } /** * Configures the admin identity key for wallet-based admin detection. * When set, the frontend can compare the user's wallet identity key against this * to determine whether to show the admin dashboard. * * @param identityKey - The hex-encoded public key of the admin */ configureAdminIdentityKey(identityKey) { this.adminIdentityKey = identityKey; this.logger.log(chalk_1.default.blue('Admin identity key has been configured.')); } /** * Configures the logger to be used by the server. * @param logger - A logger object (e.g., console) */ configureLogger(logger) { this.logger = logger; this.logger.log(chalk_1.default.blue('Logger has been configured.')); } /** * Configures the BSV Blockchain network to be used ('main' or 'test'). * By default, it re-initializes chainTracker as a WhatsOnChain for that network. * @param network - The network ('main' or 'test') */ configureNetwork(network) { this.network = network; this.chainTracker = new sdk_1.WhatsOnChain(this.network); this.logger.log(chalk_1.default.blue(`Network set to ${network}`)); } /** * Configures the ChainTracker to be used. * If 'scripts only' is used, it implies no full SPV chain tracking in the Engine. * @param chainTracker - An instance of ChainTracker or 'scripts only' */ configureChainTracker(chainTracker = new sdk_1.WhatsOnChain(this.network)) { this.chainTracker = chainTracker; this.logger.log(chalk_1.default.blue('ChainTracker has been configured.')); } /** * Configures the ARC API key. * @param apiKey - The ARC API key */ configureArcApiKey(apiKey) { this.arcApiKey = apiKey; this.logger.log(chalk_1.default.blue('ARC API key has been configured.')); } /** * Configures the ARC callback token expected by /arc-ingest. * @param token - The token ARC should present when posting callback notifications. */ configureArcCallbackToken(token) { this.arcCallbackToken = token; this.logger.log(chalk_1.default.blue('ARC callback token has been configured.')); } /** * Configures Arcade for first-choice transaction propagation and proof lookup. */ configureArcade(url, config = {}) { this.arcadeUrl = url; this.arcadeApiKey = config.apiKey; this.arcadeDeploymentId = config.deploymentId; if (config.chaintracksApiPrefix !== undefined) { this.arcadeChaintracksApiPrefix = config.chaintracksApiPrefix; } this.logger.log(chalk_1.default.blue('Arcade provider has been configured.')); } /** * Configures a go-chaintracks compatible service for header validation and * BASM reorg streaming. Arcade exposes this at `/chaintracks/v2`. */ configureChaintracks(url, config = {}) { var _a; const apiPrefix = (_a = config.apiPrefix) !== null && _a !== void 0 ? _a : '/chaintracks/v2'; const client = new ChaintracksProvider_js_1.ChaintracksProvider(url, { apiPrefix }); this.configureChainTracker(client); this.configureTopicAnchorHeaderResolver(async (blockHeight) => { const header = await client.findHeaderForHeight(blockHeight); if (header === undefined) return undefined; return { blockHeight, blockHash: header.hash, merkleRoot: header.merkleRoot }; }); if (config.reorgStream !== false) { this.configureReorgStream(client.reorgStreamUrl(), config.scanDepth); } this.logger.log(chalk_1.default.blue('go-chaintracks provider has been configured.')); } /** * Enables or disables GASP synchronization (high-level setting). * This is a broad toggle that can be overridden or customized through syncConfiguration. * @param enable - true to enable, false to disable */ configureEnableGASPSync(enable) { this.enableGASPSync = enable; this.logger.log(chalk_1.default.blue(`GASP synchronization ${enable ? 'enabled' : 'disabled'}.`)); } /** * Enables or disables BRC-136 BASM synchronization. * BASM is opt-in because it requires direct proofs and block hash resolution. */ configureEnableBASMSync(enable) { this.enableBASMSync = enable; this.logger.log(chalk_1.default.blue(`BASM synchronization ${enable ? 'enabled' : 'disabled'}.`)); } /** * Configures the block header resolver used to derive BASM block hashes. */ configureTopicAnchorHeaderResolver(resolver) { this.topicAnchorHeaderResolver = resolver; this.logger.log(chalk_1.default.blue('BASM topic anchor header resolver has been configured.')); } /** * Configures the go-chaintracks (Arcade) reorg SSE stream used to reconcile * BASM anchors with blockchain reorganizations in real time. * @param url - The reorg stream URL, e.g. `https://arcade.example/v2/reorg/stream`. * @param scanDepth - Optional revalidation-sweep depth in blocks (default 3). */ configureReorgStream(url, scanDepth) { this.reorgStreamUrl = url; if (scanDepth !== undefined) { this.reorgScanDepth = scanDepth; } this.logger.log(chalk_1.default.blue('BASM reorg stream has been configured.')); } /** * Configures the opt-in unproven state eviction threshold. */ configureUnprovenEviction(config) { if (config.thresholdBlocks !== undefined) { this.unprovenEvictionBlocks = config.thresholdBlocks; } this.logger.log(chalk_1.default.blue('Unproven transaction eviction has been configured.')); } /** * Configures periodic unproven maintenance. Each run first tries configured * proof providers, then evicts rows that are still unproven past the threshold. */ configureUnprovenMaintenance(config) { if (config.intervalMs !== undefined) { this.unprovenMaintenanceIntervalMs = config.intervalMs; } if (config.thresholdBlocks !== undefined) { this.unprovenEvictionBlocks = config.thresholdBlocks; } this.logger.log(chalk_1.default.blue('Unproven transaction maintenance has been configured.')); } /** * Configures how often the BASM anchor chain is extended with empty anchors to * follow the chain tip. Set to 0 to disable periodic polling. */ configureBASMBlockPollInterval(intervalMs) { this.basmBlockPollIntervalMs = intervalMs; this.logger.log(chalk_1.default.blue(`BASM block poll interval set to ${intervalMs}ms.`)); } /** * Enables or disables verbose request logging. * @param enable - true to enable, false to disable */ configureVerboseRequestLogging(enable) { this.verboseRequestLogging = enable; this.logger.log(chalk_1.default.blue(`Verbose request logging ${enable ? 'enabled' : 'disabled'}.`)); } /** * Configure Knex (SQL) database connection. * @param config - Knex configuration object, or a MySQL connection string loaded from configuration. */ async configureKnex(config) { if (typeof config === 'string') { config = { client: 'mysql2', connection: config }; } this.knex = (0, knex_1.default)(config); this.logger.log(chalk_1.default.blue('Knex successfully configured.')); } /** * Configures the MongoDB database connection. * Also initializes the BanService for persistent ban tracking. * @param connectionString - MongoDB connection string */ async configureMongo(connectionString) { const mongoClient = new mongodb_1.MongoClient(connectionString); await mongoClient.connect(); this.mongoClient = mongoClient; const db = mongoClient.db(`${this.name}_lookup_services`); this.mongoDb = db; // Initialize the BanService this.banService = new BanService_js_1.BanService(db); await this.banService.ensureIndexes(); this.logger.log(chalk_1.default.blue('MongoDB successfully configured and connected.')); } /** * Configures a Topic Manager. * @param name - The name of the Topic Manager * @param manager - An instance of TopicManager */ configureTopicManager(name, manager) { this.managers[name] = manager; this.logger.log(chalk_1.default.blue(`Configured topic manager ${name}`)); } /** * Configures a Lookup Service. * @param name - The name of the Lookup Service * @param service - An instance of LookupService */ configureLookupService(name, service) { this.services[name] = service; this.logger.log(chalk_1.default.blue(`Configured lookup service ${name}`)); } /** * Configures a Lookup Service using Knex (SQL) database. * @param name - The name of the Lookup Service * @param serviceFactory - A factory function that creates a LookupService instance using Knex */ configureLookupServiceWithKnex(name, serviceFactory) { const knex = this.ensureKnex(); const factoryResult = serviceFactory(knex); this.services[name] = factoryResult.service; this.migrationsToRun.push(...factoryResult.migrations); this.logger.log(chalk_1.default.blue(`Configured lookup service ${name} with Knex`)); } /** * Configures a Lookup Service using MongoDB. * @param name - The name of the Lookup Service * @param serviceFactory - A factory function that creates a LookupService instance using MongoDB */ configureLookupServiceWithMongo(name, serviceFactory) { const mongoDb = this.ensureMongo(); this.services[name] = serviceFactory(mongoDb); this.logger.log(chalk_1.default.blue(`Configured lookup service ${name} with MongoDB`)); } /** * Advanced configuration method for setting or overriding any * Engine constructor parameters via an EngineConfig object. * * Example usage: * configureEngineParams({ * logTime: true, * throwOnBroadcastFailure: true, * overlayBroadcastFacilitator: new MyCustomFacilitator() * }) * * These fields will be respected when we finally build/configure the Engine * in the `configureEngine()` method below. */ configureEngineParams(params) { this.engineConfig = { ...this.engineConfig, ...params }; this.logger.log(chalk_1.default.blue('Advanced Engine configuration params have been updated.')); } /** * Configures the Overlay Engine itself. * By default, auto-configures SHIP and SLAP unless autoConfigureShipSlap = false * Then it merges in any advanced engine config from `this.engineConfig`. * * When a BanService is available (from configureMongo), auto-configured SHIP * and SLAP managers, discovery storage, and lookup services are wrapped so * banned outputs are not admitted or indexed. * * @param autoConfigureShipSlap - Whether to auto-configure SHIP and SLAP services (default: true) */ async configureEngine(autoConfigureShipSlap = true) { var _a, _b, _c, _d, _e, _f, _g, _h, _j; const knex = this.ensureKnex(); if (autoConfigureShipSlap) { const mongoDb = this.ensureMongo(); const shipStorage = new DiscoveryServices.SHIPStorage(mongoDb); const slapStorage = new DiscoveryServices.SLAPStorage(mongoDb); // Run the one-time discovery migration before the engine can accept // traffic, so a failed unique-index build is a visible startup failure. await shipStorage.ensureIndexes(); await slapStorage.ensureIndexes(); this.configureTopicManager('tm_ship', new DiscoveryServices.SHIPTopicManager()); this.configureTopicManager('tm_slap', new DiscoveryServices.SLAPTopicManager()); const shipStorageForLookup = this.banService === undefined ? shipStorage : new BanAwareDiscoveryStorage_js_1.BanAwareSHIPStorage(shipStorage, this.banService, this.logger); const slapStorageForLookup = this.banService === undefined ? slapStorage : new BanAwareDiscoveryStorage_js_1.BanAwareSLAPStorage(slapStorage, this.banService, this.logger); this.services.ls_ship = new DiscoveryServices.SHIPLookupService(shipStorageForLookup); this.services.ls_slap = new DiscoveryServices.SLAPLookupService(slapStorageForLookup); this.logger.log(chalk_1.default.blue('Configured lookup service ls_ship with MongoDB')); this.logger.log(chalk_1.default.blue('Configured lookup service ls_slap with MongoDB')); } this.wrapBanAwareServices(); const syncConfig = this.buildSyncConfig(); const storage = new overlay_1.KnexStorage(knex); this.migrationsToRun = [...overlay_1.KnexStorageMigrations.default, ...this.migrationsToRun]; const broadcaster = this.buildBroadcaster(); const advertiser = await this.buildAdvertiser(); const EngineWithBASM = overlay_1.Engine; this.engine = new EngineWithBASM(this.managers, this.services, storage, (_a = this.engineConfig.chainTracker) !== null && _a !== void 0 ? _a : this.chainTracker, `https://${this.advertisableFQDN}`, this.network === 'test' ? ((_b = this.engineConfig.shipTrackers) !== null && _b !== void 0 ? _b : sdk_1.DEFAULT_TESTNET_SLAP_TRACKERS) : this.engineConfig.shipTrackers, this.resolveSlapTrackers(), broadcaster !== null && broadcaster !== void 0 ? broadcaster : this.engineConfig.broadcaster, advertiser, syncConfig, (_c = this.engineConfig.logTime) !== null && _c !== void 0 ? _c : false, (_d = this.engineConfig.logPrefix) !== null && _d !== void 0 ? _d : '[OVERLAY_ENGINE] ', (_e = this.engineConfig.throwOnBroadcastFailure) !== null && _e !== void 0 ? _e : true, (_f = this.engineConfig.overlayBroadcastFacilitator) !== null && _f !== void 0 ? _f : new sdk_1.HTTPSOverlayBroadcastFacilitator(), this.logger, (_g = this.engineConfig.suppressDefaultSyncAdvertisements) !== null && _g !== void 0 ? _g : true, this.buildTopicAnchorHeaderResolver(), (_h = this.engineConfig.enableBASMSync) !== null && _h !== void 0 ? _h : this.enableBASMSync, (_j = this.engineConfig.unprovenEvictionBlocks) !== null && _j !== void 0 ? _j : this.unprovenEvictionBlocks); this.initServerWallet(); this.logger.log(chalk_1.default.green('Engine has been configured.')); } /** Wrap SHIP/SLAP managers and services with ban-aware filters if BanService is configured. */ wrapBanAwareServices() { if (this.banService === undefined) return; for (const key of ['tm_ship', 'tm_slap']) { if (this.managers[key] !== undefined) { const label = key === 'tm_ship' ? 'SHIP' : 'SLAP'; this.managers[key] = new BanAwareTopicManager_js_1.BanAwareTopicManager(this.managers[key], this.banService, label, this.logger); this.logger.log(chalk_1.default.blue(`${label} topic manager wrapped with ban-aware filter.`)); } } for (const key of ['ls_ship', 'ls_slap']) { if (this.services[key] !== undefined) { const label = key === 'ls_ship' ? 'SHIP' : 'SLAP'; this.services[key] = new BanAwareLookupWrapper_js_1.BanAwareLookupWrapper(this.services[key], this.banService, label, this.logger); this.logger.log(chalk_1.default.blue(`${label} lookup service wrapped with ban-aware filter.`)); } } } /** Build the sync config based on enableGASPSync and engineConfig. */ buildSyncConfig() { var _a; if (this.enableGASPSync) { return (_a = this.engineConfig.syncConfiguration) !== null && _a !== void 0 ? _a : {}; } const syncConfig = {}; for (const name of Object.keys(this.managers)) { syncConfig[name] = false; } return syncConfig; } /** Build the configured transaction propagation provider chain. */ buildBroadcaster() { const providers = []; const callbackUrl = `https://${this.advertisableFQDN}/arc-ingest`; if (typeof this.arcadeUrl === 'string' && this.arcadeUrl.length > 0) { this.arcadeProvider = new ArcadeProvider_js_1.ArcadeProvider(this.arcadeUrl, { apiKey: this.arcadeApiKey, callbackUrl, callbackToken: this.arcCallbackToken, deploymentId: this.arcadeDeploymentId }); providers.push({ name: 'Arcade', broadcaster: this.arcadeProvider }); } else { this.arcadeProvider = undefined; } if (typeof this.arcApiKey === 'string' && this.arcApiKey.length > 0) { const arcUrl = this.network === 'test' ? 'https://arc-test.taal.com' : 'https://arc.taal.com'; providers.push({ name: 'ARC', broadcaster: new sdk_1.ARC(arcUrl, { apiKey: this.arcApiKey, callbackUrl, callbackToken: this.arcCallbackToken }) }); } if (providers.length === 0) return undefined; if (providers.length === 1) return providers[0].broadcaster; return new ProviderChainBroadcaster_js_1.ProviderChainBroadcaster(providers); } ensureArcadeProvider() { if (this.arcadeProvider !== undefined) return this.arcadeProvider; if (typeof this.arcadeUrl !== 'string' || this.arcadeUrl.length === 0) return undefined; this.arcadeProvider = new ArcadeProvider_js_1.ArcadeProvider(this.arcadeUrl, { apiKey: this.arcadeApiKey, callbackUrl: `https://${this.advertisableFQDN}/arc-ingest`, callbackToken: this.arcCallbackToken, deploymentId: this.arcadeDeploymentId }); return this.arcadeProvider; } async fetchArcadeProof(txid) { var _a, _b; const provider = this.ensureArcadeProvider(); if (provider === undefined) return undefined; const proof = await provider.fetchMerkleProof(txid); if (proof === undefined) return undefined; const chainTracker = (_a = this.engineConfig.chainTracker) !== null && _a !== void 0 ? _a : this.chainTracker; if (chainTracker === 'scripts only') { throw new Error('Cannot validate Arcade proof with scripts-only chain tracker'); } const blockHeight = (_b = proof.blockHeight) !== null && _b !== void 0 ? _b : proof.merklePath.blockHeight; if (blockHeight === undefined) { throw new Error(`Arcade proof for ${txid} did not include a block height`); } const valid = await chainTracker.isValidRootForHeight(proof.merkleRoot, blockHeight); if (!valid) { throw new Error(`Arcade proof for ${txid} did not match the chain tracker at height ${blockHeight}`); } return { ...proof, blockHeight }; } async fetchConfiguredMerkleProof(txid) { const proof = await this.fetchArcadeProof(txid); if (proof === undefined) return undefined; return { merklePath: proof.merklePath, blockHeight: proof.blockHeight }; } /** Build the BASM block header resolver. */ buildTopicAnchorHeaderResolver() { var _a; const configured = (_a = this.engineConfig.topicAnchorHeaderResolver) !== null && _a !== void 0 ? _a : this.topicAnchorHeaderResolver; if (configured !== undefined) { return configured; } return async (blockHeight) => { const response = await fetch(`https://api.whatsonchain.com/v1/bsv/${this.network}/block/${blockHeight}/header`, { method: 'GET', headers: { Accept: 'application/json' } }); if (!response.ok) { throw new Error(`WhatsOnChain header lookup failed for height ${blockHeight}: ${response.status}`); } const header = await response.json(); if (typeof header.hash !== 'string') { throw new TypeError(`WhatsOnChain did not return a block hash for height ${blockHeight}`); } return { blockHeight, blockHash: header.hash, merkleRoot: header.merkleroot }; }; } /** Resolve the SLAP trackers from config or network defaults. */ resolveSlapTrackers() { if (Array.isArray(this.engineConfig.slapTrackers)) return this.engineConfig.slapTrackers; return this.network === 'test' ? sdk_1.DEFAULT_TESTNET_SLAP_TRACKERS : sdk_1.DEFAULT_SLAP_TRACKERS; } /** Build the WalletAdvertiser (or use user-provided one). */ async buildAdvertiser() { if (this.engineConfig.advertiser !== undefined) return this.engineConfig.advertiser; const storageBase = this.network === 'test' ? 'https://staging-storage.babbage.systems' : 'https://storage.babbage.systems'; try { return new DiscoveryServices.WalletAdvertiser(this.network, this.privateKey, storageBase, `https://${this.advertisableFQDN}`); } catch (e) { this.logger.log(`Advertiser not initialized for FQDN ${this.advertisableFQDN} - SHIP and SLAP will be disabled. Reason: ${e}`); return undefined; } } /** Initialize the server wallet for BSV mutual authentication. */ initServerWallet() { var _a; try { const keyDeriver = new sdk_1.KeyDeriver(new sdk_1.PrivateKey(this.privateKey, 'hex')); const storageManager = new wallet_toolbox_client_1.WalletStorageManager(keyDeriver.identityKey); const signer = new wallet_toolbox_client_1.WalletSigner(this.network, keyDeriver, storageManager); const services = new wallet_toolbox_client_1.Services(this.network); this.serverWallet = new wallet_toolbox_client_1.Wallet(signer, services); (_a = this.adminIdentityKey) !== null && _a !== void 0 ? _a : (this.adminIdentityKey = keyDeriver.identityKey); this.logger.log(chalk_1.default.blue('Server wallet initialized for BSV mutual authentication.')); } catch (e) { this.logger.log(chalk_1.default.yellow(`Server wallet could not be initialized. BSV auth will not be available. Reason: ${e}`)); } } /** * Ensures that Knex is configured and returns it. * @throws Error if Knex is not configured */ ensureKnex() { if (this.knex === undefined) { throw new TypeError('You must configure your SQL database with the .configureKnex() method first!'); } return this.knex; } /** * Ensures that MongoDB is configured and returns it. * @throws Error if MongoDB is not configured */ ensureMongo() { if (this.mongoDb === undefined) { throw new TypeError('You must configure your MongoDB connection with the .configureMongo() method first!'); } return this.mongoDb; } /** * Ensures that the Overlay Engine is configured and returns it. * @throws Error if the Engine is not configured */ ensureEngine() { if (this.engine === undefined) { throw new TypeError('You must configure your Overlay Services engine with the .configureEngine() method first!'); } return this.engine; } /** * Creates a JanitorService instance with current configuration. */ createJanitor() { const mongoDb = this.ensureMongo(); return new JanitorService_js_1.JanitorService({ mongoDb, logger: this.logger, requestTimeoutMs: this.janitorConfig.requestTimeoutMs, hostDownRevokeScore: this.janitorConfig.hostDownRevokeScore, banService: this.banService, autoBanOnRemoval: this.janitorConfig.autoBanOnRemoval }); } /** Ban a domain and remove all its SHIP/SLAP records from MongoDB. */ async handleBanDomain(res, value, reason) { await this.banService.banDomain(value, reason); const db = this.ensureMongo(); const [shipDeleted, slapDeleted] = await Promise.all([ db.collection('shipRecords').deleteMany({ domain: value }), db.collection('slapRecords').deleteMany({ domain: value }) ]); return res.status(200).json({ status: 'success', message: `Domain "${value}" banned. Removed ${shipDeleted.deletedCount} SHIP and ${slapDeleted.deletedCount} SLAP records.` }); } /** Parse outpoint string, ban it, and evict it from all lookup services. */ async handleBanOutpoint(res, engine, value, reason) { const dotIndex = value.lastIndexOf('.'); if (dotIndex === -1) { return res.status(400).json({ status: 'error', message: 'Outpoint format must be "txid.outputIndex"' }); } const txid = value.substring(0, dotIndex); const outputIndex = Number.parseInt(value.substring(dotIndex + 1)); if (Number.isNaN(outputIndex)) { return res.status(400).json({ status: 'error', message: 'Invalid outputIndex in outpoint' }); } await this.banService.banOutpoint(txid, outputIndex, reason); await this.evictFromServices(engine, txid, outputIndex); return res.status(200).json({ status: 'success', message: `Outpoint "${value}" banned and evicted from lookup services.` }); } /** Evict an output from a specific service or all services (silent per-service errors). */ async evictFromServices(engine, txid, outputIndex, service) { if (typeof service === 'string') { const svc = engine.lookupServices[service]; if (svc !== undefined) await svc.outputEvicted(txid, outputIndex); return; } for (const svc of Object.values(engine.lookupServices)) { try { await svc.outputEvicted(txid, outputIndex); } catch { /* best-effort */ } } } /** Look up the domain of an outpoint from SHIP or SLAP records. */ async lookupDomainForOutpoint(txid, outputIndex) { var _a; const db = this.ensureMongo(); const [shipRecord, slapRecord] = await Promise.all([ db.collection('shipRecords').findOne({ txid, outputIndex }), db.collection('slapRecords').findOne({ txid, outputIndex }) ]); return ((_a = shipRecord === null || shipRecord === void 0 ? void 0 : shipRecord.domain) !== null && _a !== void 0 ? _a : slapRecord === null || slapRecord === void 0 ? void 0 : slapRecord.domain); } /** Ban a domain and delete all SHIP/SLAP records for it. */ async banDomainAndRemoveRecords(domain, reason) { await this.banService.banDomain(domain, reason); const db = this.ensureMongo(); await Promise.all([ db.collection('shipRecords').deleteMany({ domain }), db.collection('slapRecords').deleteMany({ domain }) ]); } async runHealthCheck(definition) { var _a, _b; const startedAt = Date.now(); try { const result = ((_a = (await Promise.race([ Promise.resolve(definition.handler()), new Promise((_, reject) => { setTimeout(() => reject(new Error(`Timed out after ${this.healthConfig.timeoutMs}ms`)), this.healthConfig.timeoutMs); }) ]))) !== null && _a !== void 0 ? _a : {}); return { name: definition.name, scope: definition.scope, critical: definition.critical, status: (_b = result.status) !== null && _b !== void 0 ? _b : 'ok', message: result.message, details: result.details, durationMs: Date.now() - startedAt }; } catch (error) { return { name: definition.name, scope: definition.scope, critical: definition.critical, status: 'error', message: error instanceof Error ? error.message : 'Unknown health-check error', durationMs: Date.now() - startedAt }; } } async collectHealthReport(mode) { var _a, _b, _c; const definitions = [ { name: 'process', scope: 'live', critical: true, handler: async () => ({ status: 'ok', details: { listening: this.isListening } }) }, { name: 'engine', scope: 'ready', critical: true, handler: async () => { if (this.engine === undefined) { throw new TypeError('Overlay engine is not configured'); } return { status: 'ok', details: { topicManagers: Object.keys(this.managers), lookupServices: Object.keys(this.services) } }; } }, { name: 'knex', scope: 'ready', critical: true, handler: async () => { var _a, _b, _c; if (this.knex === undefined) { throw new TypeError('Knex is not configured'); } await this.knex.raw('select 1 as ok'); return { status: 'ok', details: { client: (_c = (_b = (_a = this.knex.client) === null || _a === void 0 ? void 0 : _a.config) === null || _b === void 0 ? void 0 : _b.client) !== null && _c !== void 0 ? _c : 'unknown' } }; } }, { name: 'mongo', scope: 'ready', critical: true, handler: async () => { if (this.mongoDb === undefined) { throw new TypeError('MongoDB is not configured'); } await this.mongoDb.command({ ping: 1 }); return { status: 'ok', details: { database: this.mongoDb.databaseName } }; } } ]; for (const check of this.healthChecks) { definitions.push({ name: check.name, scope: (_a = check.scope) !== null && _a !== void 0 ? _a : 'ready', critical: (_b = check.critical) !== null && _b !== void 0 ? _b : false, handler: check.handler }); } const filteredDefinitions = definitions.filter((definition) => { if (mode === 'full') { return true; } return definition.scope === mode; }); const checks = await Promise.all(filteredDefinitions.map(async (definition) => await this.runHealthCheck(definition))); const liveChecks = checks.filter(check => check.scope === 'live'); const readyChecks = checks.filter(check => check.scope === 'ready'); const live = liveChecks.every(check => !check.critical || check.status === 'ok'); const ready = readyChecks.every(check => !check.critical || check.status === 'ok'); let status = 'ok'; if (!live || !ready || checks.some(check => check.critical && check.status === 'error')) { status = 'error'; } else if (checks.some(check => check.status !== 'ok')) { status = 'degraded'; } const context = typeof this.healthConfig.contextProvider === 'function' ? await this.healthConfig.contextProvider() : undefined; const report = { status, live, ready, service: { name: this.name, advertisableFQDN: this.advertisableFQDN, port: this.port, network: this.network, startedAt: (_c = this.startTime) === null || _c === void 0 ? void 0 : _c.toISOString(), uptimeMs: this.startTime === undefined ? 0 : Date.now() - this.startTime.getTime(), topicManagerCount: Object.keys(this.managers).length, lookupServiceCount: Object.keys(this.services).length }, checks: this.healthConfig.includeDetails ? checks : checks.map(({ details, ...check }) => check), context }; return report; } /** * Renders a request or response body for verbose logging, truncating overly long payloads. */ formatBodyForLog(body, tooLongLabel, okPrefix) { let bodyString; if (typeof body === 'object') { bodyString = JSON.stringify(body, null, 2); } else if (Buffer.isBuffer(body)) { bodyString = body.toString('utf8'); } else { bodyString = String(body); } return bodyString.length > 280 ? chalk_1.default.yellow(`(${tooLongLabel}, length: ${String(bodyString.length)} characters)`) : chalk_1.default.green(`${okPrefix}\n${bodyString}`); } /** * Installs middleware that verbosely logs incoming requests and outgoing responses. */ setupVerboseRequestLogging() { this.app.use((req, res, next) => { const startTime = Date.now(); // Log incoming request details this.logger.log(chalk_1.default.magenta.bold(`Incoming Request: ${String(req.method)} ${String(req.originalUrl)}`)); // Pretty-print headers this.logger.log(chalk_1.default.cyan('Headers:')); this.logger.log(node_util_1.default.inspect(req.headers, { colors: true, depth: null })); // Handle request body if (req.body != null && Object.keys(req.body).length > 0) { this.logger.log(this.formatBodyForLog(req.body, 'Body too long to display', 'Request Body:')); } // Intercept the res.send method to log responses const originalSend = res.send; let responseBody; res.send = function (body) { responseBody = body; return originalSend.call(this, body); }; // Log outgoing response details after the response is finished res.on('finish', () => { const duration = Date.now() - startTime; this.logger.log(chalk_1.default.magenta.bold(`Outgoing Response: ${String(req.method)} ${String(req.originalUrl)} - Status: ${String(res.statusCode)} - Duration: ${String(duration)}ms`)); this.logger.log(chalk_1.default.cyan('Response Headers:')); this.logger.log(node_util_1.default.inspect(res.getHeaders(), { colors: true, depth: null })); // Handle response body if (responseBody != null) { this.logger.log(this.formatBodyForLog(responseBody, 'Response body too long to display', 'Response Body:')); } }); next(); }); } /** * Starts the Express server. * Sets up routes and begins listening on the configured port. */ async start() { const engine = this.ensureEngine(); const knex = this.ensureKnex(); this.startTime = new Date(); this.app.use(body_parser_1.default.json({ limit: '1gb', type: 'application/json' })); this.app.use(body_parser_1.default.raw({ limit: '1gb', type: 'application/octet-stream' })); if (this.verboseRequestLogging) { this.setupVerboseRequestLogging(); } // Enable CORS this.app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Headers', '*'); res.header('Access-Control-Allow-Methods', '*'); res.header('Access-Control-Expose-Headers', '*'); res.header('Access-Control-Allow-Private-Network', 'true'); if (req.method === 'OPTIONS') { res.sendStatus(200); } else { next(); } }); // Serve a static documentation site or user interface this.app.get('/', (req, res) => { res.set('content-type', 'text/html'); res.send((0, makeUserInterface_js_1.default)({ ...this.webUIConfig, adminIdentityKey: this.adminIdentityKey })); }); // Serve health check endpoints this.app.get('/health/live', (_, res) => { ; (async () => { const report = await this.collectHealthReport('live'); return res.status(report.live ? 200 : 503).json(report); })().catch((error) => { res.status(500).json({ status: 'error', message: error instanceof Error ? error.message : 'Unexpected error' }); }); }); this.app.get('/health/ready', (_, res) => { ; (async () => { const report = await this.collectHealthReport('ready'); return res.status(report.ready ? 200 : 503).json(report); })().catch((error) => { res.status(500).json({ status: 'error', message: error instanceof Error ? error.message : 'Unexpected error' }); }); }); this.app.get('/health', (_, res) => { ; (async () => { const report = await this.collectHealthReport('full'); return res.status(report.ready ? 200 : 503).json(report); })().catch((error) => {