Skip to content

Commit c18a693

Browse files
committed
Fix budget error handling, add retry guard and missing HTTP methods
- maxPrice check now throws L402BudgetExceededError (was L402Error) - Budget records actual paid amount from SDK instead of server-reported price - Added put/patch/delete convenience methods to L402Client - Retry after payment throws if server still returns 402 (prevents infinite loops) - Bump to 0.2.0 Made-with: Cursor
1 parent 728b7c8 commit c18a693

3 files changed

Lines changed: 106 additions & 14 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lnbot/l402",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "L402 Lightning payment middleware for Express.js — paywall any API in one line",
55
"type": "module",
66
"exports": {

src/client/fetch.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { LnBot } from "@lnbot/sdk";
22
import type { L402ClientOptions } from "../types.js";
33
import {
44
L402Error,
5+
L402BudgetExceededError,
56
L402PaymentFailedError,
67
} from "../errors.js";
78
import { parseChallenge, parseAuthorization } from "../server/headers.js";
@@ -16,6 +17,12 @@ export interface L402Client {
1617
get(url: string, init?: RequestInit): Promise<unknown>;
1718
/** POST + JSON parse with automatic L402 payment. */
1819
post(url: string, init?: RequestInit): Promise<unknown>;
20+
/** PUT + JSON parse with automatic L402 payment. */
21+
put(url: string, init?: RequestInit): Promise<unknown>;
22+
/** PATCH + JSON parse with automatic L402 payment. */
23+
patch(url: string, init?: RequestInit): Promise<unknown>;
24+
/** DELETE + JSON parse with automatic L402 payment. */
25+
delete(url: string, init?: RequestInit): Promise<unknown>;
1926
}
2027

2128
/**
@@ -67,7 +74,7 @@ export function client(ln: LnBot, options: L402ClientOptions = {}): L402Client {
6774

6875
// Step 4: Budget checks
6976
if (price > maxPrice) {
70-
throw new L402Error(
77+
throw new L402BudgetExceededError(
7178
`Price ${price} sats exceeds maxPrice ${maxPrice}`,
7279
);
7380
}
@@ -97,23 +104,33 @@ export function client(ln: LnBot, options: L402ClientOptions = {}): L402Client {
97104
: undefined,
98105
});
99106

100-
budget.record(price);
107+
budget.record(payment.amount ?? price);
101108

102109
// Step 7: Retry with L402 Authorization
103110
const retryHeaders = new Headers(init?.headers);
104111
retryHeaders.set("Authorization", payment.authorization);
105-
return globalThis.fetch(url, { ...init, headers: retryHeaders });
112+
const retry = await globalThis.fetch(url, { ...init, headers: retryHeaders });
113+
114+
if (retry.status === 402) {
115+
throw new L402PaymentFailedError(
116+
"Server returned 402 after successful payment",
117+
);
118+
}
119+
120+
return retry;
121+
}
122+
123+
async function jsonMethod(method: string, url: string, init?: RequestInit) {
124+
const res = await l402Fetch(url, { ...init, method });
125+
return res.json();
106126
}
107127

108128
return {
109129
fetch: l402Fetch,
110-
async get(url: string, init?: RequestInit) {
111-
const res = await l402Fetch(url, { ...init, method: "GET" });
112-
return res.json();
113-
},
114-
async post(url: string, init?: RequestInit) {
115-
const res = await l402Fetch(url, { ...init, method: "POST" });
116-
return res.json();
117-
},
130+
get: (url: string, init?: RequestInit) => jsonMethod("GET", url, init),
131+
post: (url: string, init?: RequestInit) => jsonMethod("POST", url, init),
132+
put: (url: string, init?: RequestInit) => jsonMethod("PUT", url, init),
133+
patch: (url: string, init?: RequestInit) => jsonMethod("PATCH", url, init),
134+
delete: (url: string, init?: RequestInit) => jsonMethod("DELETE", url, init),
118135
};
119136
}

tests/client.test.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ describe("L402 client", () => {
182182
expect(res2.status).toBe(200);
183183
});
184184

185-
it("throws L402Error when price exceeds maxPrice", async () => {
185+
it("throws L402BudgetExceededError when price exceeds maxPrice", async () => {
186186
const wwwAuth = 'L402 macaroon="mac", invoice="inv"';
187187
globalThis.fetch = vi.fn().mockImplementation(() =>
188188
Promise.resolve(
@@ -192,7 +192,7 @@ describe("L402 client", () => {
192192

193193
const c = client(ln, { maxPrice: 100 });
194194

195-
await expect(c.fetch(URL_PREMIUM)).rejects.toThrow(L402Error);
195+
await expect(c.fetch(URL_PREMIUM)).rejects.toThrow(L402BudgetExceededError);
196196
await expect(c.fetch(URL_PREMIUM)).rejects.toThrow("exceeds maxPrice");
197197
expect(ln.l402.pay).not.toHaveBeenCalled();
198198
});
@@ -293,6 +293,81 @@ describe("L402 client", () => {
293293
expect(call[1]?.method).toBe("POST");
294294
});
295295

296+
it("put() sends PUT method and returns parsed JSON", async () => {
297+
globalThis.fetch = vi.fn().mockResolvedValue(
298+
jsonResponse(200, { updated: true }),
299+
);
300+
301+
const c = client(ln);
302+
const data = await c.put(URL_PREMIUM, {
303+
body: JSON.stringify({ name: "new" }),
304+
});
305+
306+
expect(data).toEqual({ updated: true });
307+
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
308+
expect(call[1]?.method).toBe("PUT");
309+
});
310+
311+
it("patch() sends PATCH method and returns parsed JSON", async () => {
312+
globalThis.fetch = vi.fn().mockResolvedValue(
313+
jsonResponse(200, { patched: true }),
314+
);
315+
316+
const c = client(ln);
317+
const data = await c.patch(URL_PREMIUM, {
318+
body: JSON.stringify({ field: "value" }),
319+
});
320+
321+
expect(data).toEqual({ patched: true });
322+
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
323+
expect(call[1]?.method).toBe("PATCH");
324+
});
325+
326+
it("delete() sends DELETE method and returns parsed JSON", async () => {
327+
globalThis.fetch = vi.fn().mockResolvedValue(
328+
jsonResponse(200, { deleted: true }),
329+
);
330+
331+
const c = client(ln);
332+
const data = await c.delete(URL_PREMIUM);
333+
334+
expect(data).toEqual({ deleted: true });
335+
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
336+
expect(call[1]?.method).toBe("DELETE");
337+
});
338+
339+
it("throws L402PaymentFailedError when retry after payment returns 402", async () => {
340+
const wwwAuth = 'L402 macaroon="mac", invoice="inv"';
341+
globalThis.fetch = vi.fn()
342+
.mockResolvedValueOnce(
343+
jsonResponse(402, { price: 10 }, { "www-authenticate": wwwAuth }),
344+
)
345+
.mockResolvedValueOnce(
346+
jsonResponse(402, { price: 10 }, { "www-authenticate": wwwAuth }),
347+
);
348+
349+
ln.l402.pay.mockResolvedValue({
350+
authorization: "L402 mac:pre",
351+
paymentHash: "hash",
352+
preimage: "pre",
353+
amount: 10,
354+
fee: 0,
355+
paymentNumber: 1,
356+
status: "settled",
357+
});
358+
359+
const c = client(ln);
360+
361+
await expect(c.fetch(URL_PREMIUM)).rejects.toSatisfy((err: unknown) => {
362+
expect(err).toBeInstanceOf(L402PaymentFailedError);
363+
expect((err as Error).message).toBe(
364+
"Server returned 402 after successful payment",
365+
);
366+
return true;
367+
});
368+
expect(ln.l402.pay).toHaveBeenCalledTimes(1);
369+
});
370+
296371
it("defaults maxPrice to 1000 sats", async () => {
297372
const wwwAuth = 'L402 macaroon="mac", invoice="inv"';
298373
globalThis.fetch = vi.fn().mockImplementation(() =>

0 commit comments

Comments
 (0)