@obbyx/sdk 0.1.1 → 0.2.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.
- package/README.md +14 -0
- package/dist/client/paying-fetch.d.ts.map +1 -1
- package/dist/client/paying-fetch.js +68 -61
- package/dist/client/paying-fetch.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/seller/consumed-proofs.d.ts.map +1 -1
- package/dist/seller/consumed-proofs.js +8 -15
- package/dist/seller/consumed-proofs.js.map +1 -1
- package/dist/seller/cors.d.ts +33 -0
- package/dist/seller/cors.d.ts.map +1 -0
- package/dist/seller/cors.js +77 -0
- package/dist/seller/cors.js.map +1 -0
- package/dist/seller/paywall.d.ts +32 -0
- package/dist/seller/paywall.d.ts.map +1 -1
- package/dist/seller/paywall.js +183 -127
- package/dist/seller/paywall.js.map +1 -1
- package/dist/seller/x402-server.d.ts +48 -8
- package/dist/seller/x402-server.d.ts.map +1 -1
- package/dist/seller/x402-server.js +63 -22
- package/dist/seller/x402-server.js.map +1 -1
- package/package.json +3 -3
- package/src/client/paying-fetch.ts +96 -64
- package/src/index.ts +10 -1
- package/src/seller/consumed-proofs.ts +10 -16
- package/src/seller/cors.ts +106 -0
- package/src/seller/paywall.ts +237 -177
- package/src/seller/x402-server.ts +101 -29
package/src/seller/paywall.ts
CHANGED
|
@@ -43,6 +43,12 @@ import {
|
|
|
43
43
|
operationFingerprint,
|
|
44
44
|
} from './quote.js';
|
|
45
45
|
import type { ConsumedProofsStore, StoredResponse } from './consumed-proofs.js';
|
|
46
|
+
import {
|
|
47
|
+
type PaywallCorsOptions,
|
|
48
|
+
allowOrigin,
|
|
49
|
+
exposePaymentHeaders,
|
|
50
|
+
preflightHandler,
|
|
51
|
+
} from './cors.js';
|
|
46
52
|
import {
|
|
47
53
|
DEFAULT_MAX_TIMEOUT_SECONDS,
|
|
48
54
|
DEFAULT_QUOTE_TTL_SECONDS,
|
|
@@ -68,6 +74,32 @@ export interface PaidResult {
|
|
|
68
74
|
|
|
69
75
|
export type PaidHandler = (req: Request) => Promise<PaidResult> | PaidResult;
|
|
70
76
|
|
|
77
|
+
/** A route's price, resolved: asset and atomic amount. */
|
|
78
|
+
type Price = { readonly asset: string; readonly amount: string };
|
|
79
|
+
|
|
80
|
+
/** One call of a paid route. */
|
|
81
|
+
interface PaidCall {
|
|
82
|
+
readonly req: Request;
|
|
83
|
+
readonly res: Response;
|
|
84
|
+
readonly route: PaidRouteOptions;
|
|
85
|
+
readonly price: Price;
|
|
86
|
+
readonly handler: PaidHandler;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A paid call whose payment matches this offer and was bound to this request. */
|
|
90
|
+
interface Payment extends PaidCall {
|
|
91
|
+
readonly payload: PaymentPayload;
|
|
92
|
+
readonly accepted: PaymentRequirements;
|
|
93
|
+
/** Unit hash (exact) or voucher hash (batch). */
|
|
94
|
+
readonly proofId: string;
|
|
95
|
+
/** Proof key in the consumed-proofs store (network-scoped). */
|
|
96
|
+
readonly key: string;
|
|
97
|
+
readonly binding: string;
|
|
98
|
+
readonly validUntil: number;
|
|
99
|
+
readonly facts: RequestFacts;
|
|
100
|
+
readonly terms: PaymentTerms;
|
|
101
|
+
}
|
|
102
|
+
|
|
71
103
|
export interface RoutePrice {
|
|
72
104
|
/** Atomic units as a decimal string. */
|
|
73
105
|
readonly amount: string;
|
|
@@ -122,11 +154,15 @@ export interface ObytePaywallOptions {
|
|
|
122
154
|
readonly paymentIdentifier?: { readonly required: boolean };
|
|
123
155
|
/** Enables batch-settlement (AA payment channels) offers; requires a running seller watcher. */
|
|
124
156
|
readonly batch?: { readonly minChannelTimeoutSeconds: number };
|
|
157
|
+
/**
|
|
158
|
+
* CORS for browser clients on another origin (x402 HTTP transport, "Browser
|
|
159
|
+
* clients"). Also mount {@link ObytePaywall.preflight} before the routes.
|
|
160
|
+
* Without it, same-origin pages and non-browser clients work as before.
|
|
161
|
+
*/
|
|
162
|
+
readonly cors?: PaywallCorsOptions;
|
|
125
163
|
readonly now?: () => number;
|
|
126
164
|
}
|
|
127
165
|
|
|
128
|
-
const EXPOSE = 'PAYMENT-REQUIRED, PAYMENT-RESPONSE';
|
|
129
|
-
|
|
130
166
|
export class ObytePaywall {
|
|
131
167
|
private readonly log: Logger;
|
|
132
168
|
private readonly now: () => number;
|
|
@@ -145,41 +181,66 @@ export class ObytePaywall {
|
|
|
145
181
|
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
146
182
|
}
|
|
147
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Answers CORS preflight requests (`app.use(paywall.preflight())`); a no-op
|
|
186
|
+
* without the `cors` option. Preflights never require payment.
|
|
187
|
+
*/
|
|
188
|
+
preflight(): RequestHandler {
|
|
189
|
+
const cors = this.options.cors;
|
|
190
|
+
return cors === undefined ? (_req, _res, next) => next() : preflightHandler(cors);
|
|
191
|
+
}
|
|
192
|
+
|
|
148
193
|
/** Wraps a handler so it only runs after a settled payment. */
|
|
149
194
|
paid(route: PaidRouteOptions, handler: PaidHandler): RequestHandler {
|
|
150
195
|
const asset = route.price.asset ?? 'base';
|
|
151
196
|
if (!isAssetId(asset)) throw new Error(`Invalid asset ${asset}`);
|
|
152
|
-
const
|
|
197
|
+
const price = { asset, amount: parseAmount(route.price.amount).toString() };
|
|
153
198
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
154
199
|
const requestId = String(req.get('x-request-id') ?? cryptoId());
|
|
155
200
|
withLogContext({ requestId, network: this.options.network }, () =>
|
|
156
|
-
this.handle(req, res, route,
|
|
201
|
+
this.handle({ req, res, route, price, handler }).catch(next),
|
|
157
202
|
);
|
|
158
203
|
};
|
|
159
204
|
}
|
|
160
205
|
|
|
161
|
-
private async handle(
|
|
162
|
-
req
|
|
163
|
-
res
|
|
164
|
-
|
|
165
|
-
price: { asset: string; amount: string },
|
|
166
|
-
handler: PaidHandler,
|
|
167
|
-
): Promise<void> {
|
|
168
|
-
res.setHeader('Access-Control-Expose-Headers', EXPOSE);
|
|
206
|
+
private async handle(call: PaidCall): Promise<void> {
|
|
207
|
+
const { req, res } = call;
|
|
208
|
+
exposePaymentHeaders(res);
|
|
209
|
+
if (this.options.cors !== undefined) allowOrigin(req, res, this.options.cors);
|
|
169
210
|
res.setHeader('Cache-Control', 'private, no-store');
|
|
170
211
|
const header = req.get('payment-signature');
|
|
171
|
-
if (header === undefined) return this.paymentRequired(
|
|
212
|
+
if (header === undefined) return this.paymentRequired(call);
|
|
213
|
+
|
|
214
|
+
const parsed = this.parsePayment(call, header);
|
|
215
|
+
if (parsed.kind === 'bad_header') {
|
|
216
|
+
res.status(400).json({ error: 'invalid_payment_header' });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (parsed.kind === 'refused') return this.paymentRequired(call, parsed.error);
|
|
220
|
+
const payment = parsed.payment;
|
|
221
|
+
await withLogContext({ binding: payment.binding, proofId: payment.proofId }, () =>
|
|
222
|
+
this.process(payment),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
172
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Decodes the payment and checks it against THIS offer: our scheme, network,
|
|
228
|
+
* price and payee, and a binding we issued for this very request.
|
|
229
|
+
*/
|
|
230
|
+
private parsePayment(
|
|
231
|
+
call: PaidCall,
|
|
232
|
+
header: string,
|
|
233
|
+
):
|
|
234
|
+
{ kind: 'ok'; payment: Payment } | { kind: 'bad_header' } | { kind: 'refused'; error: string } {
|
|
173
235
|
let payload: PaymentPayload;
|
|
174
236
|
try {
|
|
175
237
|
payload = decodePaymentSignatureHeader(header);
|
|
176
238
|
} catch {
|
|
177
|
-
|
|
178
|
-
return;
|
|
239
|
+
return { kind: 'bad_header' };
|
|
179
240
|
}
|
|
241
|
+
const { route, price } = call;
|
|
180
242
|
const accepted = payload.accepted;
|
|
181
243
|
const scheme = accepted?.scheme as PaywallScheme | undefined;
|
|
182
|
-
const offered = this.schemesFor(route);
|
|
183
244
|
const extra =
|
|
184
245
|
scheme === EXACT_SCHEME
|
|
185
246
|
? exactExtraSchema.safeParse(accepted?.extra)
|
|
@@ -190,152 +251,169 @@ export class ObytePaywall {
|
|
|
190
251
|
if (
|
|
191
252
|
payload.x402Version !== 2 ||
|
|
192
253
|
scheme === undefined ||
|
|
193
|
-
!
|
|
254
|
+
!this.schemesFor(route).includes(scheme) ||
|
|
194
255
|
accepted?.network !== this.options.network ||
|
|
195
256
|
accepted.asset !== price.asset ||
|
|
196
257
|
accepted.amount !== price.amount ||
|
|
197
258
|
accepted.payTo !== this.options.payTo ||
|
|
198
|
-
extra
|
|
199
|
-
!extra.success ||
|
|
259
|
+
extra?.success !== true ||
|
|
200
260
|
proofId === undefined ||
|
|
201
261
|
(scheme === BATCH_SCHEME &&
|
|
202
262
|
(extra.data as { minTimeout?: number }).minTimeout !==
|
|
203
263
|
this.options.batch?.minChannelTimeoutSeconds)
|
|
204
264
|
) {
|
|
205
|
-
return
|
|
265
|
+
return { kind: 'refused', error: 'invalid_payment_requirements' };
|
|
206
266
|
}
|
|
207
|
-
const facts = this.requestFacts(req, route);
|
|
267
|
+
const facts = this.requestFacts(call.req, route);
|
|
208
268
|
const terms = this.terms(price);
|
|
209
|
-
|
|
210
|
-
|
|
269
|
+
if (
|
|
270
|
+
!bindingsEqual(
|
|
271
|
+
offerBinding(this.options, scheme, extra.data, facts, terms),
|
|
272
|
+
extra.data.binding,
|
|
273
|
+
)
|
|
274
|
+
) {
|
|
211
275
|
this.log.warn({ proofId }, 'payment is bound to another operation or a forged quote');
|
|
212
|
-
return
|
|
276
|
+
return { kind: 'refused', error: 'invalid_binding' };
|
|
213
277
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
278
|
+
return {
|
|
279
|
+
kind: 'ok',
|
|
280
|
+
payment: {
|
|
281
|
+
...call,
|
|
282
|
+
payload,
|
|
283
|
+
accepted,
|
|
284
|
+
proofId,
|
|
285
|
+
binding: extra.data.binding,
|
|
286
|
+
validUntil: extra.data.validUntil,
|
|
287
|
+
facts,
|
|
288
|
+
terms,
|
|
289
|
+
key: networkKey(
|
|
290
|
+
getNetwork(this.options.network),
|
|
291
|
+
scheme === EXACT_SCHEME ? 'unit' : 'voucher',
|
|
292
|
+
proofId,
|
|
293
|
+
),
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
220
297
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
298
|
+
/** Registers the proof, then continues from its state: one payment, one operation. */
|
|
299
|
+
private async process(payment: Payment): Promise<void> {
|
|
300
|
+
const { res, key, binding } = payment;
|
|
301
|
+
const store = this.options.store;
|
|
302
|
+
const begun = store.begin(key, binding);
|
|
303
|
+
const { created } = begun;
|
|
304
|
+
let { record } = begun;
|
|
305
|
+
// A proof left in `serving` by a crashed process is recovered after the lease.
|
|
306
|
+
if (
|
|
307
|
+
record.state === 'serving' &&
|
|
308
|
+
store.reclaimStale(key, this.options.servingLeaseSeconds ?? DEFAULT_SERVING_LEASE_SECONDS)
|
|
309
|
+
) {
|
|
310
|
+
this.log.warn('recovered a payment stuck in serving (lease expired)');
|
|
311
|
+
record = store.get(key) ?? record;
|
|
312
|
+
}
|
|
313
|
+
if (!bindingsEqual(record.binding, binding)) {
|
|
314
|
+
res.setHeader('PAYMENT-RESPONSE', this.refusal('invalid_payment_reused'));
|
|
315
|
+
res.status(402).json({ error: 'invalid_payment_reused' });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (this.paymentIdentifierAnswered(payment, created)) return;
|
|
319
|
+
if (created && payment.validUntil < this.now()) {
|
|
320
|
+
store.forget(key, 'settling');
|
|
321
|
+
return this.paymentRequired(payment, 'quote_expired');
|
|
322
|
+
}
|
|
323
|
+
switch (record.state) {
|
|
324
|
+
case 'served':
|
|
325
|
+
this.log.info('replaying stored response for a repeated operation');
|
|
326
|
+
return this.send(res, record.response, record.paymentResponse);
|
|
327
|
+
case 'serving':
|
|
328
|
+
if (record.paymentResponse !== null)
|
|
329
|
+
res.setHeader('PAYMENT-RESPONSE', record.paymentResponse);
|
|
330
|
+
res.status(409).json({ error: 'payment_in_progress' });
|
|
237
331
|
return;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (decision.kind === 'reject') {
|
|
252
|
-
res.status(decision.status).json({ error: decision.error });
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
if (decision.kind === 'replay') {
|
|
256
|
-
const original = store.get(decision.proofKey);
|
|
257
|
-
this.log.info('payment-identifier repeat: stored response, new payment NOT settled');
|
|
258
|
-
return this.send(res, original?.response ?? null, original?.paymentResponse ?? null);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
if (created && extra.data.validUntil < this.now()) {
|
|
262
|
-
store.forget(key, 'settling');
|
|
263
|
-
return this.paymentRequired(req, res, route, price, 'quote_expired');
|
|
264
|
-
}
|
|
265
|
-
switch (record.state) {
|
|
266
|
-
case 'served':
|
|
267
|
-
this.log.info('replaying stored response for a repeated operation');
|
|
268
|
-
return this.send(res, record.response, record.paymentResponse);
|
|
269
|
-
case 'serving':
|
|
270
|
-
if (record.paymentResponse !== null)
|
|
271
|
-
res.setHeader('PAYMENT-RESPONSE', record.paymentResponse);
|
|
272
|
-
res.status(409).json({ error: 'payment_in_progress' });
|
|
273
|
-
return;
|
|
274
|
-
case 'failed':
|
|
275
|
-
res.status(402).json({ error: 'payment_failed' });
|
|
276
|
-
return;
|
|
277
|
-
case 'paid_not_served':
|
|
278
|
-
return this.serve(req, res, key, record.paymentResponse, handler);
|
|
279
|
-
case 'awaiting_settle':
|
|
280
|
-
return this.settleServed(req, res, route, price, key, payload, accepted);
|
|
281
|
-
case 'settling':
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
332
|
+
case 'failed':
|
|
333
|
+
res.status(402).json({ error: 'payment_failed' });
|
|
334
|
+
return;
|
|
335
|
+
case 'paid_not_served':
|
|
336
|
+
return this.serve(payment, record.paymentResponse);
|
|
337
|
+
case 'awaiting_settle':
|
|
338
|
+
return this.settleServed(payment);
|
|
339
|
+
case 'settling':
|
|
340
|
+
return payment.route.flow === 'authorization'
|
|
341
|
+
? this.authorize(payment)
|
|
342
|
+
: this.settleUpfront(payment, created);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
284
345
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
346
|
+
/**
|
|
347
|
+
* x402 payment-identifier: one client id = one operation. Decided AFTER the
|
|
348
|
+
* proof row exists, so a concurrent request always sees this proof as in
|
|
349
|
+
* progress (never as "absent") and cannot take the id over. True when the
|
|
350
|
+
* response was already sent (refused, or the stored response replayed).
|
|
351
|
+
*/
|
|
352
|
+
private paymentIdentifierAnswered(payment: Payment, created: boolean): boolean {
|
|
353
|
+
const declared = this.options.paymentIdentifier;
|
|
354
|
+
if (declared === undefined) return false;
|
|
355
|
+
const store = this.options.store;
|
|
356
|
+
const decision = decidePaymentId(store, {
|
|
357
|
+
paymentId: paymentIdOf(payment.payload),
|
|
358
|
+
required: declared.required,
|
|
359
|
+
scopeId: (id) => `${this.options.sellerId}:${id}`,
|
|
360
|
+
fingerprint: operationFingerprint(payment.facts, payment.terms),
|
|
361
|
+
proofKey: payment.key,
|
|
362
|
+
});
|
|
363
|
+
if (decision.kind === 'proceed') return false;
|
|
364
|
+
if (created) store.forget(payment.key, 'settling');
|
|
365
|
+
if (decision.kind === 'reject') {
|
|
366
|
+
payment.res.status(decision.status).json({ error: decision.error });
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
const original = store.get(decision.proofKey);
|
|
370
|
+
this.log.info('payment-identifier repeat: stored response, new payment NOT settled');
|
|
371
|
+
this.send(payment.res, original?.response ?? null, original?.paymentResponse ?? null);
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
288
374
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
this.log.info(
|
|
302
|
-
{ transaction: settled.transaction },
|
|
303
|
-
'settlement pending; client should retry with the same payment',
|
|
304
|
-
);
|
|
305
|
-
res.setHeader('PAYMENT-RESPONSE', paymentResponse);
|
|
306
|
-
res.setHeader('Retry-After', '5');
|
|
307
|
-
res.status(402).json({ error: 'settlement_pending', transaction: settled.transaction });
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
if (settled.transaction === '') {
|
|
311
|
-
// Rejected before reaching the network: the proof was not consumed.
|
|
312
|
-
// Only the request that created the row removes it: a concurrent
|
|
313
|
-
// request with the same payment may be settling it right now.
|
|
314
|
-
if (created) store.forget(key, 'settling');
|
|
315
|
-
} else {
|
|
316
|
-
store.transition(key, 'settling', 'failed', { paymentResponse });
|
|
375
|
+
/** upfront flow: settle to finality, then run the handler once. */
|
|
376
|
+
private async settleUpfront(payment: Payment, created: boolean): Promise<void> {
|
|
377
|
+
const { res, key } = payment;
|
|
378
|
+
const store = this.options.store;
|
|
379
|
+
const settled = await this.settleWithWait(payment.payload, payment.accepted);
|
|
380
|
+
const paymentResponse = encodePaymentResponseHeader(settled);
|
|
381
|
+
if (settled.success) {
|
|
382
|
+
this.log.info({ transaction: settled.transaction }, 'payment settled');
|
|
383
|
+
if (store.markSettled(key, payment.binding, paymentResponse) === 'served') {
|
|
384
|
+
// A concurrent request with the same payment already served it.
|
|
385
|
+
const served = store.get(key);
|
|
386
|
+
return this.send(res, served?.response ?? null, served?.paymentResponse ?? null);
|
|
317
387
|
}
|
|
318
|
-
this.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
388
|
+
return this.serve(payment, paymentResponse);
|
|
389
|
+
}
|
|
390
|
+
res.setHeader('PAYMENT-RESPONSE', paymentResponse);
|
|
391
|
+
if (settled.errorReason === 'settlement_pending') {
|
|
392
|
+
this.log.info(
|
|
393
|
+
{ transaction: settled.transaction },
|
|
394
|
+
'settlement pending; client should retry with the same payment',
|
|
395
|
+
);
|
|
396
|
+
return this.answerPending(res, settled.transaction);
|
|
397
|
+
}
|
|
398
|
+
if (settled.transaction === '') {
|
|
399
|
+
// Rejected before reaching the network: the proof was not consumed.
|
|
400
|
+
// Only the request that created the row removes it: a concurrent
|
|
401
|
+
// request with the same payment may be settling it right now.
|
|
402
|
+
if (created) store.forget(key, 'settling');
|
|
403
|
+
} else {
|
|
404
|
+
store.transition(key, 'settling', 'failed', { paymentResponse });
|
|
405
|
+
}
|
|
406
|
+
this.log.warn({ reason: settled.errorReason }, 'payment failed');
|
|
407
|
+
return this.paymentRequired(payment, settled.errorReason ?? 'payment_failed');
|
|
322
408
|
}
|
|
323
409
|
|
|
324
410
|
/** authorization flow: verify → handler (response stored) → settle → send. */
|
|
325
|
-
private async authorize(
|
|
326
|
-
|
|
327
|
-
res: Response,
|
|
328
|
-
route: PaidRouteOptions,
|
|
329
|
-
price: { asset: string; amount: string },
|
|
330
|
-
key: string,
|
|
331
|
-
payload: PaymentPayload,
|
|
332
|
-
accepted: PaymentRequirements,
|
|
333
|
-
handler: PaidHandler,
|
|
334
|
-
): Promise<void> {
|
|
411
|
+
private async authorize(payment: Payment): Promise<void> {
|
|
412
|
+
const { res, key } = payment;
|
|
335
413
|
const store = this.options.store;
|
|
336
414
|
let verified;
|
|
337
415
|
try {
|
|
338
|
-
verified = await this.options.facilitator.verify(payload, accepted);
|
|
416
|
+
verified = await this.options.facilitator.verify(payment.payload, payment.accepted);
|
|
339
417
|
} catch (err) {
|
|
340
418
|
this.log.warn({ err }, 'facilitator unreachable during verify');
|
|
341
419
|
store.forget(key, 'settling');
|
|
@@ -344,13 +422,7 @@ export class ObytePaywall {
|
|
|
344
422
|
}
|
|
345
423
|
if (!verified.isValid) {
|
|
346
424
|
store.forget(key, 'settling');
|
|
347
|
-
return this.paymentRequired(
|
|
348
|
-
req,
|
|
349
|
-
res,
|
|
350
|
-
route,
|
|
351
|
-
price,
|
|
352
|
-
verified.invalidReason ?? 'invalid_payment',
|
|
353
|
-
);
|
|
425
|
+
return this.paymentRequired(payment, verified.invalidReason ?? 'invalid_payment');
|
|
354
426
|
}
|
|
355
427
|
if (!store.transition(key, 'settling', 'serving')) {
|
|
356
428
|
res.status(409).json({ error: 'payment_in_progress' });
|
|
@@ -358,7 +430,7 @@ export class ObytePaywall {
|
|
|
358
430
|
}
|
|
359
431
|
let result: PaidResult;
|
|
360
432
|
try {
|
|
361
|
-
result = await handler(req);
|
|
433
|
+
result = await payment.handler(payment.req);
|
|
362
434
|
} catch (err) {
|
|
363
435
|
store.transition(key, 'serving', 'settling'); // nothing settled; the same payment may retry
|
|
364
436
|
this.log.error({ err }, 'paid handler failed before settlement');
|
|
@@ -366,20 +438,14 @@ export class ObytePaywall {
|
|
|
366
438
|
return;
|
|
367
439
|
}
|
|
368
440
|
store.transition(key, 'serving', 'awaiting_settle', { response: toStored(result) });
|
|
369
|
-
return this.settleServed(
|
|
441
|
+
return this.settleServed(payment);
|
|
370
442
|
}
|
|
371
443
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
res
|
|
375
|
-
route: PaidRouteOptions,
|
|
376
|
-
price: { asset: string; amount: string },
|
|
377
|
-
key: string,
|
|
378
|
-
payload: PaymentPayload,
|
|
379
|
-
accepted: PaymentRequirements,
|
|
380
|
-
): Promise<void> {
|
|
444
|
+
/** authorization flow, after the handler: settle, then send the stored response. */
|
|
445
|
+
private async settleServed(payment: Payment): Promise<void> {
|
|
446
|
+
const { res, key } = payment;
|
|
381
447
|
const store = this.options.store;
|
|
382
|
-
const settled = await this.settleWithWait(payload, accepted);
|
|
448
|
+
const settled = await this.settleWithWait(payment.payload, payment.accepted);
|
|
383
449
|
const paymentResponse = encodePaymentResponseHeader(settled);
|
|
384
450
|
if (settled.success) {
|
|
385
451
|
store.transition(key, 'awaiting_settle', 'served', { paymentResponse });
|
|
@@ -392,25 +458,25 @@ export class ObytePaywall {
|
|
|
392
458
|
}
|
|
393
459
|
res.setHeader('PAYMENT-RESPONSE', paymentResponse);
|
|
394
460
|
if (settled.errorReason === 'settlement_pending') {
|
|
395
|
-
|
|
396
|
-
res.status(402).json({ error: 'settlement_pending', transaction: settled.transaction });
|
|
397
|
-
return;
|
|
461
|
+
return this.answerPending(res, settled.transaction);
|
|
398
462
|
}
|
|
399
463
|
store.transition(key, 'awaiting_settle', 'failed', { paymentResponse });
|
|
400
464
|
this.log.warn(
|
|
401
465
|
{ reason: settled.errorReason },
|
|
402
466
|
'payment failed AFTER the handler ran (authorization flow); response withheld',
|
|
403
467
|
);
|
|
404
|
-
return this.paymentRequired(
|
|
468
|
+
return this.paymentRequired(payment, settled.errorReason ?? 'payment_failed');
|
|
405
469
|
}
|
|
406
470
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
res
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
471
|
+
/** Not final yet: the client retries with the same payment. */
|
|
472
|
+
private answerPending(res: Response, transaction: string): void {
|
|
473
|
+
res.setHeader('Retry-After', '5');
|
|
474
|
+
res.status(402).json({ error: 'settlement_pending', transaction });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Runs the handler once for a settled payment and stores its response. */
|
|
478
|
+
private async serve(payment: Payment, paymentResponse: string | null): Promise<void> {
|
|
479
|
+
const { res, key } = payment;
|
|
414
480
|
const store = this.options.store;
|
|
415
481
|
if (!store.transition(key, 'paid_not_served', 'serving')) {
|
|
416
482
|
res.status(409).json({ error: 'payment_in_progress' });
|
|
@@ -418,7 +484,7 @@ export class ObytePaywall {
|
|
|
418
484
|
}
|
|
419
485
|
let result: PaidResult;
|
|
420
486
|
try {
|
|
421
|
-
result = await handler(req);
|
|
487
|
+
result = await payment.handler(payment.req);
|
|
422
488
|
} catch (err) {
|
|
423
489
|
store.transition(key, 'serving', 'paid_not_served');
|
|
424
490
|
this.log.error(
|
|
@@ -452,13 +518,7 @@ export class ObytePaywall {
|
|
|
452
518
|
res.status(response.status).end(Buffer.from(response.body));
|
|
453
519
|
}
|
|
454
520
|
|
|
455
|
-
private paymentRequired(
|
|
456
|
-
req: Request,
|
|
457
|
-
res: Response,
|
|
458
|
-
route: PaidRouteOptions,
|
|
459
|
-
price: { asset: string; amount: string },
|
|
460
|
-
error?: string,
|
|
461
|
-
): void {
|
|
521
|
+
private paymentRequired({ req, res, route, price }: PaidCall, error?: string): void {
|
|
462
522
|
// A refused payment that was never settled: say so explicitly (success:
|
|
463
523
|
// false, empty transaction) so the client knows it was NOT used and can
|
|
464
524
|
// release its budget and its channel voucher. A settle failure already
|
|
@@ -530,7 +590,7 @@ export class ObytePaywall {
|
|
|
530
590
|
};
|
|
531
591
|
}
|
|
532
592
|
|
|
533
|
-
private terms(price:
|
|
593
|
+
private terms(price: Price): PaymentTerms {
|
|
534
594
|
return { network: this.options.network, payTo: this.options.payTo, ...price };
|
|
535
595
|
}
|
|
536
596
|
|