@nodana/phoenixd-mpp-sdk 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,376 @@
1
+ import {
2
+ bodyHash,
3
+ invoiceDetails,
4
+ paymentHash,
5
+ satoshis
6
+ } from "../chunk-7BE7G2UI.js";
7
+ import {
8
+ ChargeError,
9
+ __export,
10
+ charge
11
+ } from "../chunk-EQRLZHAJ.js";
12
+
13
+ // src/server/index.ts
14
+ import { Mppx } from "mppx/server";
15
+
16
+ // src/server/methods.ts
17
+ var methods_exports = {};
18
+ __export(methods_exports, {
19
+ charge: () => charge2
20
+ });
21
+
22
+ // src/server/charge.ts
23
+ import { Challenge, Method, Receipt } from "mppx";
24
+ import { Transport } from "mppx/server";
25
+
26
+ // src/server/store.ts
27
+ var validId = (id) => typeof id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(id);
28
+ function assertRecord(value) {
29
+ if (typeof value !== "object" || value === null)
30
+ throw new TypeError("Invalid stored challenge");
31
+ const record = value;
32
+ if (!validId(record.id) || !/^[0-9a-f]{64}$/.test(record.paymentHash) || !/^[0-9a-f]{64}$/.test(record.bodyHash) || !Number.isSafeInteger(record.amountSat) || record.amountSat < 1 || !Number.isSafeInteger(record.expiresAt) || typeof record.serializedChallenge !== "string" || typeof record.requestUrl !== "string" || typeof record.requestMethod !== "string") {
33
+ throw new TypeError("Invalid stored challenge");
34
+ }
35
+ }
36
+ function memoryStore(options = {}) {
37
+ const maxEntries = options.maxEntries ?? 1e4;
38
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1)
39
+ throw new TypeError("maxEntries must be positive");
40
+ const entries = /* @__PURE__ */ new Map();
41
+ const hashes = /* @__PURE__ */ new Set();
42
+ const prune = () => {
43
+ let removed = 0;
44
+ for (const [id, entry] of entries)
45
+ if (entry.charge.expiresAt <= Date.now()) {
46
+ entries.delete(id);
47
+ hashes.delete(entry.charge.paymentHash);
48
+ removed++;
49
+ }
50
+ return removed;
51
+ };
52
+ return {
53
+ async issue(charge3) {
54
+ assertRecord(charge3);
55
+ if (charge3.expiresAt <= Date.now()) throw new Error("Charge has expired");
56
+ prune();
57
+ if (entries.size >= maxEntries)
58
+ throw new Error("Charge store capacity reached");
59
+ if (entries.has(charge3.id) || hashes.has(charge3.paymentHash))
60
+ throw new Error("Charge already issued");
61
+ entries.set(charge3.id, {
62
+ charge: structuredClone(charge3),
63
+ consumed: false
64
+ });
65
+ hashes.add(charge3.paymentHash);
66
+ },
67
+ async get(id) {
68
+ return entries.has(id) ? structuredClone(entries.get(id).charge) : void 0;
69
+ },
70
+ async consume(id) {
71
+ const entry = entries.get(id);
72
+ if (!entry || entry.consumed || entry.charge.expiresAt <= Date.now())
73
+ return false;
74
+ entry.consumed = true;
75
+ return true;
76
+ },
77
+ async pruneExpired() {
78
+ return prune();
79
+ }
80
+ };
81
+ }
82
+ var issueScript = `
83
+ local time = redis.call('TIME')
84
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
85
+ local expires = tonumber(ARGV[2])
86
+ if expires <= now then return -1 end
87
+ if redis.call('EXISTS', KEYS[1]) == 1 or redis.call('EXISTS', KEYS[2]) == 1 then return 0 end
88
+ redis.call('HSET', KEYS[1], 'record', ARGV[1], 'expires', ARGV[2], 'consumed', '0')
89
+ redis.call('PEXPIREAT', KEYS[1], ARGV[2])
90
+ redis.call('SET', KEYS[2], ARGV[3])
91
+ redis.call('PEXPIREAT', KEYS[2], ARGV[2])
92
+ return 1
93
+ `;
94
+ var consumeScript = `
95
+ if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
96
+ local expires = tonumber(redis.call('HGET', KEYS[1], 'expires'))
97
+ local consumed = redis.call('HGET', KEYS[1], 'consumed')
98
+ if not expires or (consumed ~= '0' and consumed ~= '1') then
99
+ return redis.error_reply('Invalid stored payment challenge')
100
+ end
101
+ local time = redis.call('TIME')
102
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
103
+ if expires <= now or consumed == '1' then return 0 end
104
+ redis.call('HSET', KEYS[1], 'consumed', '1')
105
+ return 1
106
+ `;
107
+ function redisStore(client, options = {}) {
108
+ const prefix = options.keyPrefix ?? "phoenixd-mpp";
109
+ if (!/^[A-Za-z0-9:_-]{1,128}$/.test(prefix))
110
+ throw new TypeError("Invalid Redis keyPrefix");
111
+ const root = `{${prefix}}:`;
112
+ const key = (id) => `${root}challenge:${id}`;
113
+ return {
114
+ async issue(charge3) {
115
+ assertRecord(charge3);
116
+ const result = await client.sendCommand([
117
+ "EVAL",
118
+ issueScript,
119
+ "2",
120
+ key(charge3.id),
121
+ `${root}hash:${charge3.paymentHash}`,
122
+ JSON.stringify(charge3),
123
+ String(charge3.expiresAt),
124
+ charge3.id
125
+ ]);
126
+ if (result === -1) throw new Error("Charge has expired");
127
+ if (result === 0) throw new Error("Charge already issued");
128
+ if (result !== 1) throw new Error("Unexpected Redis issuance response");
129
+ },
130
+ async get(id) {
131
+ if (!validId(id)) return void 0;
132
+ const value = await client.sendCommand(["HGET", key(id), "record"]);
133
+ if (value === null) return void 0;
134
+ if (typeof value !== "string")
135
+ throw new TypeError("Invalid Redis record response");
136
+ const record = JSON.parse(value);
137
+ assertRecord(record);
138
+ if (record.id !== id)
139
+ throw new TypeError("Stored challenge ID does not match");
140
+ return record;
141
+ },
142
+ async consume(id) {
143
+ if (!validId(id)) return false;
144
+ const result = await client.sendCommand([
145
+ "EVAL",
146
+ consumeScript,
147
+ "1",
148
+ key(id)
149
+ ]);
150
+ if (result !== 0 && result !== 1)
151
+ throw new Error("Unexpected Redis consumption response");
152
+ return result === 1;
153
+ }
154
+ };
155
+ }
156
+
157
+ // src/server/charge.ts
158
+ var defaults = { currency: "sat", methodDetails: { invoice: "" } };
159
+ function charge2(options) {
160
+ const {
161
+ client,
162
+ store = memoryStore(),
163
+ network,
164
+ invoiceExpirySeconds = 3600
165
+ } = options;
166
+ if (!Number.isSafeInteger(invoiceExpirySeconds) || invoiceExpirySeconds < 1)
167
+ throw new TypeError("invoiceExpirySeconds must be a positive integer");
168
+ const byInput = /* @__PURE__ */ new WeakMap();
169
+ const byCaptured = /* @__PURE__ */ new WeakMap();
170
+ const http = Transport.http();
171
+ const transport = {
172
+ ...http,
173
+ async respondChallenge(parameters) {
174
+ const context = byInput.get(parameters.input);
175
+ if (!context)
176
+ throw new ChargeError(
177
+ "invalid_challenge",
178
+ "An HTTP payment handler is required"
179
+ );
180
+ const original = parameters.challenge;
181
+ const request = charge.schema.request.parse(original.request);
182
+ const amountSat = satoshis(request.amount);
183
+ let details;
184
+ let serialized;
185
+ try {
186
+ const invoice = await client.createInvoice({
187
+ amountSat,
188
+ description: original.description ?? request.description ?? "",
189
+ expirySeconds: invoiceExpirySeconds
190
+ });
191
+ serialized = invoice.serialized;
192
+ details = invoiceDetails(
193
+ await client.decodeInvoice({ invoice: serialized }),
194
+ request,
195
+ network
196
+ );
197
+ if (invoice.paymentHash !== details.paymentHash || invoice.amountSat !== amountSat)
198
+ throw new TypeError(
199
+ "Generated invoice does not match the node response"
200
+ );
201
+ } catch {
202
+ throw new ChargeError(
203
+ "invalid_invoice",
204
+ "Phoenixd could not provide a matching, unexpired invoice"
205
+ );
206
+ }
207
+ const requestedExpiry = original.expires === void 0 ? Date.now() + 3e5 : Date.parse(original.expires);
208
+ const expiresAt = Math.min(requestedExpiry, details.expiresAt);
209
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now())
210
+ throw new ChargeError(
211
+ "invoice_expired",
212
+ "The configured challenge expiry is in the past"
213
+ );
214
+ const issuedRequest = {
215
+ ...request,
216
+ methodDetails: {
217
+ invoice: serialized,
218
+ paymentHash: details.paymentHash,
219
+ network: details.network
220
+ }
221
+ };
222
+ const issued = Challenge.from({
223
+ secretKey: context.secretKey,
224
+ realm: original.realm,
225
+ method: "lightning",
226
+ intent: "charge",
227
+ request: issuedRequest,
228
+ expires: new Date(expiresAt),
229
+ ...original.description === void 0 ? {} : { description: original.description },
230
+ ...original.opaque === void 0 ? {} : { opaque: original.opaque },
231
+ ...original.digest === void 0 ? {} : { digest: original.digest },
232
+ ...original.header === void 0 ? {} : { header: original.header }
233
+ });
234
+ Object.assign(original.request, issuedRequest);
235
+ Object.assign(original, issued, { request: original.request });
236
+ const record = {
237
+ id: original.id,
238
+ serializedChallenge: Challenge.serialize(original),
239
+ paymentHash: details.paymentHash,
240
+ amountSat,
241
+ expiresAt,
242
+ requestUrl: parameters.input.url,
243
+ requestMethod: parameters.input.method,
244
+ bodyHash: context.bodyHash
245
+ };
246
+ try {
247
+ await store.issue(record);
248
+ } catch {
249
+ throw new ChargeError(
250
+ "store_unavailable",
251
+ "Could not persist the payment challenge"
252
+ );
253
+ }
254
+ return http.respondChallenge({
255
+ ...parameters,
256
+ error: parameters.error,
257
+ challenge: original
258
+ });
259
+ },
260
+ respondReceipt(parameters) {
261
+ const response = http.respondReceipt(parameters);
262
+ response.headers.set("Cache-Control", "no-store");
263
+ return response;
264
+ }
265
+ };
266
+ async function validate(parameters) {
267
+ const { credential, envelope, request } = parameters;
268
+ const context = envelope === void 0 ? void 0 : byCaptured.get(envelope.capturedRequest);
269
+ if (!context)
270
+ throw new ChargeError(
271
+ "invalid_challenge",
272
+ "Use the configured HTTP payment handler"
273
+ );
274
+ let record;
275
+ try {
276
+ record = await store.get(credential.challenge.id);
277
+ } catch {
278
+ throw new ChargeError(
279
+ "store_unavailable",
280
+ "Could not read the payment challenge"
281
+ );
282
+ }
283
+ if (!record)
284
+ throw new ChargeError(
285
+ "unknown_challenge",
286
+ "The payment challenge is unknown"
287
+ );
288
+ if (Date.now() >= record.expiresAt)
289
+ throw new ChargeError(
290
+ "invoice_expired",
291
+ "The payment challenge has expired"
292
+ );
293
+ if (record.serializedChallenge !== Challenge.serialize(credential.challenge) || record.amountSat !== satoshis(request.amount) || request.currency !== "sat") {
294
+ throw new ChargeError(
295
+ "invalid_challenge",
296
+ "The credential does not match the issued payment challenge"
297
+ );
298
+ }
299
+ if (record.requestUrl !== context.input.url || record.requestMethod !== context.input.method || record.bodyHash !== context.bodyHash) {
300
+ throw new ChargeError(
301
+ "invalid_challenge",
302
+ "The credential does not match this request"
303
+ );
304
+ }
305
+ let hash;
306
+ try {
307
+ hash = paymentHash(credential.payload.preimage);
308
+ } catch {
309
+ throw new ChargeError("invalid_preimage", "The payment proof is invalid");
310
+ }
311
+ if (hash !== record.paymentHash)
312
+ throw new ChargeError("invalid_preimage", "The payment proof is invalid");
313
+ return record;
314
+ }
315
+ return Method.toServer(charge, {
316
+ defaults,
317
+ transport,
318
+ async preflight({ capturedRequest, input, secretKey }) {
319
+ const context = {
320
+ input,
321
+ secretKey,
322
+ bodyHash: await bodyHash(input)
323
+ };
324
+ byInput.set(input, context);
325
+ if (capturedRequest) byCaptured.set(capturedRequest, context);
326
+ return void 0;
327
+ },
328
+ stableBinding: (request) => ({
329
+ amount: request.amount,
330
+ currency: request.currency
331
+ }),
332
+ async validate(parameters) {
333
+ await validate(parameters);
334
+ return {
335
+ challenge: parameters.credential.challenge,
336
+ credential: parameters.credential,
337
+ details: {},
338
+ intent: "charge",
339
+ method: "lightning",
340
+ request: parameters.credential.challenge.request
341
+ };
342
+ },
343
+ async broadcast(parameters) {
344
+ const record = await validate(parameters);
345
+ let consumed;
346
+ try {
347
+ consumed = await store.consume(record.id);
348
+ } catch {
349
+ throw new ChargeError(
350
+ "store_unavailable",
351
+ "Could not consume the payment challenge"
352
+ );
353
+ }
354
+ if (!consumed)
355
+ throw new ChargeError(
356
+ "already_consumed",
357
+ "The payment challenge has already been used or expired"
358
+ );
359
+ return Receipt.from({
360
+ method: "lightning",
361
+ reference: record.paymentHash,
362
+ status: "success",
363
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
364
+ });
365
+ }
366
+ });
367
+ }
368
+ export {
369
+ ChargeError,
370
+ Mppx,
371
+ charge2 as charge,
372
+ memoryStore,
373
+ methods_exports as phoenixd,
374
+ redisStore
375
+ };
376
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/server/index.ts","../../src/server/methods.ts","../../src/server/charge.ts","../../src/server/store.ts"],"sourcesContent":["export { Mppx } from \"mppx/server\";\nexport * as phoenixd from \"./methods\";\nexport { charge } from \"./charge\";\nexport type { ChargeOptions, ServerChargeMethod } from \"./charge\";\nexport { redisStore, memoryStore } from \"./store\";\nexport type {\n ChargeStore,\n ManagedChargeStore,\n StoredCharge,\n RedisClient,\n RedisStoreOptions,\n} from \"./store\";\nexport { ChargeError } from \"../errors\";\n","export { charge } from \"./charge\";\n","import { Challenge, Method, Receipt } from \"mppx\";\nimport { Transport } from \"mppx/server\";\nimport type { PhoenixdClient } from \"@nodana/phoenixd-ts\";\nimport * as Methods from \"../Methods\";\nimport { ChargeError } from \"../errors\";\nimport { bodyHash, invoiceDetails, paymentHash, satoshis } from \"../shared\";\nimport { memoryStore } from \"./store\";\nimport type { ChargeStore, StoredCharge } from \"./store\";\n\nexport interface ChargeOptions {\n client: Pick<PhoenixdClient, \"createInvoice\" | \"decodeInvoice\">;\n /** Use a shared durable store for deployed services. Defaults to process-local memory. */\n store?: ChargeStore;\n /** Invoice lifetime in seconds. Defaults to 3600; challenge expiry is capped to it. */\n invoiceExpirySeconds?: number;\n network?: Methods.LightningNetwork;\n}\n\nconst defaults = { currency: \"sat\", methodDetails: { invoice: \"\" } } as const;\n\nexport type ServerChargeMethod = Method.Server<\n typeof Methods.charge,\n typeof defaults,\n Transport.Http\n>;\n\ninterface HttpContext {\n input: Request;\n secretKey: string;\n bodyHash: string;\n}\n\nexport function charge(options: ChargeOptions): ServerChargeMethod {\n const {\n client,\n store = memoryStore(),\n network,\n invoiceExpirySeconds = 3600,\n } = options;\n\n if (!Number.isSafeInteger(invoiceExpirySeconds) || invoiceExpirySeconds < 1)\n throw new TypeError(\"invoiceExpirySeconds must be a positive integer\");\n\n const byInput = new WeakMap<Request, HttpContext>();\n const byCaptured = new WeakMap<Method.CapturedRequest, HttpContext>();\n const http = Transport.http();\n\n const transport: Transport.Http = {\n ...http,\n\n async respondChallenge(parameters) {\n const context = byInput.get(parameters.input);\n\n if (!context)\n throw new ChargeError(\n \"invalid_challenge\",\n \"An HTTP payment handler is required\",\n );\n\n const original = parameters.challenge;\n const request = Methods.charge.schema.request.parse(original.request);\n const amountSat = satoshis(request.amount);\n let details: ReturnType<typeof invoiceDetails>;\n let serialized: string;\n\n try {\n const invoice = await client.createInvoice({\n amountSat,\n description: original.description ?? request.description ?? \"\",\n expirySeconds: invoiceExpirySeconds,\n });\n\n serialized = invoice.serialized;\n details = invoiceDetails(\n await client.decodeInvoice({ invoice: serialized }),\n request,\n network,\n );\n\n if (\n invoice.paymentHash !== details.paymentHash ||\n invoice.amountSat !== amountSat\n )\n throw new TypeError(\n \"Generated invoice does not match the node response\",\n );\n } catch {\n throw new ChargeError(\n \"invalid_invoice\",\n \"Phoenixd could not provide a matching, unexpired invoice\",\n );\n }\n\n const requestedExpiry =\n original.expires === undefined\n ? Date.now() + 300_000\n : Date.parse(original.expires);\n const expiresAt = Math.min(requestedExpiry, details.expiresAt);\n\n if (!Number.isFinite(expiresAt) || expiresAt <= Date.now())\n throw new ChargeError(\n \"invoice_expired\",\n \"The configured challenge expiry is in the past\",\n );\n\n const issuedRequest = {\n ...request,\n methodDetails: {\n invoice: serialized,\n paymentHash: details.paymentHash,\n network: details.network,\n },\n };\n const issued = Challenge.from({\n secretKey: context.secretKey,\n realm: original.realm,\n method: \"lightning\",\n intent: \"charge\",\n request: issuedRequest,\n expires: new Date(expiresAt),\n ...(original.description === undefined\n ? {}\n : { description: original.description }),\n ...(original.opaque === undefined ? {} : { opaque: original.opaque }),\n ...(original.digest === undefined ? {} : { digest: original.digest }),\n ...(original.header === undefined ? {} : { header: original.header }),\n });\n\n // Enrich the framework's challenge in place so its lifecycle event sees\n // the same issued ID/request as the HTTP response. No invoice is created\n // on a successful credential retry; only challenge responses need one.\n Object.assign(original.request, issuedRequest);\n Object.assign(original, issued, { request: original.request });\n\n const record: StoredCharge = {\n id: original.id,\n serializedChallenge: Challenge.serialize(original),\n paymentHash: details.paymentHash,\n amountSat,\n expiresAt,\n requestUrl: parameters.input.url,\n requestMethod: parameters.input.method,\n bodyHash: context.bodyHash,\n };\n\n try {\n await store.issue(record);\n } catch {\n throw new ChargeError(\n \"store_unavailable\",\n \"Could not persist the payment challenge\",\n );\n }\n\n // Rejected credentials always receive a fresh invoice/challenge. Neither\n // malformed payloads nor a consumed preimage can trigger reuse.\n return http.respondChallenge({\n ...parameters,\n error: parameters.error,\n challenge: original,\n });\n },\n\n respondReceipt(parameters) {\n const response = http.respondReceipt(parameters);\n\n response.headers.set(\"Cache-Control\", \"no-store\");\n\n return response;\n },\n };\n\n async function validate(\n parameters: Method.VerifyContext<typeof Methods.charge>,\n ): Promise<StoredCharge> {\n const { credential, envelope, request } = parameters;\n const context =\n envelope === undefined\n ? undefined\n : byCaptured.get(envelope.capturedRequest);\n\n if (!context)\n throw new ChargeError(\n \"invalid_challenge\",\n \"Use the configured HTTP payment handler\",\n );\n\n let record: StoredCharge | undefined;\n\n try {\n record = await store.get(credential.challenge.id);\n } catch {\n throw new ChargeError(\n \"store_unavailable\",\n \"Could not read the payment challenge\",\n );\n }\n\n if (!record)\n throw new ChargeError(\n \"unknown_challenge\",\n \"The payment challenge is unknown\",\n );\n\n if (Date.now() >= record.expiresAt)\n throw new ChargeError(\n \"invoice_expired\",\n \"The payment challenge has expired\",\n );\n\n if (\n record.serializedChallenge !==\n Challenge.serialize(credential.challenge) ||\n record.amountSat !== satoshis(request.amount) ||\n request.currency !== \"sat\"\n ) {\n throw new ChargeError(\n \"invalid_challenge\",\n \"The credential does not match the issued payment challenge\",\n );\n }\n\n if (\n record.requestUrl !== context.input.url ||\n record.requestMethod !== context.input.method ||\n record.bodyHash !== context.bodyHash\n ) {\n throw new ChargeError(\n \"invalid_challenge\",\n \"The credential does not match this request\",\n );\n }\n\n let hash: string;\n\n try {\n hash = paymentHash(credential.payload.preimage);\n } catch {\n throw new ChargeError(\"invalid_preimage\", \"The payment proof is invalid\");\n }\n\n if (hash !== record.paymentHash)\n throw new ChargeError(\"invalid_preimage\", \"The payment proof is invalid\");\n\n return record;\n }\n\n return Method.toServer(Methods.charge, {\n defaults,\n transport,\n\n async preflight({ capturedRequest, input, secretKey }) {\n const context: HttpContext = {\n input,\n secretKey,\n bodyHash: await bodyHash(input),\n };\n\n byInput.set(input, context);\n\n if (capturedRequest) byCaptured.set(capturedRequest, context);\n\n return undefined;\n },\n\n stableBinding: (request) => ({\n amount: request.amount,\n currency: request.currency,\n }),\n\n async validate(parameters) {\n await validate(parameters);\n\n return {\n challenge: parameters.credential.challenge,\n credential: parameters.credential,\n details: {},\n intent: \"charge\",\n method: \"lightning\",\n request: parameters.credential.challenge.request,\n };\n },\n\n async broadcast(parameters) {\n const record = await validate(parameters);\n let consumed: boolean;\n\n try {\n consumed = await store.consume(record.id);\n } catch {\n throw new ChargeError(\n \"store_unavailable\",\n \"Could not consume the payment challenge\",\n );\n }\n\n if (!consumed)\n throw new ChargeError(\n \"already_consumed\",\n \"The payment challenge has already been used or expired\",\n );\n\n return Receipt.from({\n method: \"lightning\",\n reference: record.paymentHash,\n status: \"success\",\n timestamp: new Date().toISOString(),\n });\n },\n });\n}\n","/** Stored values contain no payment preimages or node credentials. */\nexport interface StoredCharge {\n readonly id: string;\n readonly serializedChallenge: string;\n readonly paymentHash: string;\n readonly amountSat: number;\n readonly expiresAt: number;\n readonly requestUrl: string;\n readonly requestMethod: string;\n readonly bodyHash: string;\n}\n\nexport interface ChargeStore {\n /** Persist before responding with a challenge. Reject reused IDs/payment hashes. */\n issue(charge: StoredCharge): Promise<void>;\n get(id: string): Promise<StoredCharge | undefined>;\n /** Atomically succeeds once, and only while the stored challenge is unexpired. */\n consume(id: string): Promise<boolean>;\n}\n\nexport interface ManagedChargeStore extends ChargeStore {\n pruneExpired(): Promise<number>;\n}\n\nconst validId = (id: string) =>\n typeof id === \"string\" && /^[A-Za-z0-9_-]{1,128}$/.test(id);\n\nfunction assertRecord(value: unknown): asserts value is StoredCharge {\n if (typeof value !== \"object\" || value === null)\n throw new TypeError(\"Invalid stored challenge\");\n\n const record = value as StoredCharge;\n\n if (\n !validId(record.id) ||\n !/^[0-9a-f]{64}$/.test(record.paymentHash) ||\n !/^[0-9a-f]{64}$/.test(record.bodyHash) ||\n !Number.isSafeInteger(record.amountSat) ||\n record.amountSat < 1 ||\n !Number.isSafeInteger(record.expiresAt) ||\n typeof record.serializedChallenge !== \"string\" ||\n typeof record.requestUrl !== \"string\" ||\n typeof record.requestMethod !== \"string\"\n ) {\n throw new TypeError(\"Invalid stored challenge\");\n }\n}\n\nexport function memoryStore(\n options: { maxEntries?: number } = {},\n): ManagedChargeStore {\n const maxEntries = options.maxEntries ?? 10_000;\n\n if (!Number.isSafeInteger(maxEntries) || maxEntries < 1)\n throw new TypeError(\"maxEntries must be positive\");\n\n const entries = new Map<\n string,\n { charge: StoredCharge; consumed: boolean }\n >();\n const hashes = new Set<string>();\n\n const prune = () => {\n let removed = 0;\n\n for (const [id, entry] of entries)\n if (entry.charge.expiresAt <= Date.now()) {\n entries.delete(id);\n hashes.delete(entry.charge.paymentHash);\n removed++;\n }\n\n return removed;\n };\n\n return {\n async issue(charge) {\n assertRecord(charge);\n\n if (charge.expiresAt <= Date.now()) throw new Error(\"Charge has expired\");\n\n prune();\n\n if (entries.size >= maxEntries)\n throw new Error(\"Charge store capacity reached\");\n\n if (entries.has(charge.id) || hashes.has(charge.paymentHash))\n throw new Error(\"Charge already issued\");\n\n entries.set(charge.id, {\n charge: structuredClone(charge),\n consumed: false,\n });\n hashes.add(charge.paymentHash);\n },\n\n async get(id) {\n return entries.has(id)\n ? structuredClone(entries.get(id)!.charge)\n : undefined;\n },\n\n async consume(id) {\n const entry = entries.get(id);\n\n if (!entry || entry.consumed || entry.charge.expiresAt <= Date.now())\n return false;\n\n entry.consumed = true;\n\n return true;\n },\n\n async pruneExpired() {\n return prune();\n },\n };\n}\n\n/** Compatible with a connected node-redis client using its default reply types. */\nexport interface RedisClient {\n sendCommand(args: string[]): Promise<unknown>;\n}\n\nexport interface RedisStoreOptions {\n /** Namespace shared by instances of this service. Defaults to phoenixd-mpp. */\n keyPrefix?: string;\n}\n\n// Both keys share a hash tag. All checks and writes run in one Redis operation.\nconst issueScript = `\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)\nlocal expires = tonumber(ARGV[2])\nif expires <= now then return -1 end\nif redis.call('EXISTS', KEYS[1]) == 1 or redis.call('EXISTS', KEYS[2]) == 1 then return 0 end\nredis.call('HSET', KEYS[1], 'record', ARGV[1], 'expires', ARGV[2], 'consumed', '0')\nredis.call('PEXPIREAT', KEYS[1], ARGV[2])\nredis.call('SET', KEYS[2], ARGV[3])\nredis.call('PEXPIREAT', KEYS[2], ARGV[2])\nreturn 1\n`;\n\nconst consumeScript = `\nif redis.call('EXISTS', KEYS[1]) == 0 then return 0 end\nlocal expires = tonumber(redis.call('HGET', KEYS[1], 'expires'))\nlocal consumed = redis.call('HGET', KEYS[1], 'consumed')\nif not expires or (consumed ~= '0' and consumed ~= '1') then\n return redis.error_reply('Invalid stored payment challenge')\nend\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)\nif expires <= now or consumed == '1' then return 0 end\nredis.call('HSET', KEYS[1], 'consumed', '1')\nreturn 1\n`;\n\n/** Shared payment state; Redis expires records automatically. Does not own the connection. */\nexport function redisStore(\n client: RedisClient,\n options: RedisStoreOptions = {},\n): ChargeStore {\n const prefix = options.keyPrefix ?? \"phoenixd-mpp\";\n\n if (!/^[A-Za-z0-9:_-]{1,128}$/.test(prefix))\n throw new TypeError(\"Invalid Redis keyPrefix\");\n\n const root = `{${prefix}}:`;\n\n const key = (id: string) => `${root}challenge:${id}`;\n\n return {\n async issue(charge) {\n assertRecord(charge);\n\n // Serialize before awaiting so caller mutations cannot change issued state.\n const result = await client.sendCommand([\n \"EVAL\",\n issueScript,\n \"2\",\n key(charge.id),\n `${root}hash:${charge.paymentHash}`,\n JSON.stringify(charge),\n String(charge.expiresAt),\n charge.id,\n ]);\n\n if (result === -1) throw new Error(\"Charge has expired\");\n\n if (result === 0) throw new Error(\"Charge already issued\");\n\n if (result !== 1) throw new Error(\"Unexpected Redis issuance response\");\n },\n\n async get(id) {\n if (!validId(id)) return undefined;\n\n const value = await client.sendCommand([\"HGET\", key(id), \"record\"]);\n\n if (value === null) return undefined;\n\n if (typeof value !== \"string\")\n throw new TypeError(\"Invalid Redis record response\");\n\n const record: unknown = JSON.parse(value);\n\n assertRecord(record);\n\n if (record.id !== id)\n throw new TypeError(\"Stored challenge ID does not match\");\n\n return record;\n },\n\n async consume(id) {\n if (!validId(id)) return false;\n\n const result = await client.sendCommand([\n \"EVAL\",\n consumeScript,\n \"1\",\n key(id),\n ]);\n\n if (result !== 0 && result !== 1)\n throw new Error(\"Unexpected Redis consumption response\");\n\n return result === 1;\n },\n };\n}\n"],"mappings":"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","names":["charge","charge","charge"]}
@@ -0,0 +1,82 @@
1
+ # Development
2
+
3
+ ## Local setup
4
+
5
+ Use Node.js 22.12 or later. Install dependencies and run the checks:
6
+
7
+ ```sh
8
+ npm install
9
+ npm test
10
+ ```
11
+
12
+ The adapter uses `@nodana/phoenixd-ts` from npm for both development and its public peer dependency. It requires version 0.2.0 or later within the 0.2 series.
13
+
14
+ The test command checks source and test types, runs the tests, builds all entry points, and checks ESM/CommonJS imports and TypeScript declarations as consumers. The integration test exercises the real Phoenixd HTTP client through an injected fetch implementation, including node authentication, form encoding, a missing payment response, and the MPP client's automatic paid retry. It uses mocked node responses; it does not verify real BOLT11 decoding, Lightning settlement or compatibility with a live daemon. Run a funded regtest integration before a production release.
15
+
16
+ ### Redis integration tests
17
+
18
+ With `redis-server` and `redis-cli` available on your PATH, run:
19
+
20
+ ```sh
21
+ npm run test:redis
22
+ ```
23
+
24
+ This suite starts a disposable Redis instance on a Unix socket with TCP disabled. It checks concurrent issuance and consumption across independent clients, key expiry, namespace isolation, persistence across actual Redis restarts, and the paid HTTP flow across server instances. It uses a private temporary directory and never connects to an existing Redis service. The regular `npm test` does not require a Redis installation.
25
+
26
+ ## Package roles
27
+
28
+ `phoenixd-ts` handles the node HTTP API. `mppx` supplies the MPP schemas, signed challenges, credentials, receipts and HTTP handler. This package implements the Lightning charge method between them. Server and payer exports are separate; each accepts a configured client instance.
29
+
30
+ The Lightning wire schema is adapted from the MIT-licensed Lightspark implementation. Attribution is in `NOTICE`. Node access, request binding, state storage and payment handling are implemented here.
31
+
32
+ ## HTTP lifecycle
33
+
34
+ The adapter targets mppx 0.13.4's `preflight`, `validate` and `broadcast` hooks. Preflight captures the HTTP request and hashes its body without consuming the application's copy. The adapter supports the HTTP handler lifecycle; standalone credential validation and alternate transports do not supply the required request context and fail closed.
35
+
36
+ Invoices are created only when returning a challenge. The transport response hook fills the invoice details, caps expiry to the invoice, signs the completed challenge, and persists it before responding. It updates the framework's challenge and request objects in place so lifecycle events report the same signed challenge that went to the client. A successful credential retry does not create another invoice.
37
+
38
+ Validation compares the entire echoed challenge to its stored form, checks expiry and current price, binds it to the exact URL, HTTP method and body hash, and verifies the SHA-256 preimage proof. Broadcast validates again, then atomically consumes the challenge before returning a receipt. Replayed or invalid credentials receive a fresh challenge and invoice. Payment responses use `Cache-Control: no-store`.
39
+
40
+ Request bodies are buffered for hashing. Apply the application's normal request size limits before the payment handler. Application headers are not part of the stored request binding; keep authorisation and any header-dependent resource selection in the application's access checks.
41
+
42
+ The payer checks decoded invoice amount, hash, network and expiry before paying. Concurrent requests for the same invoice within one method instance share a payment promise. It checks outgoing history before sending; a settled payment reuses its verified proof and a pending payment fails closed. On an uncertain payment result, it checks history once and otherwise reports `payment_unknown`. It does not automatically send another payment. This coalescing is process-local; coordinate multiple payer processes separately.
43
+
44
+ A consumed credential is not a fulfilment record. If application work fails after acceptance, another request requires another payment. For operations needing recoverable fulfilment, record the purchased operation and expose an authenticated recovery endpoint in the application.
45
+
46
+ ## Implement a charge store
47
+
48
+ Implement the exported `ChargeStore` interface when using a database or a distributed deployment:
49
+
50
+ - `issue(record)` persists an immutable record before the challenge response, rejecting duplicate IDs and payment hashes among retained records.
51
+ - `get(id)` returns the authoritative record or `undefined` for an unknown ID. Report storage failures by throwing.
52
+ - `consume(id)` atomically returns `true` once, only for an existing, unconsumed, unexpired record. Concurrent callers across processes must have exactly one winner. Throw on storage failure.
53
+
54
+ Store no payment preimages or node passwords. Enforce atomic uniqueness and consumption in the database itself. Retain records until expiry, including consumed records, so payment credentials cannot be replayed during their lifetime. Keep issuance and consumption reads consistent across instances.
55
+
56
+ `redisStore(client, { keyPrefix })` accepts the connected `node-redis` client through a structural `RedisClient` interface requiring `sendCommand(args)`. It does not add a Redis runtime dependency, open or close connections, or handle reconnects. The application owns the client's lifecycle. Default replies must be strings/numbers/null, as returned by node-redis without custom reply mappings.
57
+
58
+ Issuance uses a Lua script to check both challenge ID and payment hash uniqueness, then persist the record and hash reservation together. Consumption uses another script to check expiry and the consumed flag, then mark the challenge used in the same operation. Both scripts use Redis server time. Challenge records and hash reservations expire at the signed challenge's expiry; consuming a record preserves its expiry. No scheduled cleanup is needed.
59
+
60
+ The namespace is wrapped in a Redis hash tag so all script keys share a slot. Use the same `keyPrefix` for a service's instances and a different prefix for independent services. The client must support raw `EVAL` and `HGET` commands and connect to the primary; the SDK does not implement Redis Cluster routing or an HTTP Redis client. Redis ACLs must permit `EVAL` and the script's `TIME`, `EXISTS`, `HSET`, `HGET`, `SET` and `PEXPIREAT` commands on the namespace.
61
+
62
+ Use Redis persistence appropriate for payment state, with `noeviction`, sufficient capacity, and clocks synchronised between application hosts and Redis. Connection errors and unexpected replies fail closed. Lost state makes outstanding challenges unusable. Rolling back consumed state can permit reuse; persistence and replication/failover guarantees remain properties of the Redis deployment. Never reset or restore older payment state while its challenges remain valid.
63
+
64
+ `memoryStore({ maxEntries })` defaults to 10,000 entries. It prunes expired records on issuance and refuses further issuance when full rather than evicting active records. It is limited to one process and loses state at restart.
65
+
66
+ ## Release
67
+
68
+ Publishing a GitHub Release triggers `.github/workflows/npm-publish.yml`. Pushes to branches or tags, pull requests, draft releases and release-note edits do not trigger publication.
69
+
70
+ 1. Update the version in `package.json` and `package-lock.json`, for example with `npm version patch --no-git-tag-version`.
71
+ 2. Commit and push the version change and release code to `main`.
72
+ 3. In GitHub, open **Releases → Draft a new release**. Create a tag matching the package version exactly, such as `v0.1.1`, targeting the release commit on `main`.
73
+ 4. Add the release title and notes, then click **Publish release**.
74
+ 5. Check the **Publish to npm** workflow. It validates the release tag and both manifest versions, installs dependencies, runs the checks, and publishes the tagged code to npm with provenance. Its summary reports whether publication succeeded.
75
+
76
+ The npm trusted publisher must point to organisation `nodana`, repository `phoenixd-mpp-sdk`, workflow `npm-publish.yml`, with no environment and direct publishing allowed. The source repository must be public for npm provenance. Stable releases publish to the `latest` npm dist-tag; GitHub prereleases publish to `next`.
77
+
78
+ The workflow checks and builds before publishing, then uses `--ignore-scripts` to avoid running `prepublishOnly` a second time. Manual `npm publish` still runs the full test/build hook. Test results remain in the logs; the Actions summary reports the release outcome.
79
+
80
+ npm versions cannot be overwritten. If a release fails before publication, fix the cause before retrying. For versions already on npm, create a GitHub Release from the tag matching their original source commit rather than publishing the package again.
81
+
82
+ When retrying an unpublished version after changing the workflow or code, its tag must include those fixes. Commit and push the fixes first, then replace the failed tag before creating the GitHub Release. Do not move a tag for a version already published to npm.
package/package.json CHANGED
@@ -1,6 +1,83 @@
1
1
  {
2
2
  "name": "@nodana/phoenixd-mpp-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Phoenixd Lightning payment methods for the Machine Payments Protocol",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22.12.0"
9
+ },
10
+ "sideEffects": false,
11
+ "main": "./dist/index.cjs",
12
+ "module": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "import": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "require": {
21
+ "types": "./dist/index.d.cts",
22
+ "default": "./dist/index.cjs"
23
+ }
24
+ },
25
+ "./server": {
26
+ "import": {
27
+ "types": "./dist/server/index.d.ts",
28
+ "default": "./dist/server/index.js"
29
+ },
30
+ "require": {
31
+ "types": "./dist/server/index.d.cts",
32
+ "default": "./dist/server/index.cjs"
33
+ }
34
+ },
35
+ "./client": {
36
+ "import": {
37
+ "types": "./dist/client/index.d.ts",
38
+ "default": "./dist/client/index.js"
39
+ },
40
+ "require": {
41
+ "types": "./dist/client/index.d.cts",
42
+ "default": "./dist/client/index.cjs"
43
+ }
44
+ }
45
+ },
46
+ "files": [
47
+ "dist",
48
+ "docs",
49
+ "NOTICE"
50
+ ],
51
+ "scripts": {
52
+ "build": "tsup",
53
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json",
54
+ "test:unit": "vitest run --reporter=default",
55
+ "test": "npm run typecheck && npm run test:unit && npm run build && node tests/package-smoke.mjs",
56
+ "prepublishOnly": "npm test",
57
+ "test:redis": "vitest run --config vitest.redis.config.ts --reporter=default"
58
+ },
59
+ "peerDependencies": {
60
+ "@nodana/phoenixd-ts": "^0.2.0",
61
+ "mppx": "^0.13.4"
62
+ },
63
+ "devDependencies": {
64
+ "@nodana/phoenixd-ts": "^0.2.0",
65
+ "mppx": "^0.13.4",
66
+ "@types/node": "^22.0.0",
67
+ "typescript": "^5.9.3",
68
+ "tsup": "^8.5.0",
69
+ "vitest": "^4.1.11",
70
+ "esbuild": "^0.28.2"
71
+ },
72
+ "overrides": {
73
+ "esbuild": "^0.28.2"
74
+ },
75
+ "repository": {
76
+ "type": "git",
77
+ "url": "git+https://github.com/nodana/phoenixd-mpp-sdk.git"
78
+ },
79
+ "homepage": "https://github.com/nodana/phoenixd-mpp-sdk#readme",
80
+ "bugs": {
81
+ "url": "https://github.com/nodana/phoenixd-mpp-sdk/issues"
82
+ }
83
+ }