@getalby/lightning-tools 8.1.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/402.cjs CHANGED
@@ -1259,6 +1259,28 @@ class Invoice {
1259
1259
  }
1260
1260
  }
1261
1261
 
1262
+ /** Apply a previously-obtained credential to the outgoing request headers. */
1263
+ const applyCredentials = (headers, credentials) => {
1264
+ headers.set(credentials.header, credentials.value);
1265
+ };
1266
+ /** Attach payment metadata to a response and return it (typed). */
1267
+ const attachPayment = (response, payment) => {
1268
+ if (payment) {
1269
+ response.payment = payment;
1270
+ }
1271
+ return response;
1272
+ };
1273
+ /** Payment metadata describing a request authorized with a reused credential. */
1274
+ const reusedCredentialPayment = (credentials) => credentials ? { paid: false, amount: 0, credentials } : undefined;
1275
+ /** Satoshi amount of a BOLT11 invoice (0 when it cannot be decoded). */
1276
+ const getInvoiceAmount = (invoice) => {
1277
+ try {
1278
+ return new Invoice({ pr: invoice }).satoshi;
1279
+ }
1280
+ catch (_) {
1281
+ return 0;
1282
+ }
1283
+ };
1262
1284
  function createGuardedWallet(wallet, maxAmountSats) {
1263
1285
  return {
1264
1286
  payInvoice: async (args) => {
@@ -1310,6 +1332,9 @@ const handleL402Payment = async (l402Header, url, fetchArgs, headers, wallet) =>
1310
1332
  const details = parseL402(l402Header);
1311
1333
  const token = details.token || details.macaroon;
1312
1334
  const invoice = details.invoice;
1335
+ // Preserve the scheme the server challenged with (L402 or LSAT) so the
1336
+ // retry's Authorization header matches what the server expects.
1337
+ const scheme = /^\s*LSAT\b/i.test(l402Header) ? "LSAT" : "L402";
1313
1338
  if (!token) {
1314
1339
  throw new Error("L402: missing token/macaroon in WWW-Authenticate header");
1315
1340
  }
@@ -1317,8 +1342,16 @@ const handleL402Payment = async (l402Header, url, fetchArgs, headers, wallet) =>
1317
1342
  throw new Error("L402: missing invoice in WWW-Authenticate header");
1318
1343
  }
1319
1344
  const invResp = await wallet.payInvoice({ invoice });
1320
- headers.set("Authorization", `L402 ${token}:${invResp.preimage}`);
1321
- return fetch(url, fetchArgs);
1345
+ const value = `${scheme} ${token}:${invResp.preimage}`;
1346
+ headers.set("Authorization", value);
1347
+ const response = await fetch(url, fetchArgs);
1348
+ return attachPayment(response, {
1349
+ paid: true,
1350
+ amount: getInvoiceAmount(invoice),
1351
+ feesPaid: invResp.fees_paid,
1352
+ preimage: invResp.preimage,
1353
+ credentials: { header: "Authorization", value },
1354
+ });
1322
1355
  };
1323
1356
  const fetchWithL402 = async (url, fetchArgs, options) => {
1324
1357
  const wallet = options.wallet;
@@ -1332,6 +1365,15 @@ const fetchWithL402 = async (url, fetchArgs, options) => {
1332
1365
  fetchArgs.mode = "cors";
1333
1366
  const headers = new Headers(fetchArgs.headers ?? undefined);
1334
1367
  fetchArgs.headers = headers;
1368
+ // If the caller supplied a credential, we MUST use it and never pay again —
1369
+ // even if the server still responds with a 402. Re-paying here is the exact
1370
+ // double-charge this API exists to prevent; the caller decides what to do
1371
+ // with a rejected credential (retry after settlement, top up, etc.).
1372
+ if (options.credentials) {
1373
+ applyCredentials(headers, options.credentials);
1374
+ const reusedResp = await fetch(url, fetchArgs);
1375
+ return attachPayment(reusedResp, reusedCredentialPayment(options.credentials));
1376
+ }
1335
1377
  const initResp = await fetch(url, fetchArgs);
1336
1378
  const header = initResp.headers.get("www-authenticate");
1337
1379
  if (!header) {
@@ -1352,7 +1394,7 @@ const buildX402PaymentSignature = (scheme, network, invoice, requirements) => {
1352
1394
  return btoa(unescape(encodeURIComponent(json)));
1353
1395
  };
1354
1396
 
1355
- const handleX402Payment = async (x402Header, url, fetchArgs, headers, wallet) => {
1397
+ const decodeX402Header = (x402Header) => {
1356
1398
  let parsed;
1357
1399
  try {
1358
1400
  parsed = JSON.parse(decodeURIComponent(escape(atob(x402Header))));
@@ -1363,9 +1405,31 @@ const handleX402Payment = async (x402Header, url, fetchArgs, headers, wallet) =>
1363
1405
  if (!Array.isArray(parsed.accepts) || parsed.accepts.length === 0) {
1364
1406
  throw new Error("x402: PAYMENT-REQUIRED header contains no payment options");
1365
1407
  }
1366
- const requirements = parsed.accepts.find((e) => {
1367
- return e.extra?.paymentMethod === "lightning";
1368
- });
1408
+ return { accepts: parsed.accepts };
1409
+ };
1410
+ /**
1411
+ * Probe a PAYMENT-REQUIRED header for a lightning-payable offer without
1412
+ * throwing. Returns the matching requirements, or null if the header has no
1413
+ * lightning entry (e.g. USDC-only endpoints) or is malformed. Used by the
1414
+ * top-level fetch402 dispatcher to decide whether to attempt payment or hand
1415
+ * the 402 back to the caller.
1416
+ */
1417
+ const findX402LightningRequirements = (x402Header) => {
1418
+ let accepts;
1419
+ try {
1420
+ ({ accepts } = decodeX402Header(x402Header));
1421
+ }
1422
+ catch (_) {
1423
+ return null;
1424
+ }
1425
+ const requirements = accepts.find((e) => e?.extra?.paymentMethod === "lightning");
1426
+ if (!requirements?.extra?.invoice)
1427
+ return null;
1428
+ return requirements;
1429
+ };
1430
+ const handleX402Payment = async (x402Header, url, fetchArgs, headers, wallet) => {
1431
+ const { accepts } = decodeX402Header(x402Header);
1432
+ const requirements = accepts.find((e) => e?.extra?.paymentMethod === "lightning");
1369
1433
  if (!requirements) {
1370
1434
  throw new Error("x402: unsupported x402 network, only Bitcoin lightning network is supported.");
1371
1435
  }
@@ -1376,9 +1440,17 @@ const handleX402Payment = async (x402Header, url, fetchArgs, headers, wallet) =>
1376
1440
  if (invoice.amountRaw != requirements.amount) {
1377
1441
  throw new Error(`Invalid invoice amount: ${invoice.amountRaw}. expected ${requirements.amount}`);
1378
1442
  }
1379
- await wallet.payInvoice({ invoice: invoice.paymentRequest });
1380
- headers.set("payment-signature", buildX402PaymentSignature(requirements.scheme, requirements.network, invoice.paymentRequest, requirements));
1381
- return fetch(url, fetchArgs);
1443
+ const invResp = await wallet.payInvoice({ invoice: invoice.paymentRequest });
1444
+ const value = buildX402PaymentSignature(requirements.scheme, requirements.network, invoice.paymentRequest, requirements);
1445
+ headers.set("payment-signature", value);
1446
+ const response = await fetch(url, fetchArgs);
1447
+ return attachPayment(response, {
1448
+ paid: true,
1449
+ amount: invoice.satoshi,
1450
+ feesPaid: invResp.fees_paid,
1451
+ preimage: invResp.preimage,
1452
+ credentials: { header: "payment-signature", value },
1453
+ });
1382
1454
  };
1383
1455
  const fetchWithX402 = async (url, fetchArgs, options) => {
1384
1456
  const wallet = options.wallet;
@@ -1389,6 +1461,15 @@ const fetchWithX402 = async (url, fetchArgs, options) => {
1389
1461
  fetchArgs.mode = "cors";
1390
1462
  const headers = new Headers(fetchArgs.headers ?? undefined);
1391
1463
  fetchArgs.headers = headers;
1464
+ // If the caller supplied a credential, we MUST use it and never pay again —
1465
+ // even if the server still responds with a 402. Re-paying here is the exact
1466
+ // double-charge this API exists to prevent; the caller decides what to do
1467
+ // with a rejected credential (retry after settlement, top up, etc.).
1468
+ if (options.credentials) {
1469
+ applyCredentials(headers, options.credentials);
1470
+ const reusedResp = await fetch(url, fetchArgs);
1471
+ return attachPayment(reusedResp, reusedCredentialPayment(options.credentials));
1472
+ }
1392
1473
  const initResp = await fetch(url, fetchArgs);
1393
1474
  const header = initResp.headers.get("PAYMENT-REQUIRED");
1394
1475
  if (!header) {
@@ -1535,8 +1616,16 @@ const handleMppChargePayment = async (wwwAuthHeader, url, fetchArgs, headers, wa
1535
1616
  const invResp = await wallet.payInvoice({ invoice });
1536
1617
  // Per spec: Authorization: Payment <base64url-token> (single token, no wrapper)
1537
1618
  const credential = buildMppCredential(challenge, invResp.preimage);
1538
- headers.set("Authorization", `Payment ${credential}`);
1539
- return fetch(url, fetchArgs);
1619
+ const value = `Payment ${credential}`;
1620
+ headers.set("Authorization", value);
1621
+ const response = await fetch(url, fetchArgs);
1622
+ return attachPayment(response, {
1623
+ paid: true,
1624
+ amount: getInvoiceAmount(invoice),
1625
+ feesPaid: invResp.fees_paid,
1626
+ preimage: invResp.preimage,
1627
+ credentials: { header: "Authorization", value },
1628
+ });
1540
1629
  };
1541
1630
  /**
1542
1631
  * Fetch a resource protected by the draft-lightning-charge-00 payment
@@ -1547,9 +1636,11 @@ const handleMppChargePayment = async (wwwAuthHeader, url, fetchArgs, headers, wa
1547
1636
  * the function pays the embedded BOLT11 invoice and retries with the
1548
1637
  * resulting preimage as the credential.
1549
1638
  *
1550
- * Note: lightning-charge uses consume-once challenge semantics each
1551
- * challenge embeds a fresh invoice, so paid credentials cannot be reused.
1552
- * The `store` option is accepted for API consistency but is not used.
1639
+ * Pass a previous credential via `options.credentials` to reuse it (e.g. when
1640
+ * polling); the credential is applied and the function NEVER pays again, even
1641
+ * if the server still responds with a 402 (that response is returned as-is).
1642
+ * Note: lightning-charge typically uses consume-once challenge semantics, so a
1643
+ * reused credential is only accepted by servers that explicitly support it.
1553
1644
  */
1554
1645
  const fetchWithMpp = async (url, fetchArgs, options) => {
1555
1646
  const wallet = options.wallet;
@@ -1563,6 +1654,15 @@ const fetchWithMpp = async (url, fetchArgs, options) => {
1563
1654
  fetchArgs.mode = "cors";
1564
1655
  const headers = new Headers(fetchArgs.headers ?? undefined);
1565
1656
  fetchArgs.headers = headers;
1657
+ // If the caller supplied a credential, we MUST use it and never pay again —
1658
+ // even if the server still responds with a 402. Re-paying here is the exact
1659
+ // double-charge this API exists to prevent; the caller decides what to do
1660
+ // with a rejected credential (retry after settlement, top up, etc.).
1661
+ if (options.credentials) {
1662
+ applyCredentials(headers, options.credentials);
1663
+ const reusedResp = await fetch(url, fetchArgs);
1664
+ return attachPayment(reusedResp, reusedCredentialPayment(options.credentials));
1665
+ }
1566
1666
  const initResp = await fetch(url, fetchArgs);
1567
1667
  const wwwAuthHeader = initResp.headers.get("www-authenticate");
1568
1668
  if (!wwwAuthHeader ||
@@ -1583,20 +1683,38 @@ const fetch402 = async (url, fetchArgs, options) => {
1583
1683
  fetchArgs.mode = "cors";
1584
1684
  const headers = new Headers(fetchArgs.headers ?? undefined);
1585
1685
  fetchArgs.headers = headers;
1686
+ // If the caller supplied a credential, we MUST use it and never pay again —
1687
+ // even if the server still responds with a 402. Re-paying here is the exact
1688
+ // double-charge this API exists to prevent; the caller decides what to do
1689
+ // with a rejected credential (retry after settlement, top up, etc.).
1690
+ if (options.credentials) {
1691
+ applyCredentials(headers, options.credentials);
1692
+ const reusedResp = await fetch(url, fetchArgs);
1693
+ return attachPayment(reusedResp, reusedCredentialPayment(options.credentials));
1694
+ }
1586
1695
  const initResp = await fetch(url, fetchArgs);
1696
+ // L402 / LSAT: dedicated scheme, dispatch directly.
1587
1697
  const wwwAuthHeader = initResp.headers.get("www-authenticate");
1588
1698
  if (wwwAuthHeader) {
1589
1699
  const trimmed = wwwAuthHeader.trimStart().toLowerCase();
1590
- if (trimmed.startsWith("payment")) {
1591
- return handleMppChargePayment(wwwAuthHeader, url, fetchArgs, headers, wallet);
1592
- }
1593
1700
  if (trimmed.startsWith("l402") || trimmed.startsWith("lsat")) {
1594
1701
  return handleL402Payment(wwwAuthHeader, url, fetchArgs, headers, wallet);
1595
1702
  }
1596
- throw new Error(`fetch402: unsupported WWW-Authenticate scheme: ${wwwAuthHeader}`);
1597
1703
  }
1704
+ // A server may advertise multiple payment options at once (e.g. an MPP
1705
+ // USDC challenge in WWW-Authenticate alongside an x402 PAYMENT-REQUIRED
1706
+ // header that lists both USDC and lightning). Try each lightning-payable
1707
+ // handler in turn; only if none matches do we hand the original 402 back
1708
+ // to the caller so they can decide what to do with non-lightning offers.
1709
+ // 1. MPP-lightning challenge (Payment method="lightning" intent="charge").
1710
+ // parseMppChallenge returns null for any other method, which lets us
1711
+ // fall through to x402 instead of throwing.
1712
+ if (wwwAuthHeader && parseMppChallenge(wwwAuthHeader)) {
1713
+ return handleMppChargePayment(wwwAuthHeader, url, fetchArgs, headers, wallet);
1714
+ }
1715
+ // 2. x402 PAYMENT-REQUIRED with a lightning entry in `accepts`.
1598
1716
  const x402Header = initResp.headers.get("PAYMENT-REQUIRED");
1599
- if (x402Header) {
1717
+ if (x402Header && findX402LightningRequirements(x402Header)) {
1600
1718
  return handleX402Payment(x402Header, url, fetchArgs, headers, wallet);
1601
1719
  }
1602
1720
  return initResp;
@@ -1681,14 +1799,19 @@ function parseL402Authorization(input) {
1681
1799
  };
1682
1800
  }
1683
1801
 
1802
+ exports.applyCredentials = applyCredentials;
1803
+ exports.attachPayment = attachPayment;
1684
1804
  exports.createGuardedWallet = createGuardedWallet;
1685
1805
  exports.fetch402 = fetch402;
1686
1806
  exports.fetchWithL402 = fetchWithL402;
1687
1807
  exports.fetchWithMpp = fetchWithMpp;
1688
1808
  exports.fetchWithX402 = fetchWithX402;
1809
+ exports.findX402LightningRequirements = findX402LightningRequirements;
1810
+ exports.getInvoiceAmount = getInvoiceAmount;
1689
1811
  exports.issueL402Macaroon = issueL402Macaroon;
1690
1812
  exports.makeL402AuthenticateHeader = makeL402AuthenticateHeader;
1691
1813
  exports.parseL402 = parseL402;
1692
1814
  exports.parseL402Authorization = parseL402Authorization;
1815
+ exports.reusedCredentialPayment = reusedCredentialPayment;
1693
1816
  exports.verifyL402Macaroon = verifyL402Macaroon;
1694
1817
  //# sourceMappingURL=402.cjs.map