@genesis-tech/genesispay-seller 0.13.2 → 1.3.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/CHANGELOG.md +196 -0
- package/README.md +670 -91
- package/dist/checkout-documents.d.ts +99 -0
- package/dist/checkout-documents.d.ts.map +1 -0
- package/dist/checkout-documents.js +155 -0
- package/dist/checkout-documents.js.map +1 -0
- package/dist/checkout-return.d.ts +5 -5
- package/dist/checkout-return.d.ts.map +1 -1
- package/dist/checkout-return.js +3 -2
- package/dist/checkout-return.js.map +1 -1
- package/dist/client.d.ts +42 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +99 -10
- package/dist/client.js.map +1 -1
- package/dist/contract-mandates.d.ts +39 -0
- package/dist/contract-mandates.d.ts.map +1 -0
- package/dist/contract-mandates.js +98 -0
- package/dist/contract-mandates.js.map +1 -0
- package/dist/contract-usage.d.ts +29 -0
- package/dist/contract-usage.d.ts.map +1 -0
- package/dist/contract-usage.js +92 -0
- package/dist/contract-usage.js.map +1 -0
- package/dist/errors.d.ts +71 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +124 -13
- package/dist/errors.js.map +1 -1
- package/dist/fulfillment-attempts.d.ts +29 -0
- package/dist/fulfillment-attempts.d.ts.map +1 -0
- package/dist/fulfillment-attempts.js +71 -0
- package/dist/fulfillment-attempts.js.map +1 -0
- package/dist/fulfillment.d.ts +152 -0
- package/dist/fulfillment.d.ts.map +1 -0
- package/dist/fulfillment.js +810 -0
- package/dist/fulfillment.js.map +1 -0
- package/dist/genesispay-settlement.d.ts +4 -2
- package/dist/genesispay-settlement.d.ts.map +1 -1
- package/dist/genesispay-settlement.js +412 -15
- package/dist/genesispay-settlement.js.map +1 -1
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/invoices.d.ts +31 -0
- package/dist/invoices.d.ts.map +1 -1
- package/dist/invoices.js +61 -2
- package/dist/invoices.js.map +1 -1
- package/dist/item-tax.d.ts +15 -0
- package/dist/item-tax.d.ts.map +1 -0
- package/dist/item-tax.js +19 -0
- package/dist/item-tax.js.map +1 -0
- package/dist/mandate-gate.d.ts +6 -1
- package/dist/mandate-gate.d.ts.map +1 -1
- package/dist/mandate-gate.js +81 -7
- package/dist/mandate-gate.js.map +1 -1
- package/dist/mandates.d.ts +21 -4
- package/dist/mandates.d.ts.map +1 -1
- package/dist/mandates.js +24 -3
- package/dist/mandates.js.map +1 -1
- package/dist/networks.d.ts.map +1 -1
- package/dist/networks.js +5 -4
- package/dist/networks.js.map +1 -1
- package/dist/payment-gate.d.ts +32 -1
- package/dist/payment-gate.d.ts.map +1 -1
- package/dist/payment-gate.js +275 -11
- package/dist/payment-gate.js.map +1 -1
- package/dist/products.d.ts +40 -16
- package/dist/products.d.ts.map +1 -1
- package/dist/products.js +1078 -56
- package/dist/products.js.map +1 -1
- package/dist/strict-response.d.ts +21 -0
- package/dist/strict-response.d.ts.map +1 -0
- package/dist/strict-response.js +68 -0
- package/dist/strict-response.js.map +1 -0
- package/dist/webhooks.d.ts +77 -2
- package/dist/webhooks.d.ts.map +1 -1
- package/dist/webhooks.js +20 -0
- package/dist/webhooks.js.map +1 -1
- package/package.json +3 -3
package/dist/products.js
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
|
+
// This file deliberately keeps the product resource, strict checkout contract,
|
|
2
|
+
// and ProductGate adapter together. They share one product wire model and one
|
|
3
|
+
// post-settlement authority transition; splitting the gate from the resource
|
|
4
|
+
// would either expose internal evidence-validation seams or duplicate the exact
|
|
5
|
+
// recovery rules that prevent a paid handler from running without verification.
|
|
6
|
+
//
|
|
1
7
|
// `genesispay.products.*` — the merchant's catalogue over `/api/v1/products`.
|
|
2
8
|
//
|
|
3
|
-
// A product is what a shop sells
|
|
4
|
-
// reusable checkout
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
import {
|
|
9
|
+
// A product is what a shop sells. Legacy catalogue flows expose one canonical
|
|
10
|
+
// reusable link; strict 1.0 checkout creation freezes a distinct single-use
|
|
11
|
+
// link. Price, asset, destination, and delivery are copied onto either payable
|
|
12
|
+
// resource and never re-read from mutable catalogue presentation for authority.
|
|
13
|
+
// Everything is scoped to the key's own account: someone else's product answers
|
|
14
|
+
// 404 (GenesisPayNotFoundError), never 403 — the API is no catalogue-enumeration
|
|
15
|
+
// oracle, and the SDK does not paper over that.
|
|
16
|
+
import { parseItemTax } from "./item-tax.js";
|
|
17
|
+
import { GenesisPayAmbiguousLocatorError, GenesisPayConfigError, GenesisPayContractMismatchError, GenesisPayEvidenceError, GenesisPayNotFoundError, GenesisPayRateLimitError, GenesisPayValidationError, GenesisPayVersionError, } from "./errors.js";
|
|
11
18
|
import { asRecord, optionalString, requiredString, toAmountString, oneOf, } from "./resource.js";
|
|
12
|
-
import { PAYMENT_RESPONSE_HEADER, PAYMENT_SIGNATURE_HEADER } from "@genesis-tech/genesispay-protocol";
|
|
19
|
+
import { GENESISPAY_SETTLEMENT_PREPARE_HEADER, GENESISPAY_SETTLEMENT_PREPARE_PARAMS_HEADER, PAYMENT_RESPONSE_HEADER, PAYMENT_SIGNATURE_HEADER, decodePaymentSignatureHeader, decodeSettlementPrepareParamsHeader, encodeSettlementResponseHeader, } from "@genesis-tech/genesispay-protocol";
|
|
20
|
+
import { GENESISPAY_API_VERSION, GENESISPAY_PAYMENT_ATTEMPT_ID_HEADER, GENESISPAY_REQUEST_ID_HEADER, GENESISPAY_VERSION_HEADER, validateExpectedProductContract, } from "./fulfillment.js";
|
|
21
|
+
import { isStrictVersionedResponse, requireStrictResponseMetadata, } from "./strict-response.js";
|
|
22
|
+
const PRODUCT_SETTLEMENT_TIMEOUT_MS = 120_000;
|
|
13
23
|
export function createProductsResource(request,
|
|
14
24
|
/**
|
|
15
25
|
* Applies the client's `expectedPayTo` pin to the wallet the server froze
|
|
@@ -20,7 +30,7 @@ assertPinnedDestination,
|
|
|
20
30
|
/** The client's raw-link mapper, so link shapes cannot drift between resources. */
|
|
21
31
|
toLink,
|
|
22
32
|
/** The client's origin (no trailing slash) — the base of `permalink()`. */
|
|
23
|
-
baseUrl, rawGateRequest) {
|
|
33
|
+
baseUrl, verifyFulfillment, rawGateRequest, strictRequest) {
|
|
24
34
|
const resources = {
|
|
25
35
|
create: async (input) => {
|
|
26
36
|
const body = await request({
|
|
@@ -36,6 +46,7 @@ baseUrl, rawGateRequest) {
|
|
|
36
46
|
...(input.sku ? { sku: input.sku } : {}),
|
|
37
47
|
...(input.asset ? { asset: input.asset } : {}),
|
|
38
48
|
...(input.delivery ? { delivery: input.delivery } : {}),
|
|
49
|
+
...(input.taxConfig ? { taxConfig: parseItemTax(input.taxConfig) } : {}),
|
|
39
50
|
},
|
|
40
51
|
});
|
|
41
52
|
return requireProduct(body, "POST /api/v1/products");
|
|
@@ -79,7 +90,9 @@ baseUrl, rawGateRequest) {
|
|
|
79
90
|
operation: `PATCH /api/v1/products/${id}`,
|
|
80
91
|
action: `update product "${id}"`,
|
|
81
92
|
notFoundMessage: notFoundMessage(id),
|
|
82
|
-
body: "
|
|
93
|
+
body: "taxConfig" in input
|
|
94
|
+
? { taxConfig: parseItemTax(input.taxConfig), expectedTaxConfig: parseItemTax(input.expectedTaxConfig) }
|
|
95
|
+
: "delivery" in input ? { delivery: input.delivery } : { fulfilmentUrl: input.fulfilmentUrl },
|
|
83
96
|
});
|
|
84
97
|
return requireProduct(body, `PATCH /api/v1/products/${id}`);
|
|
85
98
|
},
|
|
@@ -119,6 +132,42 @@ baseUrl, rawGateRequest) {
|
|
|
119
132
|
created: record?.created === true,
|
|
120
133
|
};
|
|
121
134
|
},
|
|
135
|
+
assertContract: async (expectedInput) => {
|
|
136
|
+
if (!strictRequest)
|
|
137
|
+
throw new GenesisPayConfigError("This GenesisPay client cannot verify product contracts.");
|
|
138
|
+
const expected = validateExpectedProductContract(expectedInput);
|
|
139
|
+
assertPinnedDestination(expected.settlementDestination, "product contract");
|
|
140
|
+
const response = await strictRequest({
|
|
141
|
+
path: `/api/v1/products/${encodeURIComponent(expected.productId)}/contract`,
|
|
142
|
+
});
|
|
143
|
+
const body = await response.clone().json().catch(() => null);
|
|
144
|
+
if (!response.ok)
|
|
145
|
+
throwStrictProductError(response, body);
|
|
146
|
+
assertProductContractResponse(response, body, expected);
|
|
147
|
+
},
|
|
148
|
+
createCheckout: async (input, options) => {
|
|
149
|
+
if (!strictRequest)
|
|
150
|
+
throw new GenesisPayConfigError("This GenesisPay client cannot create product checkout links.");
|
|
151
|
+
const expected = validateExpectedProductContract(input?.expected);
|
|
152
|
+
assertPinnedDestination(expected.settlementDestination, "product checkout contract");
|
|
153
|
+
const idempotencyKey = requireStrictIdempotencyKey(options);
|
|
154
|
+
const response = await strictRequest({
|
|
155
|
+
method: "POST",
|
|
156
|
+
path: `/api/v1/products/${encodeURIComponent(expected.productId)}/checkouts`,
|
|
157
|
+
headers: { "Idempotency-Key": idempotencyKey },
|
|
158
|
+
body: {
|
|
159
|
+
expected: { ...expected, grossAmountMinor: expected.grossAmountMinor.toString() },
|
|
160
|
+
clientReferenceId: input.clientReferenceId ?? null,
|
|
161
|
+
metadata: input.metadata ?? null,
|
|
162
|
+
returnUrl: input.returnUrl ?? null,
|
|
163
|
+
cancelUrl: input.cancelUrl ?? null,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const body = await response.clone().json().catch(() => null);
|
|
167
|
+
if (!response.ok)
|
|
168
|
+
throwStrictProductError(response, body);
|
|
169
|
+
return parseProductCheckoutResponse(response, body);
|
|
170
|
+
},
|
|
122
171
|
checkoutUrl: async (publicId) => {
|
|
123
172
|
const { link } = await resources.createPaymentLink(publicId);
|
|
124
173
|
return link.payUrl;
|
|
@@ -149,6 +198,13 @@ baseUrl, rawGateRequest) {
|
|
|
149
198
|
assertPinnedDestination(rawLink.destinationWallet, "product payment link");
|
|
150
199
|
return { link: toLink(rawLink), created: record?.created === true };
|
|
151
200
|
},
|
|
201
|
+
statusRequest: strictRequest
|
|
202
|
+
? (planId) => strictRequest({
|
|
203
|
+
path: `/api/v1/settlements/${encodeURIComponent(planId)}`,
|
|
204
|
+
})
|
|
205
|
+
: undefined,
|
|
206
|
+
verifyFulfillment,
|
|
207
|
+
assertPinnedDestination,
|
|
152
208
|
request: rawGateRequest,
|
|
153
209
|
}),
|
|
154
210
|
};
|
|
@@ -174,6 +230,7 @@ function requireProduct(body, operation) {
|
|
|
174
230
|
}
|
|
175
231
|
export function toProduct(raw) {
|
|
176
232
|
return {
|
|
233
|
+
taxConfig: parseItemTax(raw.taxConfig),
|
|
177
234
|
publicId: requiredString(raw.publicId),
|
|
178
235
|
name: requiredString(raw.name),
|
|
179
236
|
description: optionalString(raw.description),
|
|
@@ -201,6 +258,198 @@ function toProductDelivery(raw) {
|
|
|
201
258
|
const url = optionalString(raw.fulfilmentUrl);
|
|
202
259
|
return url ? { type: "redirect", url, verifiedAt: optionalString(raw.fulfilmentVerifiedAt) } : { type: "none" };
|
|
203
260
|
}
|
|
261
|
+
function requireStrictIdempotencyKey(options) {
|
|
262
|
+
const value = options?.idempotencyKey;
|
|
263
|
+
if (typeof value !== "string" ||
|
|
264
|
+
value.length < 1 ||
|
|
265
|
+
value.length > 255 ||
|
|
266
|
+
value.trim() !== value) {
|
|
267
|
+
throw new GenesisPayValidationError("products.createCheckout requires an idempotencyKey with 1–255 characters and no surrounding whitespace.", [{ path: "idempotencyKey", message: "invalid idempotency key" }]);
|
|
268
|
+
}
|
|
269
|
+
return value;
|
|
270
|
+
}
|
|
271
|
+
function strictResponseMetadata(response, body) {
|
|
272
|
+
return requireStrictResponseMetadata(response, body, "product");
|
|
273
|
+
}
|
|
274
|
+
function assertProductContractResponse(response, body, expected) {
|
|
275
|
+
const metadata = strictResponseMetadata(response, body);
|
|
276
|
+
const contract = asRecord(asRecord(body)?.contract);
|
|
277
|
+
if (!contract || contract.object !== "product_contract") {
|
|
278
|
+
throw new GenesisPayEvidenceError("malformed_evidence", "Product contract response omitted its product_contract object.", metadata);
|
|
279
|
+
}
|
|
280
|
+
const amount = contract.grossAmountMinor;
|
|
281
|
+
if (typeof amount !== "string" ||
|
|
282
|
+
!/^(0|[1-9][0-9]*)$/.test(amount) ||
|
|
283
|
+
amount.length > 13 ||
|
|
284
|
+
BigInt(amount) <= 0n ||
|
|
285
|
+
BigInt(amount) > 1000000000000n) {
|
|
286
|
+
throw new GenesisPayEvidenceError("malformed_evidence", "Product contract grossAmountMinor is not canonical product money.", metadata);
|
|
287
|
+
}
|
|
288
|
+
const productId = contract.productId;
|
|
289
|
+
const settlementDestination = contract.settlementDestination;
|
|
290
|
+
const network = requireStrictProductNetwork(contract.network, metadata);
|
|
291
|
+
const delivery = requireStrictProductDelivery(contract.delivery, metadata);
|
|
292
|
+
if (typeof productId !== "string" ||
|
|
293
|
+
productId.length === 0 ||
|
|
294
|
+
typeof settlementDestination !== "string" ||
|
|
295
|
+
!/^0x[0-9a-fA-F]{40}$/.test(settlementDestination)) {
|
|
296
|
+
throw new GenesisPayEvidenceError("malformed_evidence", "Product contract omitted a typed product identity or settlement destination.", metadata);
|
|
297
|
+
}
|
|
298
|
+
const actualSku = contract.sku === null || typeof contract.sku === "string" ? contract.sku : undefined;
|
|
299
|
+
if (actualSku === undefined) {
|
|
300
|
+
throw new GenesisPayEvidenceError("malformed_evidence", "Product SKU must be string or null.", metadata);
|
|
301
|
+
}
|
|
302
|
+
const mismatches = [];
|
|
303
|
+
const compare = (path, equal) => {
|
|
304
|
+
if (!equal)
|
|
305
|
+
mismatches.push(path);
|
|
306
|
+
};
|
|
307
|
+
compare("productId", productId === expected.productId);
|
|
308
|
+
compare("sku", actualSku === expected.sku);
|
|
309
|
+
compare("grossAmountMinor", amount === expected.grossAmountMinor.toString());
|
|
310
|
+
compare("settlementDestination", equalAddress(settlementDestination, expected.settlementDestination));
|
|
311
|
+
compare("network.mode", network.mode === expected.network.mode);
|
|
312
|
+
compare("network.network", network.network === expected.network.network);
|
|
313
|
+
compare("network.chainId", network.chainId === expected.network.chainId);
|
|
314
|
+
compare("network.asset", network.asset === expected.network.asset);
|
|
315
|
+
compare("network.tokenAddress", equalAddress(network.tokenAddress, expected.network.tokenAddress));
|
|
316
|
+
compare("network.minorUnitScale", network.minorUnitScale === expected.network.minorUnitScale);
|
|
317
|
+
compare("delivery.type", delivery.type === expected.delivery.type);
|
|
318
|
+
compare("delivery.url", delivery.url === expected.delivery.url);
|
|
319
|
+
if (expected.delivery.type === "gate") {
|
|
320
|
+
const actualGate = asRecord(delivery.gate);
|
|
321
|
+
compare("delivery.gate.method", actualGate?.method === expected.delivery.gate?.method);
|
|
322
|
+
compare("delivery.gate.resourceUrl", actualGate?.resourceUrl === expected.delivery.gate?.resourceUrl);
|
|
323
|
+
// The request fingerprint is deliberately not compared here: it is derived
|
|
324
|
+
// from one concrete request (method + canonical URL + body digest), so it
|
|
325
|
+
// cannot be part of the product contract the backend emits. The gate's
|
|
326
|
+
// `protect` path compares it against the actual request instead.
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
compare("delivery.gate", delivery.gate === null);
|
|
330
|
+
}
|
|
331
|
+
if (mismatches.length > 0) {
|
|
332
|
+
throw new GenesisPayContractMismatchError(`GenesisPay product no longer matches the expected contract (${mismatches.join(", ")}).`, mismatches, metadata);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function requireStrictProductNetwork(value, metadata) {
|
|
336
|
+
const network = asRecord(value);
|
|
337
|
+
if (!network ||
|
|
338
|
+
(network.mode !== "test" && network.mode !== "live") ||
|
|
339
|
+
network.network !== "base" ||
|
|
340
|
+
(network.chainId !== 8453 && network.chainId !== 84532) ||
|
|
341
|
+
(network.asset !== "USDC" && network.asset !== "EURC") ||
|
|
342
|
+
typeof network.tokenAddress !== "string" ||
|
|
343
|
+
!/^0x[0-9a-fA-F]{40}$/.test(network.tokenAddress) ||
|
|
344
|
+
network.minorUnitScale !== 6) {
|
|
345
|
+
throw new GenesisPayEvidenceError("malformed_evidence", "Product contract network is missing a required typed authority field.", metadata);
|
|
346
|
+
}
|
|
347
|
+
return network;
|
|
348
|
+
}
|
|
349
|
+
function requireStrictProductDelivery(value, metadata) {
|
|
350
|
+
const delivery = asRecord(value);
|
|
351
|
+
if (!delivery || (delivery.type !== "none" && delivery.type !== "url" && delivery.type !== "gate")) {
|
|
352
|
+
throw new GenesisPayEvidenceError("malformed_evidence", "Product contract delivery has an unsupported type.", metadata);
|
|
353
|
+
}
|
|
354
|
+
if (delivery.type === "none") {
|
|
355
|
+
if (delivery.url !== null || delivery.gate !== null) {
|
|
356
|
+
throw new GenesisPayEvidenceError("inconsistent_evidence", "Product contract none delivery must carry null URL and gate fields.", metadata);
|
|
357
|
+
}
|
|
358
|
+
return delivery;
|
|
359
|
+
}
|
|
360
|
+
if (delivery.type === "url") {
|
|
361
|
+
if (delivery.gate !== null || requireCanonicalStrictProductUrl(delivery.url, metadata) === null) {
|
|
362
|
+
throw new GenesisPayEvidenceError("inconsistent_evidence", "Product contract URL delivery must carry one canonical URL and a null gate.", metadata);
|
|
363
|
+
}
|
|
364
|
+
return delivery;
|
|
365
|
+
}
|
|
366
|
+
if (delivery.url !== null) {
|
|
367
|
+
throw new GenesisPayEvidenceError("inconsistent_evidence", "Product contract gate delivery must carry a null delivery URL.", metadata);
|
|
368
|
+
}
|
|
369
|
+
const gate = asRecord(delivery.gate);
|
|
370
|
+
if (!gate ||
|
|
371
|
+
!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(String(gate.method)) ||
|
|
372
|
+
requireCanonicalStrictProductUrl(gate.resourceUrl, metadata) === null) {
|
|
373
|
+
throw new GenesisPayEvidenceError("malformed_evidence", "Product contract gate delivery is missing its typed method or canonical resource URL.", metadata);
|
|
374
|
+
}
|
|
375
|
+
return delivery;
|
|
376
|
+
}
|
|
377
|
+
function requireCanonicalStrictProductUrl(value, metadata) {
|
|
378
|
+
if (typeof value !== "string")
|
|
379
|
+
return null;
|
|
380
|
+
try {
|
|
381
|
+
const url = new URL(value);
|
|
382
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash || url.href !== value) {
|
|
383
|
+
throw new Error("unsafe or noncanonical product authority URL");
|
|
384
|
+
}
|
|
385
|
+
return url.href;
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
throw new GenesisPayEvidenceError("malformed_evidence", "Product contract authority URL is unsafe or not canonically serialized.", metadata);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function parseProductCheckoutResponse(response, body) {
|
|
392
|
+
const metadata = strictResponseMetadata(response, body);
|
|
393
|
+
const root = asRecord(body);
|
|
394
|
+
if (root?.object !== "product_checkout_link" ||
|
|
395
|
+
typeof root.linkId !== "string" ||
|
|
396
|
+
!root.linkId ||
|
|
397
|
+
typeof root.payUrl !== "string" ||
|
|
398
|
+
!root.payUrl ||
|
|
399
|
+
root.productContractVersion !== GENESISPAY_API_VERSION ||
|
|
400
|
+
typeof root.created !== "boolean") {
|
|
401
|
+
throw new GenesisPayEvidenceError("malformed_evidence", "Product checkout response is missing a required typed field.", metadata);
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
object: "product_checkout_link",
|
|
405
|
+
linkId: root.linkId,
|
|
406
|
+
payUrl: root.payUrl,
|
|
407
|
+
productContractVersion: GENESISPAY_API_VERSION,
|
|
408
|
+
created: root.created,
|
|
409
|
+
requestId: metadata.requestId,
|
|
410
|
+
apiVersion: metadata.apiVersion,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function equalAddress(actual, expected) {
|
|
414
|
+
return (typeof actual === "string" &&
|
|
415
|
+
/^0x[0-9a-fA-F]{40}$/.test(actual) &&
|
|
416
|
+
actual.toLowerCase() === expected.toLowerCase());
|
|
417
|
+
}
|
|
418
|
+
function throwStrictProductError(response, body) {
|
|
419
|
+
const metadata = strictResponseMetadata(response, body);
|
|
420
|
+
const root = asRecord(body);
|
|
421
|
+
const code = typeof root?.code === "string" ? root.code : null;
|
|
422
|
+
const message = typeof root?.error === "string"
|
|
423
|
+
? root.error
|
|
424
|
+
: `GenesisPay product request failed with HTTP ${response.status}.`;
|
|
425
|
+
if (code === "unsupported_api_version") {
|
|
426
|
+
throw new GenesisPayVersionError(code, message, metadata);
|
|
427
|
+
}
|
|
428
|
+
if (code === "contract_mismatch") {
|
|
429
|
+
const mismatches = Array.isArray(root?.mismatches)
|
|
430
|
+
? root.mismatches.filter((value) => typeof value === "string")
|
|
431
|
+
: [];
|
|
432
|
+
throw new GenesisPayContractMismatchError(message, mismatches, metadata);
|
|
433
|
+
}
|
|
434
|
+
if (code === "not_found")
|
|
435
|
+
throw new GenesisPayNotFoundError(message, metadata);
|
|
436
|
+
if (response.status === 422) {
|
|
437
|
+
const issues = Array.isArray(root?.issues)
|
|
438
|
+
? root.issues.flatMap((entry) => {
|
|
439
|
+
const issue = asRecord(entry);
|
|
440
|
+
return typeof issue?.message === "string"
|
|
441
|
+
? [{ path: typeof issue.path === "string" ? issue.path : "", message: issue.message }]
|
|
442
|
+
: [];
|
|
443
|
+
})
|
|
444
|
+
: [];
|
|
445
|
+
throw new GenesisPayValidationError(message, issues, metadata);
|
|
446
|
+
}
|
|
447
|
+
if (response.status === 429) {
|
|
448
|
+
const retry = response.headers.get("retry-after");
|
|
449
|
+
throw new GenesisPayRateLimitError(message, retry && /^\d+$/.test(retry) ? Number(retry) : null, metadata);
|
|
450
|
+
}
|
|
451
|
+
throw new GenesisPayConfigError(message, metadata);
|
|
452
|
+
}
|
|
204
453
|
function createProductGate(input) {
|
|
205
454
|
return {
|
|
206
455
|
async prime() {
|
|
@@ -209,58 +458,313 @@ function createProductGate(input) {
|
|
|
209
458
|
throw new GenesisPayConfigError(`Product "${input.publicId}" is not configured with gate delivery.`);
|
|
210
459
|
return { product, paymentLink: await input.createPaymentLink() };
|
|
211
460
|
},
|
|
212
|
-
async protect(request, handler) {
|
|
213
|
-
if (!input.request)
|
|
214
|
-
throw new GenesisPayConfigError("This GenesisPay client cannot call product gates.");
|
|
461
|
+
async protect(request, expectedInput, handler) {
|
|
215
462
|
const signature = request.headers.get(PAYMENT_SIGNATURE_HEADER);
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
463
|
+
const settlementDeadline = signature
|
|
464
|
+
? Date.now() + PRODUCT_SETTLEMENT_TIMEOUT_MS
|
|
465
|
+
: null;
|
|
466
|
+
const payerBroadcastRecovery = signature
|
|
467
|
+
? signedPayerBroadcastRecoveryResponse(signature)
|
|
468
|
+
: null;
|
|
469
|
+
if (!input.request) {
|
|
470
|
+
if (payerBroadcastRecovery) {
|
|
471
|
+
return knownBroadcastPreflightFailureResponse({
|
|
472
|
+
settlementResponse: payerBroadcastRecovery,
|
|
473
|
+
attemptId: null,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
throw new GenesisPayConfigError("This GenesisPay client cannot call product gates.");
|
|
225
477
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
478
|
+
let expected;
|
|
479
|
+
let signedAttemptId = null;
|
|
480
|
+
try {
|
|
481
|
+
expected = validateExpectedProductContract(expectedInput);
|
|
482
|
+
// MR-103: an explicit expected contract does not override the client's
|
|
483
|
+
// local destination pin, including before a signed settlement request.
|
|
484
|
+
input.assertPinnedDestination(expected.settlementDestination, "product gate contract");
|
|
485
|
+
if (expected.productId !== input.publicId) {
|
|
486
|
+
throw new GenesisPayContractMismatchError("The product gate ID does not match expected.productId.", ["product.productId"]);
|
|
233
487
|
}
|
|
234
|
-
|
|
488
|
+
if (expected.delivery.type !== "gate" || !expected.delivery.gate) {
|
|
489
|
+
throw new GenesisPayConfigError("A product gate requires expected.delivery.type gate.");
|
|
490
|
+
}
|
|
491
|
+
signedAttemptId = signature
|
|
492
|
+
? resolveSignedRequestAttemptId(request.url, signature)
|
|
493
|
+
: null;
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
if (!payerBroadcastRecovery)
|
|
497
|
+
throw error;
|
|
498
|
+
return knownBroadcastPreflightFailureResponse({
|
|
499
|
+
settlementResponse: payerBroadcastRecovery,
|
|
500
|
+
attemptId: signedAttemptId,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
const preparationRequested = !signature &&
|
|
504
|
+
request.headers.get(GENESISPAY_SETTLEMENT_PREPARE_HEADER) === "1";
|
|
505
|
+
const expectedGate = expected.delivery.gate;
|
|
506
|
+
if (preparationRequested) {
|
|
507
|
+
const paramsHeader = request.headers.get(GENESISPAY_SETTLEMENT_PREPARE_PARAMS_HEADER);
|
|
508
|
+
const prepared = paramsHeader === null
|
|
509
|
+
? await readLegacyPrepareBody(request)
|
|
510
|
+
: await readPurchaseBoundPrepareParams(request, paramsHeader, expectedGate);
|
|
511
|
+
if (!prepared.ok)
|
|
512
|
+
return prepared.response;
|
|
513
|
+
const { payer, idempotencyKey, feeMode, authority, attemptId } = prepared;
|
|
514
|
+
const response = await input.request(input.publicId, {
|
|
515
|
+
action: "prepare",
|
|
516
|
+
resourceUrl: expectedGate.resourceUrl,
|
|
517
|
+
method: expectedGate.method,
|
|
518
|
+
requestFingerprint: expectedGate.fingerprint.slice("sha256:".length),
|
|
519
|
+
attemptId,
|
|
520
|
+
payer,
|
|
521
|
+
idempotencyKey,
|
|
522
|
+
feeMode,
|
|
523
|
+
...(authority ? { authority } : {}),
|
|
524
|
+
expected: toWireExpectedProductContract(expected),
|
|
525
|
+
});
|
|
526
|
+
if (response.ok && !(await respondedWithStrictVersion(response))) {
|
|
527
|
+
return strictNegotiationFailedResponse(response);
|
|
528
|
+
}
|
|
529
|
+
return copyResponse(response);
|
|
235
530
|
}
|
|
236
|
-
const
|
|
237
|
-
|
|
531
|
+
const planId = request.headers.get("GENESISPAY-Settlement-Plan");
|
|
532
|
+
let url;
|
|
533
|
+
let requestMethod;
|
|
534
|
+
let requestFingerprint;
|
|
535
|
+
// Never read the mutable catalogue or mint a legacy link here. The
|
|
536
|
+
// challenge carries the complete expected contract so GenesisPay can
|
|
537
|
+
// compare it atomically with the exact frozen payable link before it
|
|
538
|
+
// creates an attempt. A settlement similarly belongs to that intent.
|
|
539
|
+
try {
|
|
540
|
+
url = canonicalRequestUrl(request.url);
|
|
541
|
+
requestMethod = request.method.toUpperCase();
|
|
542
|
+
requestFingerprint = await createGateRequestFingerprint(request);
|
|
543
|
+
const gateMismatches = [
|
|
544
|
+
...(url === expectedGate.resourceUrl ? [] : ["product.delivery.gate.resourceUrl"]),
|
|
545
|
+
...(requestMethod === expectedGate.method ? [] : ["product.delivery.gate.method"]),
|
|
546
|
+
...(requestFingerprint === expectedGate.fingerprint
|
|
547
|
+
? []
|
|
548
|
+
: ["product.delivery.gate.fingerprint"]),
|
|
549
|
+
];
|
|
550
|
+
if (gateMismatches.length > 0) {
|
|
551
|
+
throw new GenesisPayContractMismatchError(`The request does not match the expected gate intent (${gateMismatches.join(", ")}).`, gateMismatches);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
if (!payerBroadcastRecovery)
|
|
556
|
+
throw error;
|
|
557
|
+
return knownBroadcastPreflightFailureResponse({
|
|
558
|
+
settlementResponse: payerBroadcastRecovery,
|
|
559
|
+
attemptId: signedAttemptId,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
const gateResourceUrl = url;
|
|
563
|
+
const transportFingerprint = requestFingerprint.slice("sha256:".length);
|
|
564
|
+
const gateBody = {
|
|
238
565
|
action: signature ? "settle" : "challenge",
|
|
239
566
|
resourceUrl: gateResourceUrl,
|
|
240
567
|
method: requestMethod,
|
|
241
|
-
requestFingerprint,
|
|
242
|
-
...(signature ? {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
568
|
+
requestFingerprint: transportFingerprint,
|
|
569
|
+
...(!signature ? { expected: toWireExpectedProductContract(expected) } : {}),
|
|
570
|
+
...(signature
|
|
571
|
+
? {
|
|
572
|
+
paymentSignature: signature,
|
|
573
|
+
...(planId ? { planId } : {}),
|
|
574
|
+
}
|
|
575
|
+
: {}),
|
|
576
|
+
};
|
|
577
|
+
let response;
|
|
578
|
+
try {
|
|
579
|
+
response = settlementDeadline === null
|
|
580
|
+
? await input.request(input.publicId, gateBody)
|
|
581
|
+
: await productResponseBeforeDeadline(() => input.request(input.publicId, gateBody), settlementDeadline);
|
|
582
|
+
}
|
|
583
|
+
catch (error) {
|
|
584
|
+
if (!signedAttemptId && !payerBroadcastRecovery)
|
|
585
|
+
throw error;
|
|
586
|
+
// The request never reached a response. Whether the settlement ran at
|
|
587
|
+
// all is unknown, so the caller is told to retry or reconcile — never
|
|
588
|
+
// that the payment confirmed.
|
|
589
|
+
return fulfillmentUnavailableResponse({
|
|
590
|
+
settlementResponse: payerBroadcastRecovery ?? new Response(null),
|
|
591
|
+
attemptId: signedAttemptId,
|
|
592
|
+
requestId: null,
|
|
593
|
+
outcome: "unknown",
|
|
594
|
+
retryable: true,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
if (signature &&
|
|
598
|
+
planId &&
|
|
599
|
+
response.status === 202 &&
|
|
600
|
+
settlementDeadline !== null) {
|
|
601
|
+
let polling;
|
|
602
|
+
try {
|
|
603
|
+
polling = await pollProductSettlement({
|
|
604
|
+
acceptanceResponse: response,
|
|
605
|
+
planId,
|
|
606
|
+
statusRequest: input.statusRequest,
|
|
607
|
+
fallbackSettlementResponse: payerBroadcastRecovery,
|
|
608
|
+
timeoutDeadline: settlementDeadline,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return fulfillmentUnavailableResponse({
|
|
613
|
+
settlementResponse: payerBroadcastRecovery ?? new Response(null),
|
|
614
|
+
attemptId: signedAttemptId,
|
|
615
|
+
requestId: null,
|
|
616
|
+
outcome: "unknown",
|
|
617
|
+
retryable: true,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
if (!polling.settled)
|
|
621
|
+
return polling.response;
|
|
622
|
+
try {
|
|
623
|
+
response = await productResponseBeforeDeadline(() => input.request(input.publicId, gateBody), settlementDeadline);
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return fulfillmentUnavailableResponse({
|
|
627
|
+
settlementResponse: signedPayerBroadcastRecoveryResponse(signature, polling.transactionHash) ?? new Response(null),
|
|
628
|
+
attemptId: signedAttemptId,
|
|
629
|
+
requestId: null,
|
|
630
|
+
outcome: "unknown",
|
|
631
|
+
retryable: true,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// A challenge is the last moment before a payer is asked for money, and
|
|
636
|
+
// the only moment an un-upgraded backend can still be refused for free.
|
|
637
|
+
// Every response a payer could ACT on is checked — the 402 offer and any
|
|
638
|
+
// 2xx, since a backend that ignored `GENESISPAY-Version` may answer either
|
|
639
|
+
// and the success path below would then describe a request in which
|
|
640
|
+
// nothing was paid. A 4xx/5xx offers nothing to settle against, so it
|
|
641
|
+
// passes through as itself rather than being relabelled a version problem;
|
|
642
|
+
// reporting an outage as "upgrade your backend" sends people to the wrong
|
|
643
|
+
// runbook.
|
|
644
|
+
const payableChallenge = !signature && (response.ok || response.status === 402);
|
|
645
|
+
if (payableChallenge && !(await respondedWithStrictVersion(response))) {
|
|
646
|
+
return strictNegotiationFailedResponse(response);
|
|
647
|
+
}
|
|
648
|
+
if (!response.ok) {
|
|
649
|
+
if (payerBroadcastRecovery &&
|
|
650
|
+
response.status < 500) {
|
|
651
|
+
return fulfillmentUnavailableResponse({
|
|
652
|
+
settlementResponse: payerBroadcastRecovery,
|
|
653
|
+
attemptId: signedAttemptId,
|
|
654
|
+
requestId: response.headers.get(GENESISPAY_REQUEST_ID_HEADER),
|
|
655
|
+
outcome: "unknown",
|
|
656
|
+
retryable: true,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
if (response.status >= 500 &&
|
|
660
|
+
(signedAttemptId || payerBroadcastRecovery)) {
|
|
661
|
+
if (!signedAttemptId) {
|
|
662
|
+
return fulfillmentUnavailableResponse({
|
|
663
|
+
settlementResponse: payerBroadcastRecovery ?? new Response(null),
|
|
664
|
+
attemptId: null,
|
|
665
|
+
requestId: response.headers.get(GENESISPAY_REQUEST_ID_HEADER),
|
|
666
|
+
outcome: "unknown",
|
|
667
|
+
retryable: true,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
const recovery = await classifyStrictRecoveryResponse(response, signedAttemptId);
|
|
671
|
+
if (recovery === "copy")
|
|
672
|
+
return copyResponse(response);
|
|
673
|
+
// Only a backend that DECLARED a permanent evidence contradiction has
|
|
674
|
+
// told us a payment confirmed. An unstructured 5xx may have failed
|
|
675
|
+
// before the signature was ever verified, and a response about
|
|
676
|
+
// another attempt (or from another API version) is not about this
|
|
677
|
+
// payment at all — both leave the outcome unknown.
|
|
678
|
+
const declaredPermanent = recovery === "declared_inconsistent";
|
|
679
|
+
return fulfillmentUnavailableResponse({
|
|
680
|
+
settlementResponse: recovery === "conflicting_identity"
|
|
681
|
+
? payerBroadcastRecovery ?? new Response(null)
|
|
682
|
+
: payerBroadcastRecovery ?? response,
|
|
683
|
+
attemptId: signedAttemptId,
|
|
684
|
+
requestId: response.headers.get(GENESISPAY_REQUEST_ID_HEADER),
|
|
685
|
+
outcome: declaredPermanent ? "confirmed" : "unknown",
|
|
686
|
+
retryable: !declaredPermanent,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
249
689
|
return copyResponse(response);
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
690
|
+
}
|
|
691
|
+
const payment = asRecord(await response.clone().json().catch(() => null));
|
|
692
|
+
const resolvedAttempt = resolveSettlementAttemptId(response, payment);
|
|
693
|
+
if (!resolvedAttempt.ok) {
|
|
694
|
+
// A 2xx that carries no usable attempt identity did not establish a
|
|
695
|
+
// confirmation — a proxy answering 200 with its own page says nothing
|
|
696
|
+
// about this payment. Same skepticism an unstructured 5xx gets: report
|
|
697
|
+
// the outcome as unknown and point the caller at retry/reconciliation.
|
|
698
|
+
// The signed attempt id survives as the locator so the caller has
|
|
699
|
+
// something concrete to reconcile against.
|
|
700
|
+
return fulfillmentUnavailableResponse({
|
|
701
|
+
settlementResponse: hasConflictingAttemptIdentity(response, payment, signedAttemptId)
|
|
702
|
+
? new Response(null)
|
|
703
|
+
: response,
|
|
704
|
+
attemptId: signedAttemptId,
|
|
705
|
+
requestId: response.headers.get(GENESISPAY_REQUEST_ID_HEADER),
|
|
706
|
+
outcome: "unknown",
|
|
707
|
+
retryable: true,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
const { attemptId } = resolvedAttempt;
|
|
711
|
+
if (signedAttemptId && signedAttemptId !== attemptId) {
|
|
712
|
+
// The response named a different attempt than the one this request
|
|
713
|
+
// signed for. Keep the signed attempt as the reconciliation locator:
|
|
714
|
+
// it is the attempt the caller asked about. Neither the confirmation
|
|
715
|
+
// nor the receipt for that other attempt says what happened to this one.
|
|
716
|
+
return fulfillmentUnavailableResponse({
|
|
717
|
+
settlementResponse: new Response(null),
|
|
718
|
+
attemptId: signedAttemptId,
|
|
719
|
+
requestId: response.headers.get(GENESISPAY_REQUEST_ID_HEADER),
|
|
720
|
+
outcome: "unknown",
|
|
721
|
+
retryable: true,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
// An attempt ID alone is a locator. Only a strict successful settlement
|
|
725
|
+
// response establishes confirmation before the independent verification
|
|
726
|
+
// succeeds; lost metadata must not turn a later outage into a money claim.
|
|
727
|
+
const settlementConfirmed = payment?.success === true && isStrictVersionedResponse(response, payment);
|
|
728
|
+
let verification;
|
|
729
|
+
try {
|
|
730
|
+
verification = await input.verifyFulfillment({
|
|
731
|
+
locator: { attemptId },
|
|
732
|
+
expected,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
return fulfillmentUnavailableResponse({
|
|
737
|
+
settlementResponse: response,
|
|
258
738
|
attemptId,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
739
|
+
requestId: authorityRequestId(error) ?? response.headers.get(GENESISPAY_REQUEST_ID_HEADER),
|
|
740
|
+
outcome: settlementConfirmed ? "confirmed" : "unknown",
|
|
741
|
+
retryable: !isPermanentAuthorityError(error),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
if (!verification.verified) {
|
|
745
|
+
return fulfillmentUnavailableResponse({
|
|
746
|
+
settlementResponse: response,
|
|
747
|
+
attemptId,
|
|
748
|
+
requestId: verification.requestId,
|
|
749
|
+
// A negative authority lookup cannot corroborate the settlement
|
|
750
|
+
// response. Preserve that uncertainty instead of asserting payment.
|
|
751
|
+
outcome: "unknown",
|
|
752
|
+
retryable: verification.reason === "not_found" || verification.reason === "not_confirmed",
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
const verifiedProduct = verification.payment.product;
|
|
756
|
+
if (!verifiedProduct) {
|
|
757
|
+
return fulfillmentUnavailableResponse({
|
|
758
|
+
settlementResponse: response,
|
|
759
|
+
attemptId,
|
|
760
|
+
requestId: verification.requestId,
|
|
761
|
+
outcome: "confirmed",
|
|
762
|
+
retryable: false,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
const handlerResponse = await handler(request, {
|
|
766
|
+
product: { ...verifiedProduct, quantity: 1 },
|
|
767
|
+
payment: verification.payment,
|
|
264
768
|
requestFingerprint,
|
|
265
769
|
});
|
|
266
770
|
const out = new Headers(handlerResponse.headers);
|
|
@@ -271,10 +775,510 @@ function createProductGate(input) {
|
|
|
271
775
|
},
|
|
272
776
|
};
|
|
273
777
|
}
|
|
274
|
-
async function
|
|
778
|
+
async function pollProductSettlement(input) {
|
|
779
|
+
if (!input.statusRequest) {
|
|
780
|
+
return {
|
|
781
|
+
settled: false,
|
|
782
|
+
response: await copyPendingSettlementResponse(input.acceptanceResponse, input.fallbackSettlementResponse),
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
const acceptance = asRecord(await productStatusBeforeDeadline(() => input.acceptanceResponse.clone().json().catch(() => null), input.timeoutDeadline));
|
|
786
|
+
const expiresAtMs = typeof acceptance?.expiresAt === "string"
|
|
787
|
+
? Date.parse(acceptance.expiresAt)
|
|
788
|
+
: Number.NaN;
|
|
789
|
+
if (acceptance?.version !== 1 ||
|
|
790
|
+
acceptance.state !== "queued" ||
|
|
791
|
+
acceptance.planId !== input.planId ||
|
|
792
|
+
!Number.isFinite(expiresAtMs)) {
|
|
793
|
+
return {
|
|
794
|
+
settled: false,
|
|
795
|
+
response: await copyPendingSettlementResponse(input.acceptanceResponse, input.fallbackSettlementResponse),
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
const pollAfterMs = typeof acceptance.pollAfterMs === "number"
|
|
799
|
+
? Math.min(Math.max(Math.floor(acceptance.pollAfterMs), 100), 2_000)
|
|
800
|
+
: 500;
|
|
801
|
+
const timeoutDeadline = input.timeoutDeadline;
|
|
802
|
+
let deadline = Math.min(expiresAtMs, timeoutDeadline);
|
|
803
|
+
let submittedTxHash = null;
|
|
804
|
+
let lastPendingResponse = await copyPendingSettlementResponse(input.acceptanceResponse.clone(), input.fallbackSettlementResponse);
|
|
805
|
+
// A replayed durable acceptance can be older than its issuance window while
|
|
806
|
+
// its status is already submitted or settled. Read status once in that case;
|
|
807
|
+
// repeat only inside the applicable queue/reconciliation deadline.
|
|
808
|
+
let firstPoll = true;
|
|
809
|
+
while (firstPoll || Date.now() < deadline) {
|
|
810
|
+
const replayRead = firstPoll;
|
|
811
|
+
firstPoll = false;
|
|
812
|
+
let statusResponse;
|
|
813
|
+
let status;
|
|
814
|
+
try {
|
|
815
|
+
({ response: statusResponse, status } = await productStatusBeforeDeadline(async () => {
|
|
816
|
+
const rawResponse = await input.statusRequest(input.planId);
|
|
817
|
+
const bytes = await rawResponse.arrayBuffer();
|
|
818
|
+
const response = new Response(bytes, {
|
|
819
|
+
status: rawResponse.status,
|
|
820
|
+
statusText: rawResponse.statusText,
|
|
821
|
+
headers: rawResponse.headers,
|
|
822
|
+
});
|
|
823
|
+
return {
|
|
824
|
+
response,
|
|
825
|
+
status: asRecord(await response.clone().json().catch(() => null)),
|
|
826
|
+
};
|
|
827
|
+
}, replayRead && deadline <= Date.now() ? timeoutDeadline : deadline));
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
return { settled: false, response: lastPendingResponse };
|
|
831
|
+
}
|
|
832
|
+
if (statusResponse.ok &&
|
|
833
|
+
(status?.version !== 1 || status.planId !== input.planId)) {
|
|
834
|
+
return { settled: false, response: lastPendingResponse };
|
|
835
|
+
}
|
|
836
|
+
if (statusResponse.ok &&
|
|
837
|
+
status?.state === "settled" &&
|
|
838
|
+
status.sellerTransferVerified === true &&
|
|
839
|
+
typeof status.txHash === "string" &&
|
|
840
|
+
/^0x[0-9a-fA-F]{64}$/.test(status.txHash)) {
|
|
841
|
+
return {
|
|
842
|
+
settled: true,
|
|
843
|
+
transactionHash: status.txHash,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
if (!statusResponse.ok) {
|
|
847
|
+
return {
|
|
848
|
+
settled: false,
|
|
849
|
+
response: submittedTxHash
|
|
850
|
+
? lastPendingResponse
|
|
851
|
+
: await copyResponse(statusResponse),
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
if (status?.state === "failed" || status?.state === "expired") {
|
|
855
|
+
if (submittedTxHash) {
|
|
856
|
+
return { settled: false, response: lastPendingResponse };
|
|
857
|
+
}
|
|
858
|
+
if (statusResponse.ok && status?.state === "failed") {
|
|
859
|
+
return {
|
|
860
|
+
settled: false,
|
|
861
|
+
response: await copyTerminalSettlementResponse(statusResponse, 409),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
if (statusResponse.ok && status?.state === "expired") {
|
|
865
|
+
return {
|
|
866
|
+
settled: false,
|
|
867
|
+
response: await copyTerminalSettlementResponse(statusResponse, 410),
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (status?.state !== "queued" && status?.state !== "submitted") {
|
|
872
|
+
return { settled: false, response: lastPendingResponse };
|
|
873
|
+
}
|
|
874
|
+
if (status.state === "submitted") {
|
|
875
|
+
if (typeof status.txHash !== "string" ||
|
|
876
|
+
!/^0x[0-9a-fA-F]{64}$/.test(status.txHash)) {
|
|
877
|
+
return { settled: false, response: lastPendingResponse };
|
|
878
|
+
}
|
|
879
|
+
const candidate = status.txHash;
|
|
880
|
+
if (submittedTxHash &&
|
|
881
|
+
submittedTxHash.toLowerCase() !== candidate.toLowerCase()) {
|
|
882
|
+
return { settled: false, response: lastPendingResponse };
|
|
883
|
+
}
|
|
884
|
+
submittedTxHash = candidate;
|
|
885
|
+
deadline = timeoutDeadline;
|
|
886
|
+
}
|
|
887
|
+
lastPendingResponse = await copyPendingSettlementResponse(statusResponse.clone(), input.fallbackSettlementResponse);
|
|
888
|
+
const remainingMs = deadline - Date.now();
|
|
889
|
+
if (remainingMs <= 0)
|
|
890
|
+
break;
|
|
891
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(pollAfterMs, remainingMs)));
|
|
892
|
+
}
|
|
893
|
+
return { settled: false, response: lastPendingResponse };
|
|
894
|
+
}
|
|
895
|
+
async function productStatusBeforeDeadline(request, deadlineMs) {
|
|
896
|
+
const remainingMs = deadlineMs - Date.now();
|
|
897
|
+
if (remainingMs <= 0)
|
|
898
|
+
throw new Error("settlement polling deadline exceeded");
|
|
899
|
+
let timer;
|
|
900
|
+
try {
|
|
901
|
+
return await Promise.race([
|
|
902
|
+
request(),
|
|
903
|
+
new Promise((_resolve, reject) => {
|
|
904
|
+
timer = setTimeout(() => reject(new Error("settlement polling deadline exceeded")), remainingMs);
|
|
905
|
+
}),
|
|
906
|
+
]);
|
|
907
|
+
}
|
|
908
|
+
finally {
|
|
909
|
+
if (timer)
|
|
910
|
+
clearTimeout(timer);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
async function productResponseBeforeDeadline(request, deadlineMs) {
|
|
914
|
+
return productStatusBeforeDeadline(async () => {
|
|
915
|
+
const rawResponse = await request();
|
|
916
|
+
const bytes = await rawResponse.arrayBuffer();
|
|
917
|
+
return new Response(bytes, {
|
|
918
|
+
status: rawResponse.status,
|
|
919
|
+
statusText: rawResponse.statusText,
|
|
920
|
+
headers: rawResponse.headers,
|
|
921
|
+
});
|
|
922
|
+
}, deadlineMs);
|
|
923
|
+
}
|
|
924
|
+
function resolveSettlementAttemptId(response, payment) {
|
|
925
|
+
const bodyValue = payment?.paymentAttemptId;
|
|
926
|
+
const headerValue = response.headers.get(GENESISPAY_PAYMENT_ATTEMPT_ID_HEADER);
|
|
927
|
+
const valid = (value) => typeof value === "string" && value.length > 0 && value.trim() === value;
|
|
928
|
+
if (bodyValue !== undefined && !valid(bodyValue))
|
|
929
|
+
return { ok: false };
|
|
930
|
+
if (headerValue !== null && !valid(headerValue))
|
|
931
|
+
return { ok: false };
|
|
932
|
+
if (valid(bodyValue) && valid(headerValue) && bodyValue !== headerValue) {
|
|
933
|
+
return { ok: false };
|
|
934
|
+
}
|
|
935
|
+
const attemptId = valid(bodyValue) ? bodyValue : headerValue;
|
|
936
|
+
return valid(attemptId) ? { ok: true, attemptId } : { ok: false };
|
|
937
|
+
}
|
|
938
|
+
function invalidPrepareRequest() {
|
|
939
|
+
return {
|
|
940
|
+
ok: false,
|
|
941
|
+
response: Response.json({ error: "Invalid settlement preparation request.", code: "invalid_request" }, { status: 422 }),
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Legacy preparation (no params header): the request BODY is the plan
|
|
946
|
+
* parameters, so it cannot be the purchase body. Kept byte-for-byte for GET
|
|
947
|
+
* resources, hosted links and engines that predate the params header.
|
|
948
|
+
*/
|
|
949
|
+
async function readLegacyPrepareBody(request) {
|
|
950
|
+
const preparedBody = asRecord(await request.clone().json().catch(() => null));
|
|
951
|
+
const payer = preparedBody?.payer;
|
|
952
|
+
const idempotencyKey = preparedBody?.idempotencyKey;
|
|
953
|
+
const feeMode = preparedBody?.feeMode;
|
|
954
|
+
const authority = preparedBody?.authority;
|
|
955
|
+
const attemptId = gateAttemptIdFromUrl(request.url);
|
|
956
|
+
if (typeof payer !== "string" ||
|
|
957
|
+
!/^0x[0-9a-fA-F]{40}$/.test(payer) ||
|
|
958
|
+
(idempotencyKey !== null && typeof idempotencyKey !== "string") ||
|
|
959
|
+
(feeMode !== "collect" && feeMode !== "record_only") ||
|
|
960
|
+
!attemptId) {
|
|
961
|
+
return invalidPrepareRequest();
|
|
962
|
+
}
|
|
963
|
+
return { ok: true, payer, idempotencyKey, feeMode, authority, attemptId };
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Purchase-bound preparation (ADR-0083): the plan parameters travel in
|
|
967
|
+
* `GENESISPAY-Settlement-Prepare-Params`, so the request itself repeats the
|
|
968
|
+
* purchase — method, URL and body. It must therefore match the expected gate
|
|
969
|
+
* intent exactly as the challenge and the signed retry do; a preparation for a
|
|
970
|
+
* different request is refused before GenesisPay freezes any authority
|
|
971
|
+
* (MR-1011). A present but malformed header is refused, never read as a body.
|
|
972
|
+
*/
|
|
973
|
+
async function readPurchaseBoundPrepareParams(request, paramsHeader, expectedGate) {
|
|
974
|
+
const params = decodeSettlementPrepareParamsHeader(paramsHeader);
|
|
975
|
+
const attemptId = gateAttemptIdFromUrl(request.url);
|
|
976
|
+
if (!params || !attemptId)
|
|
977
|
+
return invalidPrepareRequest();
|
|
978
|
+
let requestUrl;
|
|
979
|
+
let requestFingerprint;
|
|
980
|
+
try {
|
|
981
|
+
requestUrl = canonicalRequestUrl(request.url);
|
|
982
|
+
requestFingerprint = await createGateRequestFingerprint(request);
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
return invalidPrepareRequest();
|
|
986
|
+
}
|
|
987
|
+
const mismatches = [
|
|
988
|
+
...(requestUrl === expectedGate.resourceUrl ? [] : ["product.delivery.gate.resourceUrl"]),
|
|
989
|
+
...(request.method.toUpperCase() === expectedGate.method
|
|
990
|
+
? []
|
|
991
|
+
: ["product.delivery.gate.method"]),
|
|
992
|
+
...(requestFingerprint === expectedGate.fingerprint
|
|
993
|
+
? []
|
|
994
|
+
: ["product.delivery.gate.fingerprint"]),
|
|
995
|
+
];
|
|
996
|
+
if (mismatches.length > 0) {
|
|
997
|
+
return {
|
|
998
|
+
ok: false,
|
|
999
|
+
response: Response.json({
|
|
1000
|
+
error: `The preparation request does not match the expected gate intent (${mismatches.join(", ")}).`,
|
|
1001
|
+
code: "contract_mismatch",
|
|
1002
|
+
mismatches,
|
|
1003
|
+
}, { status: 422 }),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
return {
|
|
1007
|
+
ok: true,
|
|
1008
|
+
payer: params.payer,
|
|
1009
|
+
idempotencyKey: params.idempotencyKey,
|
|
1010
|
+
feeMode: params.feeMode,
|
|
1011
|
+
authority: params.authority,
|
|
1012
|
+
attemptId,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
function gateAttemptIdFromUrl(value) {
|
|
1016
|
+
try {
|
|
1017
|
+
const attemptId = new URL(value).searchParams.get("gp_attempt");
|
|
1018
|
+
return attemptId &&
|
|
1019
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(attemptId)
|
|
1020
|
+
? attemptId
|
|
1021
|
+
: null;
|
|
1022
|
+
}
|
|
1023
|
+
catch {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/** Missing identity is inconclusive; an explicit contradiction invalidates its receipt. */
|
|
1028
|
+
function hasConflictingAttemptIdentity(response, body, signedAttemptId) {
|
|
1029
|
+
const headerAttemptId = response.headers.get(GENESISPAY_PAYMENT_ATTEMPT_ID_HEADER);
|
|
1030
|
+
const bodyAttemptId = body?.paymentAttemptId;
|
|
1031
|
+
return ((signedAttemptId !== null &&
|
|
1032
|
+
((headerAttemptId !== null && headerAttemptId !== signedAttemptId) ||
|
|
1033
|
+
(bodyAttemptId !== undefined && bodyAttemptId !== signedAttemptId))) ||
|
|
1034
|
+
(headerAttemptId !== null && bodyAttemptId !== undefined && headerAttemptId !== bodyAttemptId));
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* What a 5xx from a signed settlement can be trusted to mean.
|
|
1038
|
+
*
|
|
1039
|
+
* - `copy` — a complete strict recovery answer about THIS attempt; pass it on.
|
|
1040
|
+
* - `declared_inconsistent` — the backend itself declared a permanent evidence
|
|
1041
|
+
* contradiction for this attempt, which implies a persisted confirmation.
|
|
1042
|
+
* - `conflicting_identity` — the response names another attempt or another API
|
|
1043
|
+
* version; it says nothing about this payment.
|
|
1044
|
+
* - `unstructured` — an opaque 5xx that may predate signature verification.
|
|
1045
|
+
*/
|
|
1046
|
+
async function classifyStrictRecoveryResponse(response, signedAttemptId) {
|
|
1047
|
+
const body = asRecord(await response.clone().json().catch(() => null));
|
|
1048
|
+
const error = asRecord(body?.error);
|
|
1049
|
+
const headerAttemptId = response.headers.get(GENESISPAY_PAYMENT_ATTEMPT_ID_HEADER);
|
|
1050
|
+
const bodyAttemptId = body?.paymentAttemptId;
|
|
1051
|
+
const headerVersion = response.headers.get(GENESISPAY_VERSION_HEADER);
|
|
1052
|
+
const bodyVersion = body?.apiVersion;
|
|
1053
|
+
const headerRequestId = response.headers.get(GENESISPAY_REQUEST_ID_HEADER);
|
|
1054
|
+
const bodyRequestId = body?.requestId;
|
|
1055
|
+
if (hasConflictingAttemptIdentity(response, body, signedAttemptId) ||
|
|
1056
|
+
(headerVersion !== null && headerVersion !== GENESISPAY_API_VERSION) ||
|
|
1057
|
+
(bodyVersion !== undefined && bodyVersion !== GENESISPAY_API_VERSION) ||
|
|
1058
|
+
(headerRequestId !== null &&
|
|
1059
|
+
bodyRequestId !== undefined &&
|
|
1060
|
+
headerRequestId !== bodyRequestId)) {
|
|
1061
|
+
return "conflicting_identity";
|
|
1062
|
+
}
|
|
1063
|
+
// Other 5xx statuses are opaque outcomes, but their explicit identities must
|
|
1064
|
+
// still be checked before retaining any PAYMENT-RESPONSE during recovery.
|
|
1065
|
+
if (response.status !== 503)
|
|
1066
|
+
return "unstructured";
|
|
1067
|
+
const retryable = error?.retryable;
|
|
1068
|
+
const code = error?.code;
|
|
1069
|
+
const retryHeader = response.headers.get("retry-after");
|
|
1070
|
+
const cacheControl = response.headers.get("cache-control") ?? "";
|
|
1071
|
+
const hasNoStore = cacheControl
|
|
1072
|
+
.split(",")
|
|
1073
|
+
.some((directive) => directive.trim().toLowerCase() === "no-store");
|
|
1074
|
+
const requestIdIsValid = typeof bodyRequestId === "string" &&
|
|
1075
|
+
bodyRequestId.length > 0 &&
|
|
1076
|
+
bodyRequestId === headerRequestId;
|
|
1077
|
+
const errorIsValid = typeof error?.message === "string" &&
|
|
1078
|
+
(((code === "fulfillment_evidence_unavailable" ||
|
|
1079
|
+
code === "settlement_outcome_unknown") &&
|
|
1080
|
+
retryable === true &&
|
|
1081
|
+
retryHeader === "2") ||
|
|
1082
|
+
(code === "fulfillment_evidence_inconsistent" &&
|
|
1083
|
+
retryable === false &&
|
|
1084
|
+
retryHeader === null));
|
|
1085
|
+
const completeStrictRecovery = headerAttemptId === signedAttemptId &&
|
|
1086
|
+
bodyAttemptId === signedAttemptId &&
|
|
1087
|
+
headerVersion === GENESISPAY_API_VERSION &&
|
|
1088
|
+
bodyVersion === GENESISPAY_API_VERSION &&
|
|
1089
|
+
requestIdIsValid &&
|
|
1090
|
+
hasNoStore &&
|
|
1091
|
+
errorIsValid;
|
|
1092
|
+
if (completeStrictRecovery)
|
|
1093
|
+
return "copy";
|
|
1094
|
+
// A backend that has already identified a deterministic evidence
|
|
1095
|
+
// contradiction must never be made retryable merely because some response
|
|
1096
|
+
// metadata was lost or malformed in transit. Conflicting identities were
|
|
1097
|
+
// rejected above; preserve the fail-closed permanent classification here.
|
|
1098
|
+
if (code === "fulfillment_evidence_inconsistent" && retryable === false) {
|
|
1099
|
+
return "declared_inconsistent";
|
|
1100
|
+
}
|
|
1101
|
+
return "unstructured";
|
|
1102
|
+
}
|
|
1103
|
+
function resolveSignedRequestAttemptId(requestUrl, signature) {
|
|
1104
|
+
const requestAttemptId = readAttemptId(requestUrl);
|
|
1105
|
+
let payloadAttemptId = null;
|
|
1106
|
+
try {
|
|
1107
|
+
const payloadUrl = decodePaymentSignatureHeader(signature).resource?.url;
|
|
1108
|
+
payloadAttemptId = payloadUrl ? readAttemptId(payloadUrl) : null;
|
|
1109
|
+
}
|
|
1110
|
+
catch {
|
|
1111
|
+
// The backend owns full signature validation. A malformed signature cannot
|
|
1112
|
+
// supply a recovery locator, but it should still receive the normal x402
|
|
1113
|
+
// error response when transport succeeds.
|
|
1114
|
+
}
|
|
1115
|
+
if (requestAttemptId && payloadAttemptId && requestAttemptId !== payloadAttemptId) {
|
|
1116
|
+
throw new GenesisPayAmbiguousLocatorError("The signed request URL and PAYMENT-SIGNATURE carry different payment attempt IDs.");
|
|
1117
|
+
}
|
|
1118
|
+
return requestAttemptId ?? payloadAttemptId;
|
|
1119
|
+
}
|
|
1120
|
+
function signedPayerBroadcastRecoveryResponse(signature, knownTransactionHash) {
|
|
1121
|
+
try {
|
|
1122
|
+
const payment = decodePaymentSignatureHeader(signature);
|
|
1123
|
+
const extensions = payment.extensions;
|
|
1124
|
+
let transactionHash = knownTransactionHash ?? null;
|
|
1125
|
+
if (!transactionHash && extensions) {
|
|
1126
|
+
for (const key of ["txHash", "transaction", "transactionHash"]) {
|
|
1127
|
+
const value = extensions[key];
|
|
1128
|
+
if (typeof value === "string" && /^0x[0-9a-fA-F]{64}$/.test(value)) {
|
|
1129
|
+
transactionHash = value;
|
|
1130
|
+
break;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
if (!transactionHash)
|
|
1135
|
+
return null;
|
|
1136
|
+
const settlement = {
|
|
1137
|
+
success: false,
|
|
1138
|
+
transaction: transactionHash,
|
|
1139
|
+
network: payment.accepted.network,
|
|
1140
|
+
amount: payment.accepted.amount,
|
|
1141
|
+
errorReason: "The payer transaction may already be on-chain. Do not send another payment; retry or reconcile this attempt.",
|
|
1142
|
+
extensions: {
|
|
1143
|
+
authorizationVerified: false,
|
|
1144
|
+
settlementVerified: false,
|
|
1145
|
+
persistenceVerified: false,
|
|
1146
|
+
},
|
|
1147
|
+
};
|
|
1148
|
+
return new Response(null, {
|
|
1149
|
+
headers: {
|
|
1150
|
+
[PAYMENT_RESPONSE_HEADER]: encodeSettlementResponseHeader(settlement),
|
|
1151
|
+
},
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
function knownBroadcastPreflightFailureResponse(input) {
|
|
1159
|
+
return fulfillmentUnavailableResponse({
|
|
1160
|
+
settlementResponse: input.settlementResponse,
|
|
1161
|
+
attemptId: input.attemptId,
|
|
1162
|
+
requestId: null,
|
|
1163
|
+
outcome: "unknown",
|
|
1164
|
+
retryable: true,
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
function readAttemptId(value) {
|
|
1168
|
+
try {
|
|
1169
|
+
const attemptId = new URL(value).searchParams.get("gp_attempt");
|
|
1170
|
+
return attemptId && attemptId.length <= 120 && attemptId.trim() === attemptId
|
|
1171
|
+
? attemptId
|
|
1172
|
+
: null;
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
return null;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
/** Canonical SHA-256 gate intent fingerprint used in ExpectedProductContract. */
|
|
1179
|
+
export async function createGateRequestFingerprint(request) {
|
|
275
1180
|
const body = new Uint8Array(await request.clone().arrayBuffer());
|
|
276
1181
|
const bodyHash = await sha256(body);
|
|
277
|
-
|
|
1182
|
+
const fingerprint = await sha256(new TextEncoder().encode(`${request.method.toUpperCase()}\n${canonicalRequestUrl(request.url)}\n${bodyHash}`));
|
|
1183
|
+
return `sha256:${fingerprint}`;
|
|
1184
|
+
}
|
|
1185
|
+
function isPermanentAuthorityError(error) {
|
|
1186
|
+
return (error instanceof GenesisPayContractMismatchError ||
|
|
1187
|
+
error instanceof GenesisPayAmbiguousLocatorError ||
|
|
1188
|
+
error instanceof GenesisPayEvidenceError ||
|
|
1189
|
+
error instanceof GenesisPayVersionError);
|
|
1190
|
+
}
|
|
1191
|
+
function authorityRequestId(error) {
|
|
1192
|
+
if (!error || typeof error !== "object" || !("requestId" in error))
|
|
1193
|
+
return null;
|
|
1194
|
+
return typeof error.requestId === "string" ? error.requestId : null;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* The one response shape a strict gate returns when it cannot fulfil.
|
|
1198
|
+
*
|
|
1199
|
+
* `outcome` is the money claim, and it is not cosmetic: only a settlement this
|
|
1200
|
+
* SDK saw succeed may say "payment was confirmed". When the settlement outcome
|
|
1201
|
+
* is unknown — no response at all, or a 5xx that carries no strict evidence of
|
|
1202
|
+
* what happened — the caller is told exactly that and pointed at reconciliation,
|
|
1203
|
+
* because fulfilling on a payment that may never have happened is the failure
|
|
1204
|
+
* this whole surface exists to prevent (MR-804).
|
|
1205
|
+
*/
|
|
1206
|
+
function fulfillmentUnavailableResponse(input) {
|
|
1207
|
+
const code = !input.retryable
|
|
1208
|
+
? "fulfillment_evidence_inconsistent"
|
|
1209
|
+
: input.outcome === "unknown"
|
|
1210
|
+
? "settlement_outcome_unknown"
|
|
1211
|
+
: "fulfillment_evidence_unavailable";
|
|
1212
|
+
const message = !input.retryable
|
|
1213
|
+
? "Fulfillment evidence requires operator intervention. Reconcile the payment attempt before fulfilling."
|
|
1214
|
+
: input.outcome === "unknown"
|
|
1215
|
+
? "The settlement outcome for this payment attempt is unknown. Retry, or reconcile the attempt with the fulfillment verification API, before fulfilling."
|
|
1216
|
+
: "Payment was confirmed but fulfillment evidence is temporarily unavailable.";
|
|
1217
|
+
const headers = new Headers({
|
|
1218
|
+
"content-type": "application/json",
|
|
1219
|
+
"cache-control": "no-store",
|
|
1220
|
+
[GENESISPAY_VERSION_HEADER]: GENESISPAY_API_VERSION,
|
|
1221
|
+
});
|
|
1222
|
+
if (input.retryable)
|
|
1223
|
+
headers.set("retry-after", "2");
|
|
1224
|
+
if (input.attemptId) {
|
|
1225
|
+
headers.set(GENESISPAY_PAYMENT_ATTEMPT_ID_HEADER, input.attemptId);
|
|
1226
|
+
}
|
|
1227
|
+
if (input.requestId)
|
|
1228
|
+
headers.set(GENESISPAY_REQUEST_ID_HEADER, input.requestId);
|
|
1229
|
+
const paymentResponse = input.settlementResponse.headers.get(PAYMENT_RESPONSE_HEADER);
|
|
1230
|
+
if (paymentResponse)
|
|
1231
|
+
headers.set(PAYMENT_RESPONSE_HEADER, paymentResponse);
|
|
1232
|
+
return Response.json({
|
|
1233
|
+
error: { code, message, retryable: input.retryable },
|
|
1234
|
+
paymentAttemptId: input.attemptId,
|
|
1235
|
+
requestId: input.requestId,
|
|
1236
|
+
apiVersion: GENESISPAY_API_VERSION,
|
|
1237
|
+
}, { status: 503, headers });
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Did this response come from a backend that actually speaks the strict API
|
|
1241
|
+
* version we asked for? Reads a cloned body so the caller can still use the
|
|
1242
|
+
* original response.
|
|
1243
|
+
*/
|
|
1244
|
+
async function respondedWithStrictVersion(response) {
|
|
1245
|
+
const body = await response.clone().json().catch(() => null);
|
|
1246
|
+
return isStrictVersionedResponse(response, body);
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* The safe answer to an un-negotiated challenge: NOT a 402. It carries no
|
|
1250
|
+
* `accepts` offer and no PAYMENT-REQUIRED semantics, so no payer can settle
|
|
1251
|
+
* against it and no unverifiable attempt is created.
|
|
1252
|
+
*
|
|
1253
|
+
* The backend a challenge reached is the backend a settlement would reach, so
|
|
1254
|
+
* refusing here is also what keeps the settle path from ever meeting a 0.x
|
|
1255
|
+
* dialect it cannot establish evidence against.
|
|
1256
|
+
*/
|
|
1257
|
+
function strictNegotiationFailedResponse(response) {
|
|
1258
|
+
return Response.json({
|
|
1259
|
+
error: {
|
|
1260
|
+
code: "strict_version_unsupported",
|
|
1261
|
+
message: `This GenesisPay backend did not answer ${GENESISPAY_API_VERSION}. ` +
|
|
1262
|
+
"Upgrade the GenesisPay deployment before serving strict product gates; " +
|
|
1263
|
+
"no payment was requested.",
|
|
1264
|
+
retryable: false,
|
|
1265
|
+
},
|
|
1266
|
+
paymentAttemptId: null,
|
|
1267
|
+
requestId: response.headers.get(GENESISPAY_REQUEST_ID_HEADER),
|
|
1268
|
+
apiVersion: GENESISPAY_API_VERSION,
|
|
1269
|
+
}, {
|
|
1270
|
+
status: 503,
|
|
1271
|
+
headers: {
|
|
1272
|
+
"cache-control": "no-store",
|
|
1273
|
+
[GENESISPAY_VERSION_HEADER]: GENESISPAY_API_VERSION,
|
|
1274
|
+
},
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
function toWireExpectedProductContract(expected) {
|
|
1278
|
+
return {
|
|
1279
|
+
...expected,
|
|
1280
|
+
grossAmountMinor: expected.grossAmountMinor.toString(),
|
|
1281
|
+
};
|
|
278
1282
|
}
|
|
279
1283
|
function canonicalRequestUrl(value) {
|
|
280
1284
|
const url = new URL(value);
|
|
@@ -292,4 +1296,22 @@ async function sha256(value) {
|
|
|
292
1296
|
async function copyResponse(response) {
|
|
293
1297
|
return new Response(await response.arrayBuffer(), { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
294
1298
|
}
|
|
1299
|
+
async function copyPendingSettlementResponse(response, fallbackSettlementResponse) {
|
|
1300
|
+
const headers = new Headers(response.headers);
|
|
1301
|
+
const paymentResponse = headers.get(PAYMENT_RESPONSE_HEADER) ??
|
|
1302
|
+
fallbackSettlementResponse?.headers.get(PAYMENT_RESPONSE_HEADER);
|
|
1303
|
+
if (paymentResponse)
|
|
1304
|
+
headers.set(PAYMENT_RESPONSE_HEADER, paymentResponse);
|
|
1305
|
+
return new Response(await response.arrayBuffer(), {
|
|
1306
|
+
status: 202,
|
|
1307
|
+
headers,
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
async function copyTerminalSettlementResponse(response, status) {
|
|
1311
|
+
const headers = new Headers(response.headers);
|
|
1312
|
+
// A terminal queue status is not a settlement receipt. Never let a status
|
|
1313
|
+
// endpoint's accidental success header escape through the protected gate.
|
|
1314
|
+
headers.delete(PAYMENT_RESPONSE_HEADER);
|
|
1315
|
+
return new Response(await response.arrayBuffer(), { status, headers });
|
|
1316
|
+
}
|
|
295
1317
|
//# sourceMappingURL=products.js.map
|