@obbyx/sdk 0.2.0 → 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/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/seller/paywall.d.ts +20 -0
- package/dist/seller/paywall.d.ts.map +1 -1
- package/dist/seller/paywall.js +171 -125
- package/dist/seller/paywall.js.map +1 -1
- package/package.json +3 -3
- package/src/client/paying-fetch.ts +96 -64
- package/src/seller/paywall.ts +214 -174
package/src/seller/paywall.ts
CHANGED
|
@@ -74,6 +74,32 @@ export interface PaidResult {
|
|
|
74
74
|
|
|
75
75
|
export type PaidHandler = (req: Request) => Promise<PaidResult> | PaidResult;
|
|
76
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
|
+
|
|
77
103
|
export interface RoutePrice {
|
|
78
104
|
/** Atomic units as a decimal string. */
|
|
79
105
|
readonly amount: string;
|
|
@@ -168,38 +194,53 @@ export class ObytePaywall {
|
|
|
168
194
|
paid(route: PaidRouteOptions, handler: PaidHandler): RequestHandler {
|
|
169
195
|
const asset = route.price.asset ?? 'base';
|
|
170
196
|
if (!isAssetId(asset)) throw new Error(`Invalid asset ${asset}`);
|
|
171
|
-
const
|
|
197
|
+
const price = { asset, amount: parseAmount(route.price.amount).toString() };
|
|
172
198
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
173
199
|
const requestId = String(req.get('x-request-id') ?? cryptoId());
|
|
174
200
|
withLogContext({ requestId, network: this.options.network }, () =>
|
|
175
|
-
this.handle(req, res, route,
|
|
201
|
+
this.handle({ req, res, route, price, handler }).catch(next),
|
|
176
202
|
);
|
|
177
203
|
};
|
|
178
204
|
}
|
|
179
205
|
|
|
180
|
-
private async handle(
|
|
181
|
-
req
|
|
182
|
-
res: Response,
|
|
183
|
-
route: PaidRouteOptions,
|
|
184
|
-
price: { asset: string; amount: string },
|
|
185
|
-
handler: PaidHandler,
|
|
186
|
-
): Promise<void> {
|
|
206
|
+
private async handle(call: PaidCall): Promise<void> {
|
|
207
|
+
const { req, res } = call;
|
|
187
208
|
exposePaymentHeaders(res);
|
|
188
209
|
if (this.options.cors !== undefined) allowOrigin(req, res, this.options.cors);
|
|
189
210
|
res.setHeader('Cache-Control', 'private, no-store');
|
|
190
211
|
const header = req.get('payment-signature');
|
|
191
|
-
if (header === undefined) return this.paymentRequired(
|
|
212
|
+
if (header === undefined) return this.paymentRequired(call);
|
|
192
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
|
+
}
|
|
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 } {
|
|
193
235
|
let payload: PaymentPayload;
|
|
194
236
|
try {
|
|
195
237
|
payload = decodePaymentSignatureHeader(header);
|
|
196
238
|
} catch {
|
|
197
|
-
|
|
198
|
-
return;
|
|
239
|
+
return { kind: 'bad_header' };
|
|
199
240
|
}
|
|
241
|
+
const { route, price } = call;
|
|
200
242
|
const accepted = payload.accepted;
|
|
201
243
|
const scheme = accepted?.scheme as PaywallScheme | undefined;
|
|
202
|
-
const offered = this.schemesFor(route);
|
|
203
244
|
const extra =
|
|
204
245
|
scheme === EXACT_SCHEME
|
|
205
246
|
? exactExtraSchema.safeParse(accepted?.extra)
|
|
@@ -210,152 +251,169 @@ export class ObytePaywall {
|
|
|
210
251
|
if (
|
|
211
252
|
payload.x402Version !== 2 ||
|
|
212
253
|
scheme === undefined ||
|
|
213
|
-
!
|
|
254
|
+
!this.schemesFor(route).includes(scheme) ||
|
|
214
255
|
accepted?.network !== this.options.network ||
|
|
215
256
|
accepted.asset !== price.asset ||
|
|
216
257
|
accepted.amount !== price.amount ||
|
|
217
258
|
accepted.payTo !== this.options.payTo ||
|
|
218
|
-
extra
|
|
219
|
-
!extra.success ||
|
|
259
|
+
extra?.success !== true ||
|
|
220
260
|
proofId === undefined ||
|
|
221
261
|
(scheme === BATCH_SCHEME &&
|
|
222
262
|
(extra.data as { minTimeout?: number }).minTimeout !==
|
|
223
263
|
this.options.batch?.minChannelTimeoutSeconds)
|
|
224
264
|
) {
|
|
225
|
-
return
|
|
265
|
+
return { kind: 'refused', error: 'invalid_payment_requirements' };
|
|
226
266
|
}
|
|
227
|
-
const facts = this.requestFacts(req, route);
|
|
267
|
+
const facts = this.requestFacts(call.req, route);
|
|
228
268
|
const terms = this.terms(price);
|
|
229
|
-
|
|
230
|
-
|
|
269
|
+
if (
|
|
270
|
+
!bindingsEqual(
|
|
271
|
+
offerBinding(this.options, scheme, extra.data, facts, terms),
|
|
272
|
+
extra.data.binding,
|
|
273
|
+
)
|
|
274
|
+
) {
|
|
231
275
|
this.log.warn({ proofId }, 'payment is bound to another operation or a forged quote');
|
|
232
|
-
return
|
|
276
|
+
return { kind: 'refused', error: 'invalid_binding' };
|
|
233
277
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
}
|
|
240
297
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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' });
|
|
257
331
|
return;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (decision.kind === 'reject') {
|
|
272
|
-
res.status(decision.status).json({ error: decision.error });
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
if (decision.kind === 'replay') {
|
|
276
|
-
const original = store.get(decision.proofKey);
|
|
277
|
-
this.log.info('payment-identifier repeat: stored response, new payment NOT settled');
|
|
278
|
-
return this.send(res, original?.response ?? null, original?.paymentResponse ?? null);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
if (created && extra.data.validUntil < this.now()) {
|
|
282
|
-
store.forget(key, 'settling');
|
|
283
|
-
return this.paymentRequired(req, res, route, price, 'quote_expired');
|
|
284
|
-
}
|
|
285
|
-
switch (record.state) {
|
|
286
|
-
case 'served':
|
|
287
|
-
this.log.info('replaying stored response for a repeated operation');
|
|
288
|
-
return this.send(res, record.response, record.paymentResponse);
|
|
289
|
-
case 'serving':
|
|
290
|
-
if (record.paymentResponse !== null)
|
|
291
|
-
res.setHeader('PAYMENT-RESPONSE', record.paymentResponse);
|
|
292
|
-
res.status(409).json({ error: 'payment_in_progress' });
|
|
293
|
-
return;
|
|
294
|
-
case 'failed':
|
|
295
|
-
res.status(402).json({ error: 'payment_failed' });
|
|
296
|
-
return;
|
|
297
|
-
case 'paid_not_served':
|
|
298
|
-
return this.serve(req, res, key, record.paymentResponse, handler);
|
|
299
|
-
case 'awaiting_settle':
|
|
300
|
-
return this.settleServed(req, res, route, price, key, payload, accepted);
|
|
301
|
-
case 'settling':
|
|
302
|
-
break;
|
|
303
|
-
}
|
|
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
|
+
}
|
|
304
345
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
+
}
|
|
308
374
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
this.log.info(
|
|
322
|
-
{ transaction: settled.transaction },
|
|
323
|
-
'settlement pending; client should retry with the same payment',
|
|
324
|
-
);
|
|
325
|
-
res.setHeader('PAYMENT-RESPONSE', paymentResponse);
|
|
326
|
-
res.setHeader('Retry-After', '5');
|
|
327
|
-
res.status(402).json({ error: 'settlement_pending', transaction: settled.transaction });
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
if (settled.transaction === '') {
|
|
331
|
-
// Rejected before reaching the network: the proof was not consumed.
|
|
332
|
-
// Only the request that created the row removes it: a concurrent
|
|
333
|
-
// request with the same payment may be settling it right now.
|
|
334
|
-
if (created) store.forget(key, 'settling');
|
|
335
|
-
} else {
|
|
336
|
-
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);
|
|
337
387
|
}
|
|
338
|
-
this.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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');
|
|
342
408
|
}
|
|
343
409
|
|
|
344
410
|
/** authorization flow: verify → handler (response stored) → settle → send. */
|
|
345
|
-
private async authorize(
|
|
346
|
-
|
|
347
|
-
res: Response,
|
|
348
|
-
route: PaidRouteOptions,
|
|
349
|
-
price: { asset: string; amount: string },
|
|
350
|
-
key: string,
|
|
351
|
-
payload: PaymentPayload,
|
|
352
|
-
accepted: PaymentRequirements,
|
|
353
|
-
handler: PaidHandler,
|
|
354
|
-
): Promise<void> {
|
|
411
|
+
private async authorize(payment: Payment): Promise<void> {
|
|
412
|
+
const { res, key } = payment;
|
|
355
413
|
const store = this.options.store;
|
|
356
414
|
let verified;
|
|
357
415
|
try {
|
|
358
|
-
verified = await this.options.facilitator.verify(payload, accepted);
|
|
416
|
+
verified = await this.options.facilitator.verify(payment.payload, payment.accepted);
|
|
359
417
|
} catch (err) {
|
|
360
418
|
this.log.warn({ err }, 'facilitator unreachable during verify');
|
|
361
419
|
store.forget(key, 'settling');
|
|
@@ -364,13 +422,7 @@ export class ObytePaywall {
|
|
|
364
422
|
}
|
|
365
423
|
if (!verified.isValid) {
|
|
366
424
|
store.forget(key, 'settling');
|
|
367
|
-
return this.paymentRequired(
|
|
368
|
-
req,
|
|
369
|
-
res,
|
|
370
|
-
route,
|
|
371
|
-
price,
|
|
372
|
-
verified.invalidReason ?? 'invalid_payment',
|
|
373
|
-
);
|
|
425
|
+
return this.paymentRequired(payment, verified.invalidReason ?? 'invalid_payment');
|
|
374
426
|
}
|
|
375
427
|
if (!store.transition(key, 'settling', 'serving')) {
|
|
376
428
|
res.status(409).json({ error: 'payment_in_progress' });
|
|
@@ -378,7 +430,7 @@ export class ObytePaywall {
|
|
|
378
430
|
}
|
|
379
431
|
let result: PaidResult;
|
|
380
432
|
try {
|
|
381
|
-
result = await handler(req);
|
|
433
|
+
result = await payment.handler(payment.req);
|
|
382
434
|
} catch (err) {
|
|
383
435
|
store.transition(key, 'serving', 'settling'); // nothing settled; the same payment may retry
|
|
384
436
|
this.log.error({ err }, 'paid handler failed before settlement');
|
|
@@ -386,20 +438,14 @@ export class ObytePaywall {
|
|
|
386
438
|
return;
|
|
387
439
|
}
|
|
388
440
|
store.transition(key, 'serving', 'awaiting_settle', { response: toStored(result) });
|
|
389
|
-
return this.settleServed(
|
|
441
|
+
return this.settleServed(payment);
|
|
390
442
|
}
|
|
391
443
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
res
|
|
395
|
-
route: PaidRouteOptions,
|
|
396
|
-
price: { asset: string; amount: string },
|
|
397
|
-
key: string,
|
|
398
|
-
payload: PaymentPayload,
|
|
399
|
-
accepted: PaymentRequirements,
|
|
400
|
-
): 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;
|
|
401
447
|
const store = this.options.store;
|
|
402
|
-
const settled = await this.settleWithWait(payload, accepted);
|
|
448
|
+
const settled = await this.settleWithWait(payment.payload, payment.accepted);
|
|
403
449
|
const paymentResponse = encodePaymentResponseHeader(settled);
|
|
404
450
|
if (settled.success) {
|
|
405
451
|
store.transition(key, 'awaiting_settle', 'served', { paymentResponse });
|
|
@@ -412,25 +458,25 @@ export class ObytePaywall {
|
|
|
412
458
|
}
|
|
413
459
|
res.setHeader('PAYMENT-RESPONSE', paymentResponse);
|
|
414
460
|
if (settled.errorReason === 'settlement_pending') {
|
|
415
|
-
|
|
416
|
-
res.status(402).json({ error: 'settlement_pending', transaction: settled.transaction });
|
|
417
|
-
return;
|
|
461
|
+
return this.answerPending(res, settled.transaction);
|
|
418
462
|
}
|
|
419
463
|
store.transition(key, 'awaiting_settle', 'failed', { paymentResponse });
|
|
420
464
|
this.log.warn(
|
|
421
465
|
{ reason: settled.errorReason },
|
|
422
466
|
'payment failed AFTER the handler ran (authorization flow); response withheld',
|
|
423
467
|
);
|
|
424
|
-
return this.paymentRequired(
|
|
468
|
+
return this.paymentRequired(payment, settled.errorReason ?? 'payment_failed');
|
|
425
469
|
}
|
|
426
470
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
res
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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;
|
|
434
480
|
const store = this.options.store;
|
|
435
481
|
if (!store.transition(key, 'paid_not_served', 'serving')) {
|
|
436
482
|
res.status(409).json({ error: 'payment_in_progress' });
|
|
@@ -438,7 +484,7 @@ export class ObytePaywall {
|
|
|
438
484
|
}
|
|
439
485
|
let result: PaidResult;
|
|
440
486
|
try {
|
|
441
|
-
result = await handler(req);
|
|
487
|
+
result = await payment.handler(payment.req);
|
|
442
488
|
} catch (err) {
|
|
443
489
|
store.transition(key, 'serving', 'paid_not_served');
|
|
444
490
|
this.log.error(
|
|
@@ -472,13 +518,7 @@ export class ObytePaywall {
|
|
|
472
518
|
res.status(response.status).end(Buffer.from(response.body));
|
|
473
519
|
}
|
|
474
520
|
|
|
475
|
-
private paymentRequired(
|
|
476
|
-
req: Request,
|
|
477
|
-
res: Response,
|
|
478
|
-
route: PaidRouteOptions,
|
|
479
|
-
price: { asset: string; amount: string },
|
|
480
|
-
error?: string,
|
|
481
|
-
): void {
|
|
521
|
+
private paymentRequired({ req, res, route, price }: PaidCall, error?: string): void {
|
|
482
522
|
// A refused payment that was never settled: say so explicitly (success:
|
|
483
523
|
// false, empty transaction) so the client knows it was NOT used and can
|
|
484
524
|
// release its budget and its channel voucher. A settle failure already
|
|
@@ -550,7 +590,7 @@ export class ObytePaywall {
|
|
|
550
590
|
};
|
|
551
591
|
}
|
|
552
592
|
|
|
553
|
-
private terms(price:
|
|
593
|
+
private terms(price: Price): PaymentTerms {
|
|
554
594
|
return { network: this.options.network, payTo: this.options.payTo, ...price };
|
|
555
595
|
}
|
|
556
596
|
|