diff --git a/src/index.js b/src/index.js index 6d67469..d1c8c99 100755 --- a/src/index.js +++ b/src/index.js @@ -7,10 +7,22 @@ const config_router = require('./router_config').config_router const dotenv = require('dotenv') const express = require('express') const debug = require('debug')('api') +const error = require('debug')('api:error') const { PurpleInvoiceManager } = require('./invoicing') const { WebAuthManager } = require('./web_auth') const { SimplePool } = require('nostr-tools/pool') +// Global handlers to prevent unexpected errors from crashing the server process. +// These are last-resort safety nets; each request's errors should ideally be caught +// and handled closer to their source (e.g. in route handlers or invoicing code). +process.on('uncaughtException', (err) => { + error('Uncaught exception: %s', err.toString()) +}) + +process.on('unhandledRejection', (reason) => { + error('Unhandled promise rejection: %s', reason instanceof Error ? reason.toString() : JSON.stringify(reason)) +}) + const ENV_VARS = ["LN_NODE_ID", "LN_NODE_ADDRESS", "LN_RUNE", "LN_WS_PROXY", "DEEPL_KEY", "DB_PATH", "NOTEDECK_INSTALL_MD", "NOTEDECK_INSTALL_PREMIUM_MD", "DAMUS_ANDROID_INSTALL_MD", "DAMUS_ANDROID_INSTALL_PREMIUM_MD"] function check_env() { diff --git a/src/invoicing.js b/src/invoicing.js index 617c930..f75b162 100644 --- a/src/invoicing.js +++ b/src/invoicing.js @@ -257,15 +257,22 @@ class PurpleInvoiceManager { async check_invoice_is_paid(label) { try { const params = { label } - return new Promise(async (resolve, reject) => { - setTimeout(() => { - resolve(undefined) - }, parseInt(process.env.LN_INVOICE_CHECK_TIMEOUT_MS) || 60000) - const res = await this.ln_rpc({ method: "waitinvoice", params }) - resolve(res.error ? false : true) + const timeout_ms = parseInt(process.env.LN_INVOICE_CHECK_TIMEOUT_MS) || 60000 + const timeout_promise = new Promise(resolve => { + const timer = setTimeout(() => resolve(undefined), timeout_ms) + // Don't prevent process exit if nothing else is keeping the event loop alive + if (timer.unref) timer.unref() }) + const rpc_promise = this.ln_rpc({ method: "waitinvoice", params }) + .then(res => res.error ? false : true) + .catch((e) => { + error("Error checking invoice status for label %s: %s", label, e.toString()) + return undefined + }) + return Promise.race([timeout_promise, rpc_promise]) } - catch { + catch (e) { + error("Synchronous error setting up invoice check for label %s: %s", label, e.toString()) return undefined } } diff --git a/src/router_config.js b/src/router_config.js index e364b26..cc7be3a 100644 --- a/src/router_config.js +++ b/src/router_config.js @@ -303,8 +303,13 @@ function config_router(app) { error_response(res, 'Could not parse checkout_id') return } - const checkout_object = await app.invoice_manager.check_checkout_object_invoice(checkout_id) - json_response(res, checkout_object) + try { + const checkout_object = await app.invoice_manager.check_checkout_object_invoice(checkout_id) + json_response(res, checkout_object) + } catch (e) { + error("%s", e.toString()) + error_response(res, 'Failed to check invoice status') + } }) // Used by the Damus app to authenticate the user, and generate the final LN invoice @@ -561,12 +566,17 @@ function config_router(app) { return } const checkout_object = await app.invoice_manager.new_checkout(product_template_name) - const { checkout_object: new_checkout_object, request_error } = await app.invoice_manager.verify_checkout_object(checkout_object.id, authorized_pubkey) - if (request_error) { - invalid_request(res, "Error when verifying checkout: " + request_error) - return + try { + const { checkout_object: new_checkout_object, request_error } = await app.invoice_manager.verify_checkout_object(checkout_object.id, authorized_pubkey) + if (request_error) { + invalid_request(res, "Error when verifying checkout: " + request_error) + return + } + json_response(res, new_checkout_object) + } catch (e) { + error("%s", e.toString()) + error_response(res, 'Failed to create verified checkout') } - json_response(res, new_checkout_object) }) if (process.env.ENABLE_DEBUG_ENDPOINTS == "true") { @@ -719,6 +729,15 @@ function config_router(app) { json_response(res, { success: true }) }); } + + // Global error handling middleware — catches errors thrown or passed via next(err) in any route handler + // eslint-disable-next-line no-unused-vars + router.use((err, req, res, next) => { + error('Unhandled route error for %s %s: %s', req.method, req.url, err.toString()) + if (!res.headersSent) { + error_response(res, 'An unexpected error occurred') + } + }) } function get_allowed_cors_origins() { diff --git a/test/controllers/mock_ln_node_controller.js b/test/controllers/mock_ln_node_controller.js index a7aff5b..47f7468 100644 --- a/test/controllers/mock_ln_node_controller.js +++ b/test/controllers/mock_ln_node_controller.js @@ -15,6 +15,7 @@ class MockLNNodeController { constructor(t, invoice_expiry_period) { this.invoice_expiry_period = invoice_expiry_period this.control_invoice_should_fail = false + this.control_node_is_down = false this.invoices = {} this.bolt11_to_label = {} this.open_intervals = [] @@ -43,6 +44,14 @@ class MockLNNodeController { this.invoices[this.bolt11_to_label[bolt11]].error = "Simulated invoice error" } + simulate_node_down() { + this.control_node_is_down = true + } + + simulate_node_up() { + this.control_node_is_down = false + } + // MARK: - Mocking LNSocket functions /** genkey - Generates a new key @@ -61,6 +70,10 @@ class MockLNNodeController { */ connect_and_init(nodeid, address) { return new Promise((resolve, reject) => { + if (this.control_node_is_down) { + reject(new Error('connect ECONNREFUSED (simulated node down)')) + return + } setTimeout(() => { resolve() }, 1000) // Simulate a 1 second delay to make timing more realistic diff --git a/test/ln_flow.test.js b/test/ln_flow.test.js index 4247b7a..5320963 100644 --- a/test/ln_flow.test.js +++ b/test/ln_flow.test.js @@ -240,3 +240,48 @@ test('LN Flow — Background polling processes paid invoices without client chec t.end(); }); + +test('LN Flow — Downed LN node does not crash the server', async (t) => { + // Initialize the PurpleTestController + const purple_api_controller = await PurpleTestController.new(t); + const user_pubkey_1 = purple_api_controller.new_client(); + + // Start a new checkout (does not require LN connection) + const new_checkout_response = await purple_api_controller.clients[user_pubkey_1].new_checkout(PURPLE_ONE_MONTH); + t.same(new_checkout_response.statusCode, 200); + + // Simulate the LN node going down + purple_api_controller.mock_ln_node_controller.simulate_node_down(); + + // Attempting to verify a checkout (which generates an invoice) should return an error, not crash + const verify_checkout_response = await purple_api_controller.clients[user_pubkey_1].verify_checkout(new_checkout_response.body.id); + t.same(verify_checkout_response.statusCode, 400, 'verify checkout should fail gracefully when node is down'); + t.ok(verify_checkout_response.body.error, 'error response body should contain an error message'); + + // Restore node, set up a fresh checkout and verify it successfully + purple_api_controller.mock_ln_node_controller.simulate_node_up(); + const new_checkout_response_2 = await purple_api_controller.clients[user_pubkey_1].new_checkout(PURPLE_ONE_MONTH); + t.same(new_checkout_response_2.statusCode, 200); + const verify_checkout_response_2 = await purple_api_controller.clients[user_pubkey_1].verify_checkout(new_checkout_response_2.body.id); + t.same(verify_checkout_response_2.statusCode, 200); + t.ok(verify_checkout_response_2.body.invoice?.bolt11); + + // Bring node down again and try to check the invoice — should fail gracefully + purple_api_controller.mock_ln_node_controller.simulate_node_down(); + const check_invoice_response = await purple_api_controller.clients[user_pubkey_1].check_invoice(new_checkout_response_2.body.id); + // When the LN node is down, check-invoice responds with 200 and the checkout state as-is (invoice.paid is undefined/undetermined) + t.same(check_invoice_response.statusCode, 200, 'check-invoice should respond gracefully (not crash) when node is down'); + t.same(check_invoice_response.body.invoice?.paid, undefined, 'invoice paid status should be undefined when node is unreachable'); + + // Background polling with the LN node down should not throw or crash the process + await t.resolves( + purple_api_controller.purple_api.invoice_manager.poll_unpaid_invoices(), + 'background polling should not throw when node is down' + ); + + // Confirm the server is still up and responding to requests after all the above + const get_account_response = await purple_api_controller.clients[user_pubkey_1].get_account(); + t.same(get_account_response.statusCode, 404, 'server should still be running after LN node failures'); + + t.end(); +});