@bsv/overlay-express
Version:
BSV Blockchain Overlay Express
1,106 lines (1,105 loc) • 102 kB
JavaScript
"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) => {