l402-server 0.1.1 → 0.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/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # l402-server
2
2
 
3
+ [![Discord](https://img.shields.io/discord/1405389254892195951?label=community&logo=discord&color=5865F2)](https://discord.gg/rX7NxHY8vx)
4
+
5
+
3
6
  [![npm](https://img.shields.io/npm/v/l402-server.svg)](https://www.npmjs.com/package/l402-server)
4
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
8
 
@@ -83,13 +86,17 @@ Returns: `{ invoice, macaroon, paymentHash, expiresAt, resource, priceSats, mppC
83
86
 
84
87
  ```ts
85
88
  {
86
- macaroon?: string; // required for L402; omit only for MPP
89
+ macaroon?: string; // required for L402; omit only for MPP
87
90
  preimage: string;
91
+ resource?: string; // optional: enforce the macaroon's path caveat server-side
92
+ amountSats?: number; // optional: enforce the amount_sats caveat server-side (≥ 1)
88
93
  }
89
94
  ```
90
95
 
91
96
  Returns: `{ valid, error?, resource?, merchantId?, amountSats?, paymentHash? }`. Inspect `result.valid` — the producer API returns 200 OK for both valid and invalid tokens.
92
97
 
98
+ Pass `resource` (typically the incoming request path) and/or `amountSats` (your endpoint's price) to have Lightning Enable enforce the macaroon's `path` and `amount_sats` caveats during verification — a token bound to a different resource or price tier comes back `valid: false`. If you omit them, the caveat values are still returned on the result but **not** enforced; the comparison is then your responsibility.
99
+
93
100
  ### Errors
94
101
 
95
102
  All SDK errors extend `L402ServerError`:
@@ -108,10 +115,13 @@ Lightning Enable supports two integration shapes:
108
115
  - **Proxy mode** — point Lightning Enable at your API URL; we forward authenticated requests on your behalf. Best for public APIs or quick experiments. [Setup walkthrough](https://docs.lightningenable.com/products/l402-microtransactions/proxy-setup-walkthrough).
109
116
  - **Native mode** — install this SDK in your existing API. Lightning Enable handles payment; your API handles everything else. Best for commercial APIs with their own auth, observability, or sensitive infrastructure. **This SDK is the Native mode building block.**
110
117
 
111
- Framework-specific middleware that wraps this SDK is in development:
118
+ Framework-specific middleware that wraps the server SDKs is available today:
119
+
120
+ - [`l402-express`](https://www.npmjs.com/package/l402-express) — Express middleware (wraps this SDK)
121
+ - [`L402Server.AspNetCore`](https://www.nuget.org/packages/L402Server.AspNetCore) — ASP.NET Core middleware (wraps the [`L402Server`](https://www.nuget.org/packages/L402Server) .NET SDK)
122
+
123
+ On the roadmap:
112
124
 
113
- - `l402-express` — Express middleware
114
- - ASP.NET Core middleware (separate package)
115
125
  - FastAPI dependency (separate package)
116
126
  - `l402-server-go` — Go middleware (Phase 2 of the roadmap)
117
127
 
package/dist/index.cjs CHANGED
@@ -169,7 +169,9 @@ var L402Server = class {
169
169
  * inspect `result.valid` rather than relying on HTTP status. Non-200
170
170
  * responses indicate a higher-level problem (auth, plan, transport).
171
171
  *
172
- * @param args - macaroon (required for L402; omit only for MPP) + preimage.
172
+ * @param args - macaroon (required for L402; omit only for MPP) + preimage,
173
+ * plus optional `resource` / `amountSats` for server-side caveat
174
+ * enforcement against the current request.
173
175
  * @returns The {@link VerificationResult}.
174
176
  * @throws {@link L402AuthError} on 401 (invalid API key).
175
177
  * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).
@@ -180,6 +182,16 @@ var L402Server = class {
180
182
  if (!args.preimage || args.preimage.trim().length === 0) {
181
183
  throw new Error("verifyToken: `preimage` is required.");
182
184
  }
185
+ if (args.resource !== void 0 && args.resource.trim().length === 0) {
186
+ throw new Error(
187
+ "verifyToken: `resource` must be non-empty when provided; omit it entirely to skip path-caveat enforcement."
188
+ );
189
+ }
190
+ if (args.amountSats !== void 0 && (!Number.isFinite(args.amountSats) || args.amountSats < 1)) {
191
+ throw new Error(
192
+ "verifyToken: `amountSats` must be \u2265 1 when provided; omit it entirely to skip amount-caveat enforcement."
193
+ );
194
+ }
183
195
  const headers = {
184
196
  "Content-Type": "application/json",
185
197
  "X-API-Key": this.apiKey,
@@ -187,7 +199,9 @@ var L402Server = class {
187
199
  };
188
200
  const body = JSON.stringify({
189
201
  macaroon: args.macaroon,
190
- preimage: args.preimage
202
+ preimage: args.preimage,
203
+ resource: args.resource,
204
+ amountSats: args.amountSats
191
205
  });
192
206
  const response = await this.request(
193
207
  "/api/l402/challenges/verify",
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/errors.ts","../src/L402Server.ts"],"sourcesContent":["export { L402Server } from \"./L402Server.js\";\nexport {\n L402ApiError,\n L402AuthError,\n L402NetworkError,\n L402PlanError,\n L402ServerError,\n} from \"./errors.js\";\nexport type {\n Challenge,\n CreateChallengeArgs,\n L402ServerOptions,\n VerificationResult,\n VerifyTokenArgs,\n} from \"./types.js\";\n","/**\n * Base error class for all SDK-thrown errors. Distinguishable from arbitrary\n * `Error` instances via `instanceof L402ServerError`.\n */\nexport class L402ServerError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"L402ServerError\";\n }\n}\n\n/**\n * Thrown on `401 Unauthorized` from the producer API. Almost always means\n * the merchant API key is missing, malformed, expired, or revoked.\n */\nexport class L402AuthError extends L402ServerError {\n constructor(message: string = \"Merchant API key is missing or invalid.\") {\n super(message);\n this.name = \"L402AuthError\";\n }\n}\n\n/**\n * Thrown on `403 Forbidden` from the producer API. Means the merchant\n * exists and the key is valid, but L402 is not enabled on their plan.\n * The merchant needs to upgrade to an Agentic Commerce plan.\n */\nexport class L402PlanError extends L402ServerError {\n /**\n * The plan tier currently on the merchant (e.g., \"starter\").\n * Populated when the server includes it in the error payload.\n */\n readonly currentPlan?: string;\n\n constructor(\n message: string = \"L402 is not enabled on this merchant's plan.\",\n currentPlan?: string,\n ) {\n super(message);\n this.name = \"L402PlanError\";\n this.currentPlan = currentPlan;\n }\n}\n\n/**\n * Thrown for transport-level failures: timeout, DNS error, TLS error,\n * unreachable host. The `cause` carries the original error.\n */\nexport class L402NetworkError extends L402ServerError {\n constructor(message: string, cause?: unknown) {\n super(message, { cause });\n this.name = \"L402NetworkError\";\n }\n}\n\n/**\n * Thrown when the server returns a non-success status that doesn't map to\n * a more specific error class above (e.g., 400 with a request-validation\n * problem, 500 from upstream wallet failure, 429 from rate limiting).\n */\nexport class L402ApiError extends L402ServerError {\n /**\n * HTTP status code returned by the producer API.\n */\n readonly statusCode: number;\n\n /**\n * Raw response body, useful for debugging. May be a parsed object or a\n * string if parsing failed.\n */\n readonly responseBody?: unknown;\n\n constructor(\n statusCode: number,\n message: string,\n responseBody?: unknown,\n ) {\n super(message);\n this.name = \"L402ApiError\";\n this.statusCode = statusCode;\n this.responseBody = responseBody;\n }\n}\n","import {\n L402ApiError,\n L402AuthError,\n L402NetworkError,\n L402PlanError,\n} from \"./errors.js\";\nimport type {\n Challenge,\n CreateChallengeArgs,\n L402ServerOptions,\n VerificationResult,\n VerifyTokenArgs,\n} from \"./types.js\";\n\nconst DEFAULT_BASE_URL = \"https://api.lightningenable.com\";\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/**\n * Server-side client for Lightning Enable's L402 producer API. Wraps two\n * endpoints:\n *\n * - {@link createChallenge} → `POST /api/l402/challenges` — mint a\n * Lightning invoice + macaroon for a given resource and price.\n * - {@link verifyToken} → `POST /api/l402/challenges/verify` — validate\n * an incoming L402 token (macaroon + preimage).\n *\n * **No protocol logic lives in this SDK.** The Lightning Enable backend\n * signs macaroons, mints invoices, verifies preimages, and tracks consumed\n * tokens for replay protection. The SDK is purely an HTTP client with\n * typed inputs/outputs.\n *\n * @example\n * ```ts\n * import { L402Server } from \"l402-server\";\n *\n * const l402 = new L402Server({\n * apiKey: process.env.LIGHTNING_ENABLE_API_KEY!,\n * });\n *\n * // On an unauthenticated incoming request:\n * const challenge = await l402.createChallenge({\n * resource: \"/api/premium/weather\",\n * priceSats: 100,\n * description: \"Premium weather forecast\",\n * });\n *\n * // Send back as 402 Payment Required with the challenge headers.\n *\n * // When client comes back with Authorization: L402 mac:preimage,\n * // parse and verify:\n * const verification = await l402.verifyToken({\n * macaroon: parsedMacaroon,\n * preimage: parsedPreimage,\n * });\n * if (verification.valid) {\n * // Serve the response.\n * }\n * ```\n */\nexport class L402Server {\n private readonly apiKey: string;\n private readonly baseUrl: string;\n private readonly timeoutMs: number;\n private readonly fetchImpl: typeof fetch;\n\n constructor(options: L402ServerOptions) {\n if (!options.apiKey || options.apiKey.trim().length === 0) {\n throw new Error(\n \"L402Server: `apiKey` is required. Get one from your Lightning Enable dashboard.\",\n );\n }\n if (/^\\$\\{[^}]+\\}$/.test(options.apiKey.trim())) {\n throw new Error(\n `L402Server: \\`apiKey\\` looks like an unresolved environment-variable placeholder (${options.apiKey.trim()}). ` +\n `This usually means a parent shell exported the literal string \\\"\\${VAR_NAME}\\\" instead of the substituted value. ` +\n `Common sources: settings.json/launch.json with unrendered \\${env:NAME}, a Dockerfile ENV line, or a chained .env loader. ` +\n `Fix by setting LIGHTNING_ENABLE_API_KEY directly to the real key, or by clearing the placeholder so the SDK reads the right value.`,\n );\n }\n\n this.apiKey = options.apiKey;\n this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.fetchImpl = options.fetch ?? fetch;\n }\n\n /**\n * Mint an L402 challenge — a Lightning invoice plus a macaroon scoped to\n * the given resource. Present this to the requesting client/agent in a\n * `402 Payment Required` response. Once they pay the invoice and obtain\n * the preimage, they will retry the request with\n * `Authorization: L402 <macaroon>:<preimage>`.\n *\n * @param args - resource path, price in sats, optional description and idempotency key.\n * @returns The {@link Challenge} containing the invoice, macaroon, and metadata.\n * @throws {@link L402AuthError} on 401 (invalid API key).\n * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).\n * @throws {@link L402ApiError} on other non-2xx responses.\n * @throws {@link L402NetworkError} on timeout or transport failure.\n */\n async createChallenge(args: CreateChallengeArgs): Promise<Challenge> {\n if (!args.resource || args.resource.trim().length === 0) {\n throw new Error(\"createChallenge: `resource` is required.\");\n }\n if (!Number.isFinite(args.priceSats) || args.priceSats < 1) {\n throw new Error(\"createChallenge: `priceSats` must be an integer ≥ 1.\");\n }\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"X-API-Key\": this.apiKey,\n Accept: \"application/json\",\n };\n if (args.idempotencyKey) {\n headers[\"X-Idempotency-Key\"] = args.idempotencyKey;\n }\n\n const body = JSON.stringify({\n resource: args.resource,\n priceSats: args.priceSats,\n description: args.description,\n });\n\n const response = await this.request(\n \"/api/l402/challenges\",\n \"POST\",\n headers,\n body,\n );\n\n if (response.status === 200) {\n const data = (await response.json()) as {\n invoice: string;\n macaroon: string;\n paymentHash: string;\n expiresAt: string;\n resource: string;\n priceSats: number;\n mppChallenge?: string | null;\n };\n return {\n invoice: data.invoice,\n macaroon: data.macaroon,\n paymentHash: data.paymentHash,\n expiresAt: data.expiresAt,\n resource: data.resource,\n priceSats: data.priceSats,\n mppChallenge: data.mppChallenge ?? undefined,\n };\n }\n\n await this.throwForStatus(response);\n // Unreachable — throwForStatus always throws on non-2xx.\n throw new L402ApiError(\n response.status,\n \"Unexpected response from L402 producer API.\",\n );\n }\n\n /**\n * Verify an L402 token. Returns a {@link VerificationResult} indicating\n * whether the token is valid plus metadata extracted from the macaroon\n * (resource, merchant ID, amount).\n *\n * The producer API returns `200 OK` for both valid and invalid tokens;\n * inspect `result.valid` rather than relying on HTTP status. Non-200\n * responses indicate a higher-level problem (auth, plan, transport).\n *\n * @param args - macaroon (required for L402; omit only for MPP) + preimage.\n * @returns The {@link VerificationResult}.\n * @throws {@link L402AuthError} on 401 (invalid API key).\n * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).\n * @throws {@link L402ApiError} on other non-2xx responses.\n * @throws {@link L402NetworkError} on timeout or transport failure.\n */\n async verifyToken(args: VerifyTokenArgs): Promise<VerificationResult> {\n if (!args.preimage || args.preimage.trim().length === 0) {\n throw new Error(\"verifyToken: `preimage` is required.\");\n }\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"X-API-Key\": this.apiKey,\n Accept: \"application/json\",\n };\n\n const body = JSON.stringify({\n macaroon: args.macaroon,\n preimage: args.preimage,\n });\n\n const response = await this.request(\n \"/api/l402/challenges/verify\",\n \"POST\",\n headers,\n body,\n );\n\n if (response.status === 200) {\n const data = (await response.json()) as {\n valid: boolean;\n resource?: string | null;\n merchantId?: number | null;\n amountSats?: number | null;\n paymentHash?: string | null;\n error?: string | null;\n };\n return {\n valid: data.valid,\n error: data.error ?? undefined,\n resource: data.resource ?? undefined,\n merchantId: data.merchantId ?? undefined,\n amountSats: data.amountSats ?? undefined,\n paymentHash: data.paymentHash ?? undefined,\n };\n }\n\n await this.throwForStatus(response);\n throw new L402ApiError(\n response.status,\n \"Unexpected response from L402 producer API.\",\n );\n }\n\n private async request(\n path: string,\n method: string,\n headers: Record<string, string>,\n body: string,\n ): Promise<Response> {\n const url = `${this.baseUrl}${path}`;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.timeoutMs);\n\n try {\n return await this.fetchImpl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n if ((err as { name?: string })?.name === \"AbortError\") {\n throw new L402NetworkError(\n `Request to ${url} timed out after ${this.timeoutMs}ms`,\n err,\n );\n }\n throw new L402NetworkError(\n `Network error talking to ${url}: ${(err as Error).message}`,\n err,\n );\n } finally {\n clearTimeout(timer);\n }\n }\n\n private async throwForStatus(response: Response): Promise<never> {\n let body: unknown;\n try {\n body = await response.json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = undefined;\n }\n }\n\n const errorMessage =\n (body as { error?: string; message?: string })?.error ??\n (body as { error?: string; message?: string })?.message ??\n `HTTP ${response.status} from ${response.url}`;\n\n if (response.status === 401) {\n throw new L402AuthError(errorMessage);\n }\n if (response.status === 403) {\n const currentPlan = (body as { current_plan?: string })?.current_plan;\n throw new L402PlanError(errorMessage, currentPlan);\n }\n throw new L402ApiError(response.status, errorMessage, body);\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/errors.ts","../src/L402Server.ts"],"sourcesContent":["export { L402Server } from \"./L402Server.js\";\nexport {\n L402ApiError,\n L402AuthError,\n L402NetworkError,\n L402PlanError,\n L402ServerError,\n} from \"./errors.js\";\nexport type {\n Challenge,\n CreateChallengeArgs,\n L402ServerOptions,\n VerificationResult,\n VerifyTokenArgs,\n} from \"./types.js\";\n","/**\n * Base error class for all SDK-thrown errors. Distinguishable from arbitrary\n * `Error` instances via `instanceof L402ServerError`.\n */\nexport class L402ServerError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"L402ServerError\";\n }\n}\n\n/**\n * Thrown on `401 Unauthorized` from the producer API. Almost always means\n * the merchant API key is missing, malformed, expired, or revoked.\n */\nexport class L402AuthError extends L402ServerError {\n constructor(message: string = \"Merchant API key is missing or invalid.\") {\n super(message);\n this.name = \"L402AuthError\";\n }\n}\n\n/**\n * Thrown on `403 Forbidden` from the producer API. Means the merchant\n * exists and the key is valid, but L402 is not enabled on their plan.\n * The merchant needs to upgrade to an Agentic Commerce plan.\n */\nexport class L402PlanError extends L402ServerError {\n /**\n * The plan tier currently on the merchant (e.g., \"starter\").\n * Populated when the server includes it in the error payload.\n */\n readonly currentPlan?: string;\n\n constructor(\n message: string = \"L402 is not enabled on this merchant's plan.\",\n currentPlan?: string,\n ) {\n super(message);\n this.name = \"L402PlanError\";\n this.currentPlan = currentPlan;\n }\n}\n\n/**\n * Thrown for transport-level failures: timeout, DNS error, TLS error,\n * unreachable host. The `cause` carries the original error.\n */\nexport class L402NetworkError extends L402ServerError {\n constructor(message: string, cause?: unknown) {\n super(message, { cause });\n this.name = \"L402NetworkError\";\n }\n}\n\n/**\n * Thrown when the server returns a non-success status that doesn't map to\n * a more specific error class above (e.g., 400 with a request-validation\n * problem, 500 from upstream wallet failure, 429 from rate limiting).\n */\nexport class L402ApiError extends L402ServerError {\n /**\n * HTTP status code returned by the producer API.\n */\n readonly statusCode: number;\n\n /**\n * Raw response body, useful for debugging. May be a parsed object or a\n * string if parsing failed.\n */\n readonly responseBody?: unknown;\n\n constructor(\n statusCode: number,\n message: string,\n responseBody?: unknown,\n ) {\n super(message);\n this.name = \"L402ApiError\";\n this.statusCode = statusCode;\n this.responseBody = responseBody;\n }\n}\n","import {\n L402ApiError,\n L402AuthError,\n L402NetworkError,\n L402PlanError,\n} from \"./errors.js\";\nimport type {\n Challenge,\n CreateChallengeArgs,\n L402ServerOptions,\n VerificationResult,\n VerifyTokenArgs,\n} from \"./types.js\";\n\nconst DEFAULT_BASE_URL = \"https://api.lightningenable.com\";\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/**\n * Server-side client for Lightning Enable's L402 producer API. Wraps two\n * endpoints:\n *\n * - {@link createChallenge} → `POST /api/l402/challenges` — mint a\n * Lightning invoice + macaroon for a given resource and price.\n * - {@link verifyToken} → `POST /api/l402/challenges/verify` — validate\n * an incoming L402 token (macaroon + preimage).\n *\n * **No protocol logic lives in this SDK.** The Lightning Enable backend\n * signs macaroons, mints invoices, verifies preimages, and tracks consumed\n * tokens for replay protection. The SDK is purely an HTTP client with\n * typed inputs/outputs.\n *\n * @example\n * ```ts\n * import { L402Server } from \"l402-server\";\n *\n * const l402 = new L402Server({\n * apiKey: process.env.LIGHTNING_ENABLE_API_KEY!,\n * });\n *\n * // On an unauthenticated incoming request:\n * const challenge = await l402.createChallenge({\n * resource: \"/api/premium/weather\",\n * priceSats: 100,\n * description: \"Premium weather forecast\",\n * });\n *\n * // Send back as 402 Payment Required with the challenge headers.\n *\n * // When client comes back with Authorization: L402 mac:preimage,\n * // parse and verify:\n * const verification = await l402.verifyToken({\n * macaroon: parsedMacaroon,\n * preimage: parsedPreimage,\n * });\n * if (verification.valid) {\n * // Serve the response.\n * }\n * ```\n */\nexport class L402Server {\n private readonly apiKey: string;\n private readonly baseUrl: string;\n private readonly timeoutMs: number;\n private readonly fetchImpl: typeof fetch;\n\n constructor(options: L402ServerOptions) {\n if (!options.apiKey || options.apiKey.trim().length === 0) {\n throw new Error(\n \"L402Server: `apiKey` is required. Get one from your Lightning Enable dashboard.\",\n );\n }\n if (/^\\$\\{[^}]+\\}$/.test(options.apiKey.trim())) {\n throw new Error(\n `L402Server: \\`apiKey\\` looks like an unresolved environment-variable placeholder (${options.apiKey.trim()}). ` +\n `This usually means a parent shell exported the literal string \\\"\\${VAR_NAME}\\\" instead of the substituted value. ` +\n `Common sources: settings.json/launch.json with unrendered \\${env:NAME}, a Dockerfile ENV line, or a chained .env loader. ` +\n `Fix by setting LIGHTNING_ENABLE_API_KEY directly to the real key, or by clearing the placeholder so the SDK reads the right value.`,\n );\n }\n\n this.apiKey = options.apiKey;\n this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.fetchImpl = options.fetch ?? fetch;\n }\n\n /**\n * Mint an L402 challenge — a Lightning invoice plus a macaroon scoped to\n * the given resource. Present this to the requesting client/agent in a\n * `402 Payment Required` response. Once they pay the invoice and obtain\n * the preimage, they will retry the request with\n * `Authorization: L402 <macaroon>:<preimage>`.\n *\n * @param args - resource path, price in sats, optional description and idempotency key.\n * @returns The {@link Challenge} containing the invoice, macaroon, and metadata.\n * @throws {@link L402AuthError} on 401 (invalid API key).\n * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).\n * @throws {@link L402ApiError} on other non-2xx responses.\n * @throws {@link L402NetworkError} on timeout or transport failure.\n */\n async createChallenge(args: CreateChallengeArgs): Promise<Challenge> {\n if (!args.resource || args.resource.trim().length === 0) {\n throw new Error(\"createChallenge: `resource` is required.\");\n }\n if (!Number.isFinite(args.priceSats) || args.priceSats < 1) {\n throw new Error(\"createChallenge: `priceSats` must be an integer ≥ 1.\");\n }\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"X-API-Key\": this.apiKey,\n Accept: \"application/json\",\n };\n if (args.idempotencyKey) {\n headers[\"X-Idempotency-Key\"] = args.idempotencyKey;\n }\n\n const body = JSON.stringify({\n resource: args.resource,\n priceSats: args.priceSats,\n description: args.description,\n });\n\n const response = await this.request(\n \"/api/l402/challenges\",\n \"POST\",\n headers,\n body,\n );\n\n if (response.status === 200) {\n const data = (await response.json()) as {\n invoice: string;\n macaroon: string;\n paymentHash: string;\n expiresAt: string;\n resource: string;\n priceSats: number;\n mppChallenge?: string | null;\n };\n return {\n invoice: data.invoice,\n macaroon: data.macaroon,\n paymentHash: data.paymentHash,\n expiresAt: data.expiresAt,\n resource: data.resource,\n priceSats: data.priceSats,\n mppChallenge: data.mppChallenge ?? undefined,\n };\n }\n\n await this.throwForStatus(response);\n // Unreachable — throwForStatus always throws on non-2xx.\n throw new L402ApiError(\n response.status,\n \"Unexpected response from L402 producer API.\",\n );\n }\n\n /**\n * Verify an L402 token. Returns a {@link VerificationResult} indicating\n * whether the token is valid plus metadata extracted from the macaroon\n * (resource, merchant ID, amount).\n *\n * The producer API returns `200 OK` for both valid and invalid tokens;\n * inspect `result.valid` rather than relying on HTTP status. Non-200\n * responses indicate a higher-level problem (auth, plan, transport).\n *\n * @param args - macaroon (required for L402; omit only for MPP) + preimage,\n * plus optional `resource` / `amountSats` for server-side caveat\n * enforcement against the current request.\n * @returns The {@link VerificationResult}.\n * @throws {@link L402AuthError} on 401 (invalid API key).\n * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).\n * @throws {@link L402ApiError} on other non-2xx responses.\n * @throws {@link L402NetworkError} on timeout or transport failure.\n */\n async verifyToken(args: VerifyTokenArgs): Promise<VerificationResult> {\n if (!args.preimage || args.preimage.trim().length === 0) {\n throw new Error(\"verifyToken: `preimage` is required.\");\n }\n // Mirror the producer API's validation so the caller gets an immediate,\n // descriptive error instead of a 400 round-trip. An empty-string\n // `resource` would otherwise silently disable path-caveat enforcement.\n if (args.resource !== undefined && args.resource.trim().length === 0) {\n throw new Error(\n \"verifyToken: `resource` must be non-empty when provided; omit it entirely to skip path-caveat enforcement.\",\n );\n }\n if (\n args.amountSats !== undefined &&\n (!Number.isFinite(args.amountSats) || args.amountSats < 1)\n ) {\n throw new Error(\n \"verifyToken: `amountSats` must be ≥ 1 when provided; omit it entirely to skip amount-caveat enforcement.\",\n );\n }\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"X-API-Key\": this.apiKey,\n Accept: \"application/json\",\n };\n\n // JSON.stringify drops undefined properties, so optional args are only\n // sent when the caller opted into server-side caveat enforcement.\n const body = JSON.stringify({\n macaroon: args.macaroon,\n preimage: args.preimage,\n resource: args.resource,\n amountSats: args.amountSats,\n });\n\n const response = await this.request(\n \"/api/l402/challenges/verify\",\n \"POST\",\n headers,\n body,\n );\n\n if (response.status === 200) {\n const data = (await response.json()) as {\n valid: boolean;\n resource?: string | null;\n merchantId?: number | null;\n amountSats?: number | null;\n paymentHash?: string | null;\n error?: string | null;\n };\n return {\n valid: data.valid,\n error: data.error ?? undefined,\n resource: data.resource ?? undefined,\n merchantId: data.merchantId ?? undefined,\n amountSats: data.amountSats ?? undefined,\n paymentHash: data.paymentHash ?? undefined,\n };\n }\n\n await this.throwForStatus(response);\n throw new L402ApiError(\n response.status,\n \"Unexpected response from L402 producer API.\",\n );\n }\n\n private async request(\n path: string,\n method: string,\n headers: Record<string, string>,\n body: string,\n ): Promise<Response> {\n const url = `${this.baseUrl}${path}`;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.timeoutMs);\n\n try {\n return await this.fetchImpl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n if ((err as { name?: string })?.name === \"AbortError\") {\n throw new L402NetworkError(\n `Request to ${url} timed out after ${this.timeoutMs}ms`,\n err,\n );\n }\n throw new L402NetworkError(\n `Network error talking to ${url}: ${(err as Error).message}`,\n err,\n );\n } finally {\n clearTimeout(timer);\n }\n }\n\n private async throwForStatus(response: Response): Promise<never> {\n let body: unknown;\n try {\n body = await response.json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = undefined;\n }\n }\n\n const errorMessage =\n (body as { error?: string; message?: string })?.error ??\n (body as { error?: string; message?: string })?.message ??\n `HTTP ${response.status} from ${response.url}`;\n\n if (response.status === 401) {\n throw new L402AuthError(errorMessage);\n }\n if (response.status === 403) {\n const currentPlan = (body as { current_plan?: string })?.current_plan;\n throw new L402PlanError(errorMessage, currentPlan);\n }\n throw new L402ApiError(response.status, errorMessage, body);\n }\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -124,6 +124,25 @@ interface VerifyTokenArgs {
124
124
  * Hex-encoded payment preimage (64 chars).
125
125
  */
126
126
  preimage: string;
127
+ /**
128
+ * Optional: the request path you are gating, compared against the
129
+ * macaroon's `path` caveat **server-side**. When provided, the producer
130
+ * API returns `valid: false` if the token was bound to a different
131
+ * resource. When omitted, the path caveat is read out (see
132
+ * {@link VerificationResult.resource}) but NOT enforced — you are
133
+ * responsible for the comparison. Must be non-empty when provided
134
+ * (the producer API rejects an empty string with 400).
135
+ */
136
+ resource?: string;
137
+ /**
138
+ * Optional: the price (in satoshis) your endpoint requires, compared
139
+ * against the macaroon's `amount_sats` caveat **server-side**. When
140
+ * provided, the producer API returns `valid: false` on a mismatch —
141
+ * prevents replaying a cheap token against a pricier endpoint. When
142
+ * omitted, the amount caveat is read out but NOT enforced. Must be
143
+ * ≥ 1 when provided.
144
+ */
145
+ amountSats?: number;
127
146
  }
128
147
  /**
129
148
  * Result from {@link L402Server.verifyToken}. The producer API returns
@@ -233,7 +252,9 @@ declare class L402Server {
233
252
  * inspect `result.valid` rather than relying on HTTP status. Non-200
234
253
  * responses indicate a higher-level problem (auth, plan, transport).
235
254
  *
236
- * @param args - macaroon (required for L402; omit only for MPP) + preimage.
255
+ * @param args - macaroon (required for L402; omit only for MPP) + preimage,
256
+ * plus optional `resource` / `amountSats` for server-side caveat
257
+ * enforcement against the current request.
237
258
  * @returns The {@link VerificationResult}.
238
259
  * @throws {@link L402AuthError} on 401 (invalid API key).
239
260
  * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).
package/dist/index.d.ts CHANGED
@@ -124,6 +124,25 @@ interface VerifyTokenArgs {
124
124
  * Hex-encoded payment preimage (64 chars).
125
125
  */
126
126
  preimage: string;
127
+ /**
128
+ * Optional: the request path you are gating, compared against the
129
+ * macaroon's `path` caveat **server-side**. When provided, the producer
130
+ * API returns `valid: false` if the token was bound to a different
131
+ * resource. When omitted, the path caveat is read out (see
132
+ * {@link VerificationResult.resource}) but NOT enforced — you are
133
+ * responsible for the comparison. Must be non-empty when provided
134
+ * (the producer API rejects an empty string with 400).
135
+ */
136
+ resource?: string;
137
+ /**
138
+ * Optional: the price (in satoshis) your endpoint requires, compared
139
+ * against the macaroon's `amount_sats` caveat **server-side**. When
140
+ * provided, the producer API returns `valid: false` on a mismatch —
141
+ * prevents replaying a cheap token against a pricier endpoint. When
142
+ * omitted, the amount caveat is read out but NOT enforced. Must be
143
+ * ≥ 1 when provided.
144
+ */
145
+ amountSats?: number;
127
146
  }
128
147
  /**
129
148
  * Result from {@link L402Server.verifyToken}. The producer API returns
@@ -233,7 +252,9 @@ declare class L402Server {
233
252
  * inspect `result.valid` rather than relying on HTTP status. Non-200
234
253
  * responses indicate a higher-level problem (auth, plan, transport).
235
254
  *
236
- * @param args - macaroon (required for L402; omit only for MPP) + preimage.
255
+ * @param args - macaroon (required for L402; omit only for MPP) + preimage,
256
+ * plus optional `resource` / `amountSats` for server-side caveat
257
+ * enforcement against the current request.
237
258
  * @returns The {@link VerificationResult}.
238
259
  * @throws {@link L402AuthError} on 401 (invalid API key).
239
260
  * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).
package/dist/index.js CHANGED
@@ -138,7 +138,9 @@ var L402Server = class {
138
138
  * inspect `result.valid` rather than relying on HTTP status. Non-200
139
139
  * responses indicate a higher-level problem (auth, plan, transport).
140
140
  *
141
- * @param args - macaroon (required for L402; omit only for MPP) + preimage.
141
+ * @param args - macaroon (required for L402; omit only for MPP) + preimage,
142
+ * plus optional `resource` / `amountSats` for server-side caveat
143
+ * enforcement against the current request.
142
144
  * @returns The {@link VerificationResult}.
143
145
  * @throws {@link L402AuthError} on 401 (invalid API key).
144
146
  * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).
@@ -149,6 +151,16 @@ var L402Server = class {
149
151
  if (!args.preimage || args.preimage.trim().length === 0) {
150
152
  throw new Error("verifyToken: `preimage` is required.");
151
153
  }
154
+ if (args.resource !== void 0 && args.resource.trim().length === 0) {
155
+ throw new Error(
156
+ "verifyToken: `resource` must be non-empty when provided; omit it entirely to skip path-caveat enforcement."
157
+ );
158
+ }
159
+ if (args.amountSats !== void 0 && (!Number.isFinite(args.amountSats) || args.amountSats < 1)) {
160
+ throw new Error(
161
+ "verifyToken: `amountSats` must be \u2265 1 when provided; omit it entirely to skip amount-caveat enforcement."
162
+ );
163
+ }
152
164
  const headers = {
153
165
  "Content-Type": "application/json",
154
166
  "X-API-Key": this.apiKey,
@@ -156,7 +168,9 @@ var L402Server = class {
156
168
  };
157
169
  const body = JSON.stringify({
158
170
  macaroon: args.macaroon,
159
- preimage: args.preimage
171
+ preimage: args.preimage,
172
+ resource: args.resource,
173
+ amountSats: args.amountSats
160
174
  });
161
175
  const response = await this.request(
162
176
  "/api/l402/challenges/verify",
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/errors.ts","../src/L402Server.ts"],"sourcesContent":["/**\n * Base error class for all SDK-thrown errors. Distinguishable from arbitrary\n * `Error` instances via `instanceof L402ServerError`.\n */\nexport class L402ServerError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"L402ServerError\";\n }\n}\n\n/**\n * Thrown on `401 Unauthorized` from the producer API. Almost always means\n * the merchant API key is missing, malformed, expired, or revoked.\n */\nexport class L402AuthError extends L402ServerError {\n constructor(message: string = \"Merchant API key is missing or invalid.\") {\n super(message);\n this.name = \"L402AuthError\";\n }\n}\n\n/**\n * Thrown on `403 Forbidden` from the producer API. Means the merchant\n * exists and the key is valid, but L402 is not enabled on their plan.\n * The merchant needs to upgrade to an Agentic Commerce plan.\n */\nexport class L402PlanError extends L402ServerError {\n /**\n * The plan tier currently on the merchant (e.g., \"starter\").\n * Populated when the server includes it in the error payload.\n */\n readonly currentPlan?: string;\n\n constructor(\n message: string = \"L402 is not enabled on this merchant's plan.\",\n currentPlan?: string,\n ) {\n super(message);\n this.name = \"L402PlanError\";\n this.currentPlan = currentPlan;\n }\n}\n\n/**\n * Thrown for transport-level failures: timeout, DNS error, TLS error,\n * unreachable host. The `cause` carries the original error.\n */\nexport class L402NetworkError extends L402ServerError {\n constructor(message: string, cause?: unknown) {\n super(message, { cause });\n this.name = \"L402NetworkError\";\n }\n}\n\n/**\n * Thrown when the server returns a non-success status that doesn't map to\n * a more specific error class above (e.g., 400 with a request-validation\n * problem, 500 from upstream wallet failure, 429 from rate limiting).\n */\nexport class L402ApiError extends L402ServerError {\n /**\n * HTTP status code returned by the producer API.\n */\n readonly statusCode: number;\n\n /**\n * Raw response body, useful for debugging. May be a parsed object or a\n * string if parsing failed.\n */\n readonly responseBody?: unknown;\n\n constructor(\n statusCode: number,\n message: string,\n responseBody?: unknown,\n ) {\n super(message);\n this.name = \"L402ApiError\";\n this.statusCode = statusCode;\n this.responseBody = responseBody;\n }\n}\n","import {\n L402ApiError,\n L402AuthError,\n L402NetworkError,\n L402PlanError,\n} from \"./errors.js\";\nimport type {\n Challenge,\n CreateChallengeArgs,\n L402ServerOptions,\n VerificationResult,\n VerifyTokenArgs,\n} from \"./types.js\";\n\nconst DEFAULT_BASE_URL = \"https://api.lightningenable.com\";\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/**\n * Server-side client for Lightning Enable's L402 producer API. Wraps two\n * endpoints:\n *\n * - {@link createChallenge} → `POST /api/l402/challenges` — mint a\n * Lightning invoice + macaroon for a given resource and price.\n * - {@link verifyToken} → `POST /api/l402/challenges/verify` — validate\n * an incoming L402 token (macaroon + preimage).\n *\n * **No protocol logic lives in this SDK.** The Lightning Enable backend\n * signs macaroons, mints invoices, verifies preimages, and tracks consumed\n * tokens for replay protection. The SDK is purely an HTTP client with\n * typed inputs/outputs.\n *\n * @example\n * ```ts\n * import { L402Server } from \"l402-server\";\n *\n * const l402 = new L402Server({\n * apiKey: process.env.LIGHTNING_ENABLE_API_KEY!,\n * });\n *\n * // On an unauthenticated incoming request:\n * const challenge = await l402.createChallenge({\n * resource: \"/api/premium/weather\",\n * priceSats: 100,\n * description: \"Premium weather forecast\",\n * });\n *\n * // Send back as 402 Payment Required with the challenge headers.\n *\n * // When client comes back with Authorization: L402 mac:preimage,\n * // parse and verify:\n * const verification = await l402.verifyToken({\n * macaroon: parsedMacaroon,\n * preimage: parsedPreimage,\n * });\n * if (verification.valid) {\n * // Serve the response.\n * }\n * ```\n */\nexport class L402Server {\n private readonly apiKey: string;\n private readonly baseUrl: string;\n private readonly timeoutMs: number;\n private readonly fetchImpl: typeof fetch;\n\n constructor(options: L402ServerOptions) {\n if (!options.apiKey || options.apiKey.trim().length === 0) {\n throw new Error(\n \"L402Server: `apiKey` is required. Get one from your Lightning Enable dashboard.\",\n );\n }\n if (/^\\$\\{[^}]+\\}$/.test(options.apiKey.trim())) {\n throw new Error(\n `L402Server: \\`apiKey\\` looks like an unresolved environment-variable placeholder (${options.apiKey.trim()}). ` +\n `This usually means a parent shell exported the literal string \\\"\\${VAR_NAME}\\\" instead of the substituted value. ` +\n `Common sources: settings.json/launch.json with unrendered \\${env:NAME}, a Dockerfile ENV line, or a chained .env loader. ` +\n `Fix by setting LIGHTNING_ENABLE_API_KEY directly to the real key, or by clearing the placeholder so the SDK reads the right value.`,\n );\n }\n\n this.apiKey = options.apiKey;\n this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.fetchImpl = options.fetch ?? fetch;\n }\n\n /**\n * Mint an L402 challenge — a Lightning invoice plus a macaroon scoped to\n * the given resource. Present this to the requesting client/agent in a\n * `402 Payment Required` response. Once they pay the invoice and obtain\n * the preimage, they will retry the request with\n * `Authorization: L402 <macaroon>:<preimage>`.\n *\n * @param args - resource path, price in sats, optional description and idempotency key.\n * @returns The {@link Challenge} containing the invoice, macaroon, and metadata.\n * @throws {@link L402AuthError} on 401 (invalid API key).\n * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).\n * @throws {@link L402ApiError} on other non-2xx responses.\n * @throws {@link L402NetworkError} on timeout or transport failure.\n */\n async createChallenge(args: CreateChallengeArgs): Promise<Challenge> {\n if (!args.resource || args.resource.trim().length === 0) {\n throw new Error(\"createChallenge: `resource` is required.\");\n }\n if (!Number.isFinite(args.priceSats) || args.priceSats < 1) {\n throw new Error(\"createChallenge: `priceSats` must be an integer ≥ 1.\");\n }\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"X-API-Key\": this.apiKey,\n Accept: \"application/json\",\n };\n if (args.idempotencyKey) {\n headers[\"X-Idempotency-Key\"] = args.idempotencyKey;\n }\n\n const body = JSON.stringify({\n resource: args.resource,\n priceSats: args.priceSats,\n description: args.description,\n });\n\n const response = await this.request(\n \"/api/l402/challenges\",\n \"POST\",\n headers,\n body,\n );\n\n if (response.status === 200) {\n const data = (await response.json()) as {\n invoice: string;\n macaroon: string;\n paymentHash: string;\n expiresAt: string;\n resource: string;\n priceSats: number;\n mppChallenge?: string | null;\n };\n return {\n invoice: data.invoice,\n macaroon: data.macaroon,\n paymentHash: data.paymentHash,\n expiresAt: data.expiresAt,\n resource: data.resource,\n priceSats: data.priceSats,\n mppChallenge: data.mppChallenge ?? undefined,\n };\n }\n\n await this.throwForStatus(response);\n // Unreachable — throwForStatus always throws on non-2xx.\n throw new L402ApiError(\n response.status,\n \"Unexpected response from L402 producer API.\",\n );\n }\n\n /**\n * Verify an L402 token. Returns a {@link VerificationResult} indicating\n * whether the token is valid plus metadata extracted from the macaroon\n * (resource, merchant ID, amount).\n *\n * The producer API returns `200 OK` for both valid and invalid tokens;\n * inspect `result.valid` rather than relying on HTTP status. Non-200\n * responses indicate a higher-level problem (auth, plan, transport).\n *\n * @param args - macaroon (required for L402; omit only for MPP) + preimage.\n * @returns The {@link VerificationResult}.\n * @throws {@link L402AuthError} on 401 (invalid API key).\n * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).\n * @throws {@link L402ApiError} on other non-2xx responses.\n * @throws {@link L402NetworkError} on timeout or transport failure.\n */\n async verifyToken(args: VerifyTokenArgs): Promise<VerificationResult> {\n if (!args.preimage || args.preimage.trim().length === 0) {\n throw new Error(\"verifyToken: `preimage` is required.\");\n }\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"X-API-Key\": this.apiKey,\n Accept: \"application/json\",\n };\n\n const body = JSON.stringify({\n macaroon: args.macaroon,\n preimage: args.preimage,\n });\n\n const response = await this.request(\n \"/api/l402/challenges/verify\",\n \"POST\",\n headers,\n body,\n );\n\n if (response.status === 200) {\n const data = (await response.json()) as {\n valid: boolean;\n resource?: string | null;\n merchantId?: number | null;\n amountSats?: number | null;\n paymentHash?: string | null;\n error?: string | null;\n };\n return {\n valid: data.valid,\n error: data.error ?? undefined,\n resource: data.resource ?? undefined,\n merchantId: data.merchantId ?? undefined,\n amountSats: data.amountSats ?? undefined,\n paymentHash: data.paymentHash ?? undefined,\n };\n }\n\n await this.throwForStatus(response);\n throw new L402ApiError(\n response.status,\n \"Unexpected response from L402 producer API.\",\n );\n }\n\n private async request(\n path: string,\n method: string,\n headers: Record<string, string>,\n body: string,\n ): Promise<Response> {\n const url = `${this.baseUrl}${path}`;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.timeoutMs);\n\n try {\n return await this.fetchImpl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n if ((err as { name?: string })?.name === \"AbortError\") {\n throw new L402NetworkError(\n `Request to ${url} timed out after ${this.timeoutMs}ms`,\n err,\n );\n }\n throw new L402NetworkError(\n `Network error talking to ${url}: ${(err as Error).message}`,\n err,\n );\n } finally {\n clearTimeout(timer);\n }\n }\n\n private async throwForStatus(response: Response): Promise<never> {\n let body: unknown;\n try {\n body = await response.json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = undefined;\n }\n }\n\n const errorMessage =\n (body as { error?: string; message?: string })?.error ??\n (body as { error?: string; message?: string })?.message ??\n `HTTP ${response.status} from ${response.url}`;\n\n if (response.status === 401) {\n throw new L402AuthError(errorMessage);\n }\n if (response.status === 403) {\n const currentPlan = (body as { current_plan?: string })?.current_plan;\n throw new L402PlanError(errorMessage, currentPlan);\n }\n throw new L402ApiError(response.status, errorMessage, body);\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/errors.ts","../src/L402Server.ts"],"sourcesContent":["/**\n * Base error class for all SDK-thrown errors. Distinguishable from arbitrary\n * `Error` instances via `instanceof L402ServerError`.\n */\nexport class L402ServerError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(message, options);\n this.name = \"L402ServerError\";\n }\n}\n\n/**\n * Thrown on `401 Unauthorized` from the producer API. Almost always means\n * the merchant API key is missing, malformed, expired, or revoked.\n */\nexport class L402AuthError extends L402ServerError {\n constructor(message: string = \"Merchant API key is missing or invalid.\") {\n super(message);\n this.name = \"L402AuthError\";\n }\n}\n\n/**\n * Thrown on `403 Forbidden` from the producer API. Means the merchant\n * exists and the key is valid, but L402 is not enabled on their plan.\n * The merchant needs to upgrade to an Agentic Commerce plan.\n */\nexport class L402PlanError extends L402ServerError {\n /**\n * The plan tier currently on the merchant (e.g., \"starter\").\n * Populated when the server includes it in the error payload.\n */\n readonly currentPlan?: string;\n\n constructor(\n message: string = \"L402 is not enabled on this merchant's plan.\",\n currentPlan?: string,\n ) {\n super(message);\n this.name = \"L402PlanError\";\n this.currentPlan = currentPlan;\n }\n}\n\n/**\n * Thrown for transport-level failures: timeout, DNS error, TLS error,\n * unreachable host. The `cause` carries the original error.\n */\nexport class L402NetworkError extends L402ServerError {\n constructor(message: string, cause?: unknown) {\n super(message, { cause });\n this.name = \"L402NetworkError\";\n }\n}\n\n/**\n * Thrown when the server returns a non-success status that doesn't map to\n * a more specific error class above (e.g., 400 with a request-validation\n * problem, 500 from upstream wallet failure, 429 from rate limiting).\n */\nexport class L402ApiError extends L402ServerError {\n /**\n * HTTP status code returned by the producer API.\n */\n readonly statusCode: number;\n\n /**\n * Raw response body, useful for debugging. May be a parsed object or a\n * string if parsing failed.\n */\n readonly responseBody?: unknown;\n\n constructor(\n statusCode: number,\n message: string,\n responseBody?: unknown,\n ) {\n super(message);\n this.name = \"L402ApiError\";\n this.statusCode = statusCode;\n this.responseBody = responseBody;\n }\n}\n","import {\n L402ApiError,\n L402AuthError,\n L402NetworkError,\n L402PlanError,\n} from \"./errors.js\";\nimport type {\n Challenge,\n CreateChallengeArgs,\n L402ServerOptions,\n VerificationResult,\n VerifyTokenArgs,\n} from \"./types.js\";\n\nconst DEFAULT_BASE_URL = \"https://api.lightningenable.com\";\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/**\n * Server-side client for Lightning Enable's L402 producer API. Wraps two\n * endpoints:\n *\n * - {@link createChallenge} → `POST /api/l402/challenges` — mint a\n * Lightning invoice + macaroon for a given resource and price.\n * - {@link verifyToken} → `POST /api/l402/challenges/verify` — validate\n * an incoming L402 token (macaroon + preimage).\n *\n * **No protocol logic lives in this SDK.** The Lightning Enable backend\n * signs macaroons, mints invoices, verifies preimages, and tracks consumed\n * tokens for replay protection. The SDK is purely an HTTP client with\n * typed inputs/outputs.\n *\n * @example\n * ```ts\n * import { L402Server } from \"l402-server\";\n *\n * const l402 = new L402Server({\n * apiKey: process.env.LIGHTNING_ENABLE_API_KEY!,\n * });\n *\n * // On an unauthenticated incoming request:\n * const challenge = await l402.createChallenge({\n * resource: \"/api/premium/weather\",\n * priceSats: 100,\n * description: \"Premium weather forecast\",\n * });\n *\n * // Send back as 402 Payment Required with the challenge headers.\n *\n * // When client comes back with Authorization: L402 mac:preimage,\n * // parse and verify:\n * const verification = await l402.verifyToken({\n * macaroon: parsedMacaroon,\n * preimage: parsedPreimage,\n * });\n * if (verification.valid) {\n * // Serve the response.\n * }\n * ```\n */\nexport class L402Server {\n private readonly apiKey: string;\n private readonly baseUrl: string;\n private readonly timeoutMs: number;\n private readonly fetchImpl: typeof fetch;\n\n constructor(options: L402ServerOptions) {\n if (!options.apiKey || options.apiKey.trim().length === 0) {\n throw new Error(\n \"L402Server: `apiKey` is required. Get one from your Lightning Enable dashboard.\",\n );\n }\n if (/^\\$\\{[^}]+\\}$/.test(options.apiKey.trim())) {\n throw new Error(\n `L402Server: \\`apiKey\\` looks like an unresolved environment-variable placeholder (${options.apiKey.trim()}). ` +\n `This usually means a parent shell exported the literal string \\\"\\${VAR_NAME}\\\" instead of the substituted value. ` +\n `Common sources: settings.json/launch.json with unrendered \\${env:NAME}, a Dockerfile ENV line, or a chained .env loader. ` +\n `Fix by setting LIGHTNING_ENABLE_API_KEY directly to the real key, or by clearing the placeholder so the SDK reads the right value.`,\n );\n }\n\n this.apiKey = options.apiKey;\n this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.fetchImpl = options.fetch ?? fetch;\n }\n\n /**\n * Mint an L402 challenge — a Lightning invoice plus a macaroon scoped to\n * the given resource. Present this to the requesting client/agent in a\n * `402 Payment Required` response. Once they pay the invoice and obtain\n * the preimage, they will retry the request with\n * `Authorization: L402 <macaroon>:<preimage>`.\n *\n * @param args - resource path, price in sats, optional description and idempotency key.\n * @returns The {@link Challenge} containing the invoice, macaroon, and metadata.\n * @throws {@link L402AuthError} on 401 (invalid API key).\n * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).\n * @throws {@link L402ApiError} on other non-2xx responses.\n * @throws {@link L402NetworkError} on timeout or transport failure.\n */\n async createChallenge(args: CreateChallengeArgs): Promise<Challenge> {\n if (!args.resource || args.resource.trim().length === 0) {\n throw new Error(\"createChallenge: `resource` is required.\");\n }\n if (!Number.isFinite(args.priceSats) || args.priceSats < 1) {\n throw new Error(\"createChallenge: `priceSats` must be an integer ≥ 1.\");\n }\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"X-API-Key\": this.apiKey,\n Accept: \"application/json\",\n };\n if (args.idempotencyKey) {\n headers[\"X-Idempotency-Key\"] = args.idempotencyKey;\n }\n\n const body = JSON.stringify({\n resource: args.resource,\n priceSats: args.priceSats,\n description: args.description,\n });\n\n const response = await this.request(\n \"/api/l402/challenges\",\n \"POST\",\n headers,\n body,\n );\n\n if (response.status === 200) {\n const data = (await response.json()) as {\n invoice: string;\n macaroon: string;\n paymentHash: string;\n expiresAt: string;\n resource: string;\n priceSats: number;\n mppChallenge?: string | null;\n };\n return {\n invoice: data.invoice,\n macaroon: data.macaroon,\n paymentHash: data.paymentHash,\n expiresAt: data.expiresAt,\n resource: data.resource,\n priceSats: data.priceSats,\n mppChallenge: data.mppChallenge ?? undefined,\n };\n }\n\n await this.throwForStatus(response);\n // Unreachable — throwForStatus always throws on non-2xx.\n throw new L402ApiError(\n response.status,\n \"Unexpected response from L402 producer API.\",\n );\n }\n\n /**\n * Verify an L402 token. Returns a {@link VerificationResult} indicating\n * whether the token is valid plus metadata extracted from the macaroon\n * (resource, merchant ID, amount).\n *\n * The producer API returns `200 OK` for both valid and invalid tokens;\n * inspect `result.valid` rather than relying on HTTP status. Non-200\n * responses indicate a higher-level problem (auth, plan, transport).\n *\n * @param args - macaroon (required for L402; omit only for MPP) + preimage,\n * plus optional `resource` / `amountSats` for server-side caveat\n * enforcement against the current request.\n * @returns The {@link VerificationResult}.\n * @throws {@link L402AuthError} on 401 (invalid API key).\n * @throws {@link L402PlanError} on 403 (L402 not enabled on merchant's plan).\n * @throws {@link L402ApiError} on other non-2xx responses.\n * @throws {@link L402NetworkError} on timeout or transport failure.\n */\n async verifyToken(args: VerifyTokenArgs): Promise<VerificationResult> {\n if (!args.preimage || args.preimage.trim().length === 0) {\n throw new Error(\"verifyToken: `preimage` is required.\");\n }\n // Mirror the producer API's validation so the caller gets an immediate,\n // descriptive error instead of a 400 round-trip. An empty-string\n // `resource` would otherwise silently disable path-caveat enforcement.\n if (args.resource !== undefined && args.resource.trim().length === 0) {\n throw new Error(\n \"verifyToken: `resource` must be non-empty when provided; omit it entirely to skip path-caveat enforcement.\",\n );\n }\n if (\n args.amountSats !== undefined &&\n (!Number.isFinite(args.amountSats) || args.amountSats < 1)\n ) {\n throw new Error(\n \"verifyToken: `amountSats` must be ≥ 1 when provided; omit it entirely to skip amount-caveat enforcement.\",\n );\n }\n\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n \"X-API-Key\": this.apiKey,\n Accept: \"application/json\",\n };\n\n // JSON.stringify drops undefined properties, so optional args are only\n // sent when the caller opted into server-side caveat enforcement.\n const body = JSON.stringify({\n macaroon: args.macaroon,\n preimage: args.preimage,\n resource: args.resource,\n amountSats: args.amountSats,\n });\n\n const response = await this.request(\n \"/api/l402/challenges/verify\",\n \"POST\",\n headers,\n body,\n );\n\n if (response.status === 200) {\n const data = (await response.json()) as {\n valid: boolean;\n resource?: string | null;\n merchantId?: number | null;\n amountSats?: number | null;\n paymentHash?: string | null;\n error?: string | null;\n };\n return {\n valid: data.valid,\n error: data.error ?? undefined,\n resource: data.resource ?? undefined,\n merchantId: data.merchantId ?? undefined,\n amountSats: data.amountSats ?? undefined,\n paymentHash: data.paymentHash ?? undefined,\n };\n }\n\n await this.throwForStatus(response);\n throw new L402ApiError(\n response.status,\n \"Unexpected response from L402 producer API.\",\n );\n }\n\n private async request(\n path: string,\n method: string,\n headers: Record<string, string>,\n body: string,\n ): Promise<Response> {\n const url = `${this.baseUrl}${path}`;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.timeoutMs);\n\n try {\n return await this.fetchImpl(url, {\n method,\n headers,\n body,\n signal: controller.signal,\n });\n } catch (err) {\n if ((err as { name?: string })?.name === \"AbortError\") {\n throw new L402NetworkError(\n `Request to ${url} timed out after ${this.timeoutMs}ms`,\n err,\n );\n }\n throw new L402NetworkError(\n `Network error talking to ${url}: ${(err as Error).message}`,\n err,\n );\n } finally {\n clearTimeout(timer);\n }\n }\n\n private async throwForStatus(response: Response): Promise<never> {\n let body: unknown;\n try {\n body = await response.json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = undefined;\n }\n }\n\n const errorMessage =\n (body as { error?: string; message?: string })?.error ??\n (body as { error?: string; message?: string })?.message ??\n `HTTP ${response.status} from ${response.url}`;\n\n if (response.status === 401) {\n throw new L402AuthError(errorMessage);\n }\n if (response.status === 403) {\n const currentPlan = (body as { current_plan?: string })?.current_plan;\n throw new L402PlanError(errorMessage, currentPlan);\n }\n throw new L402ApiError(response.status, errorMessage, body);\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "l402-server",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "L402 server SDK for Node — mint Lightning invoices + macaroons and verify L402 tokens. Thin TypeScript wrapper around Lightning Enable's hosted producer API.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",