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