diff --git a/README.md b/README.md index a144171..c6505d5 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The Damus API backend for Damus Purple and other functionality. - `ALLOW_HTTP_AUTH`: Set to `"true"` to enable HTTP basic auth for all endpoints. (Useful for testing locally, otherwise it forces HTTPS) - `ADMIN_PASSWORD`: Password for admin API endpoints (optional, leaving this blank will disable admin endpoints) - `LN_INVOICE_CHECK_TIMEOUT_MS`: Timeout in milliseconds for checking the status of a Lightning Network invoice. Defaults to 60000 (60 seconds), and shorter for tests +- `ENABLE_DEBUG_ENDPOINTS`: Set to `"true"` to enable debug endpoints (for testing or staging only). This includes endpoints to delete users or force UUIDs. ## npm scripts @@ -46,3 +47,18 @@ The Damus API backend for Damus Purple and other functionality. - `npm test`: Run the unit tests - `npm run type-check`: Run a type check on all files in the project - `npm run type-check-path -- `: Run a type check on a specific file or directory + +## Testing and debugging tips + +### IAP (Apple In-app Purchase) receipt verification + +- Run the server with `DEBUG=iap` to see verbose debug logs for the IAP receipt verification process. You can also use those logs to find which UUID an IAP is associated with. +- If you need to force a specific UUID for a user (e.g. when you reset the db but can't reset your Sandbox IAP history), you can enable the `ENABLE_DEBUG_ENDPOINTS` environment variable and use this debug endpoint to force a UUID for a user: + ``` +```bash +curl -X PUT http:///admin/users//account-uuid \ + -H "Content-Type: application/json" \ + -d '{"admin_password": "", "account_uuid": ""}' +``` + + diff --git a/src/app_store_receipt_verifier.js b/src/app_store_receipt_verifier.js index 5318b1d..2613301 100644 --- a/src/app_store_receipt_verifier.js +++ b/src/app_store_receipt_verifier.js @@ -7,6 +7,7 @@ const { AppStoreServerAPIClient, Environment, ReceiptUtility, Order, ProductType, SignedDataVerifier } = require("@apple/app-store-server-library") const { current_time } = require("./utils") const fs = require('fs') +const debug = require('debug')('iap') /** * Verifies the receipt data and returns the expiry date if the receipt is valid. @@ -17,8 +18,10 @@ const fs = require('fs') * @returns {Promise} The expiry date of the receipt if valid, null otherwise. */ async function verify_receipt(receipt_data, authenticated_account_token) { + debug("Verifying receipt with authenticated account token: %s", authenticated_account_token); // Mocking logic for testing purposes if (process.env.MOCK_VERIFY_RECEIPT == "true") { + debug("Mocking verify_receipt with expiry date 30 days from now"); return current_time() + 60 * 60 * 24 * 30; } @@ -30,6 +33,7 @@ async function verify_receipt(receipt_data, authenticated_account_token) { // Get the transaction ID from the receipt const transactionId = extractTransactionIdFromAppReceipt(receipt_data); + debug("[Account token: %s] Transaction ID extracted from the receipt: %s", authenticated_account_token, transactionId); // If the transaction ID is present, fetch the transaction history, verify the transactions, and return the latest expiry date if (transactionId != null) { @@ -53,14 +57,19 @@ async function verify_receipt(receipt_data, authenticated_account_token) { */ async function fetchLastVerifiedExpiryDate(client, transactionId, rootCaDir, environment, bundleId, authenticatedAccountToken) { const transactions = await fetchTransactionHistory(client, transactionId); + debug("[Account token: %s] Fetched transaction history for transaction ID: %s; Found %d transactions", authenticatedAccountToken, transactionId, transactions.length); const rootCAs = readCertificateFiles(rootCaDir); const decodedTransactions = await verifyAndDecodeTransactions(transactions, rootCAs, environment, bundleId); + debug("[Account token: %s] Verified and decoded %d transactions", authenticatedAccountToken, decodedTransactions.length); const validDecodedTransactions = filterTransactionsThatBelongToAccount(decodedTransactions, authenticatedAccountToken); + debug("[Account token: %s] Filtered transactions that belong to the account UUID. Found %d matching transactions", authenticatedAccountToken, validDecodedTransactions.length); if (validDecodedTransactions.length === 0) { return null; } const expiryDates = decodedTransactions.map((decodedTransaction) => decodedTransaction.expiresDate); + debug("[Account token: %s] Found expiry dates: %o", authenticatedAccountToken, expiryDates); const latestExpiryDate = Math.max(...expiryDates); + debug("[Account token: %s] Latest expiry date: %d", authenticatedAccountToken, latestExpiryDate); return latestExpiryDate / 1000; // Return the latest expiry date in seconds } @@ -73,7 +82,12 @@ async function fetchLastVerifiedExpiryDate(client, transactionId, rootCaDir, env * @returns {Array} The transactions that belong to the authorized account token. */ function filterTransactionsThatBelongToAccount(transactions, authenticatedAccountToken) { - return transactions.filter((transaction) => transaction.appAccountToken.toUpperCase() === authenticatedAccountToken.toUpperCase()); + return transactions.filter((transaction) => { + const txToken = transaction.appAccountToken.toUpperCase(); + const authAccountToken = authenticatedAccountToken.toUpperCase(); + debug("Comparing transaction account token: %s with authenticated account token: %s", txToken, authAccountToken); + return txToken === authAccountToken; + }) } const certificateCache = new Map(); diff --git a/src/router_config.js b/src/router_config.js index b747e8e..078179f 100644 --- a/src/router_config.js +++ b/src/router_config.js @@ -1,5 +1,5 @@ const { json_response, simple_response, error_response, invalid_request, unauthorized_response } = require('./server_helpers') -const { create_account, get_account_info_payload, check_account, get_account, put_account, get_account_and_user_id, get_user_uuid, bumpy_set_expiry } = require('./user_management') +const { create_account, get_account_info_payload, check_account, get_account, put_account, get_account_and_user_id, get_user_uuid, bumpy_set_expiry, delete_account } = require('./user_management') const handle_translate = require('./translate') const verify_receipt = require('./app_store_receipt_verifier').verify_receipt const bodyParser = require('body-parser') @@ -293,7 +293,77 @@ function config_router(app) { } json_response(res, new_checkout_object) }) - + + if (process.env.ENABLE_DEBUG_ENDPOINTS == "true") { + /** + * This route is used to delete a user account. + * This is useful when testing the onboarding flow, and we need to reset the user's account. + */ + router.delete('/admin/users/:pubkey', async (req, res) => { + const pubkey = req.params.pubkey + const body = req.body + const admin_password = body.admin_password + if(!process.env.ADMIN_PASSWORD) { + unauthorized_response(res, 'Admin password not set in the environment variables') + return + } + if (!admin_password) { + unauthorized_response(res, 'Missing admin_password') + return + } + if (admin_password != process.env.ADMIN_PASSWORD) { + unauthorized_response(res, 'Invalid admin password') + return + } + if (!pubkey) { + invalid_request(res, 'Missing pubkey') + return + } + const { delete_error } = delete_account(app, pubkey) + if (delete_error) { + invalid_request(res, { error: delete_error }) + return + } + + json_response(res, { success: true }) + }) + + /** + * This route is used to force a specific UUID for a user account. + * + * This is useful when we accidentally nuke the db on staging, + * but we want to keep the user's account UUID the same as before because resetting Apple's Sandbox account is a pain. + */ + router.put('/admin/users/:pubkey/account-uuid', async (req, res) => { + const pubkey = req.params.pubkey + const body = req.body + const admin_password = body.admin_password + const account_uuid = body.account_uuid + if(!process.env.ADMIN_PASSWORD) { + unauthorized_response(res, 'Admin password not set in the environment variables') + return + } + if (!admin_password) { + unauthorized_response(res, 'Missing admin_password') + return + } + if (admin_password != process.env.ADMIN_PASSWORD) { + unauthorized_response(res, 'Invalid admin password') + return + } + if (!pubkey) { + invalid_request(res, 'Missing pubkey') + return + } + if (!account_uuid) { + invalid_request(res, 'Missing account_uuid') + return + } + app.dbs.pubkeys_to_user_uuids.put(pubkey, account_uuid.toUpperCase()) + + json_response(res, { success: true }) + }); + } } function get_allowed_cors_origins() { diff --git a/src/user_management.js b/src/user_management.js index df309e5..496ef1d 100644 --- a/src/user_management.js +++ b/src/user_management.js @@ -152,4 +152,17 @@ function get_user_uuid(api, pubkey) { return uuid } -module.exports = { check_account, create_account, get_account_info_payload, bump_expiry, get_account, put_account, get_account_and_user_id, get_user_id_from_pubkey, get_user_uuid, bumpy_set_expiry } +function delete_account(api, pubkey) { + const user_id = get_user_id_from_pubkey(api, pubkey); + if (!user_id) { + return { delete_error: 'User ID not found for the given pubkey' }; + } + + api.dbs.accounts.remove(user_id); + api.dbs.pubkeys_to_user_ids.remove(pubkey); + api.dbs.pubkeys_to_user_uuids.remove(pubkey); + + return { delete_error: null }; +} + +module.exports = { check_account, create_account, get_account_info_payload, bump_expiry, get_account, put_account, get_account_and_user_id, get_user_id_from_pubkey, get_user_uuid, bumpy_set_expiry, delete_account }