Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
21 changes: 14 additions & 7 deletions src/invoicing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
33 changes: 26 additions & 7 deletions src/router_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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() {
Expand Down
13 changes: 13 additions & 0 deletions test/controllers/mock_ln_node_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions test/ln_flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});