@odatano/x402 0.2.0 → 0.3.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/CHANGELOG.md +35 -0
- package/README.md +5 -0
- package/cds-plugin.js +2 -0
- package/db/x402-grants.cds +49 -0
- package/db/x402-receipts.cds +44 -0
- package/package.json +4 -3
- package/srv/bridge.d.ts +9 -12
- package/srv/bridge.js +10 -13
- package/srv/client/axios.d.ts +1 -1
- package/srv/client/axios.js +45 -8
- package/srv/client/envelope.d.ts +1 -1
- package/srv/client/envelope.js +1 -1
- package/srv/client/errors.d.ts +86 -0
- package/srv/client/errors.js +107 -0
- package/srv/client/fetch.d.ts +2 -2
- package/srv/client/fetch.js +71 -11
- package/srv/client/pay-handlers.d.ts +4 -4
- package/srv/client/pay-handlers.js +3 -3
- package/srv/client/types.d.ts +19 -7
- package/srv/client/types.js +3 -3
- package/srv/core/asset.d.ts +1 -1
- package/srv/core/decode.d.ts +2 -2
- package/srv/core/decode.js +5 -5
- package/srv/core/errors.js +3 -3
- package/srv/core/network.d.ts +1 -1
- package/srv/core/network.js +1 -1
- package/srv/core/requirements.d.ts +37 -5
- package/srv/core/requirements.js +43 -4
- package/srv/core/types.d.ts +68 -6
- package/srv/core/types.js +3 -3
- package/srv/core/validate.d.ts +31 -7
- package/srv/core/validate.js +84 -9
- package/srv/facilitator/adapter.d.ts +8 -8
- package/srv/facilitator/adapter.js +5 -5
- package/srv/facilitator/http.d.ts +4 -4
- package/srv/facilitator/http.js +5 -5
- package/srv/facilitator/nonce.d.ts +4 -4
- package/srv/facilitator/nonce.js +4 -4
- package/srv/facilitator/server.d.ts +68 -0
- package/srv/facilitator/server.js +167 -0
- package/srv/facilitator/settle.d.ts +2 -2
- package/srv/facilitator/settle.js +4 -4
- package/srv/facilitator/verify.d.ts +5 -5
- package/srv/facilitator/verify.js +19 -5
- package/srv/helpers/build-unsigned-tx.d.ts +5 -5
- package/srv/helpers/build-unsigned-tx.js +3 -3
- package/srv/helpers/verify-confirmed.d.ts +1 -1
- package/srv/helpers/verify-confirmed.js +1 -1
- package/srv/index.d.ts +4 -2
- package/srv/index.js +9 -3
- package/srv/middleware/cap.d.ts +47 -9
- package/srv/middleware/cap.js +111 -43
- package/srv/middleware/express.d.ts +15 -10
- package/srv/middleware/express.js +18 -19
- package/srv/middleware/grants.d.ts +64 -0
- package/srv/middleware/grants.js +113 -0
- package/srv/middleware/pricing.d.ts +41 -0
- package/srv/middleware/pricing.js +78 -0
- package/srv/middleware/receipts.d.ts +38 -0
- package/srv/middleware/receipts.js +68 -0
- package/srv/plugin.d.ts +2 -2
- package/srv/plugin.js +2 -2
package/srv/middleware/cap.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* CAP services receive requests through `srv.before(...)` / `srv.on(...)`
|
|
6
6
|
* handlers, not Express middleware. For OData-served entities, the 402
|
|
7
|
-
* needs to come from `req.reject(402, body)
|
|
7
|
+
* needs to come from `req.reject(402, body)`, the response is built by
|
|
8
8
|
* CAP, not by `res.status(...)`.
|
|
9
9
|
*
|
|
10
10
|
* Usage:
|
|
@@ -34,42 +34,37 @@ const cds_1 = __importDefault(require("@sap/cds"));
|
|
|
34
34
|
const requirements_1 = require("../core/requirements");
|
|
35
35
|
const adapter_1 = require("../facilitator/adapter");
|
|
36
36
|
const errors_1 = require("../core/errors");
|
|
37
|
+
const pricing_1 = require("./pricing");
|
|
38
|
+
const receipts_1 = require("./receipts");
|
|
39
|
+
const grants_1 = require("./grants");
|
|
37
40
|
const log = cds_1.default.log('x402');
|
|
41
|
+
function getAllHeaders(req) {
|
|
42
|
+
const httpReq = req;
|
|
43
|
+
return (httpReq.http?.req?.headers ?? httpReq._?.req?.headers ?? {});
|
|
44
|
+
}
|
|
45
|
+
function getHeader(req, name) {
|
|
46
|
+
const v = getAllHeaders(req)[name];
|
|
47
|
+
return Array.isArray(v) ? v[0] : v;
|
|
48
|
+
}
|
|
38
49
|
/**
|
|
39
|
-
*
|
|
50
|
+
* Build the `PricingContext` for `resolvePrice` from a CAP request.
|
|
40
51
|
*
|
|
41
|
-
* CAP fires two distinct shapes:
|
|
42
|
-
* - CRUD on
|
|
43
|
-
*
|
|
44
|
-
* - Action call: req.event === '<actionName>'
|
|
45
|
-
* req.target may be empty or the bound-entity target
|
|
52
|
+
* CAP fires two distinct event shapes:
|
|
53
|
+
* - CRUD on entity: req.event === 'READ' | 'CREATE' | ... ; req.target.name === '<Service>.<Entity>'
|
|
54
|
+
* - Action call: req.event === '<actionName>' ; req.target may be empty or bound entity
|
|
46
55
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* through to opts.priceUnits or null.
|
|
56
|
+
* We surface both `event` (the verb / action name) and `target` (the
|
|
57
|
+
* fully-qualified entity name when present), giving the resolver enough
|
|
58
|
+
* to discriminate CRUD-vs-action and per-entity pricing.
|
|
51
59
|
*/
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (opts.priceUnits != null)
|
|
61
|
-
return String(opts.priceUnits);
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
return opts.priceUnits != null ? String(opts.priceUnits) : null;
|
|
65
|
-
}
|
|
66
|
-
function getHeader(req, name) {
|
|
67
|
-
// CAP's req.http?.req exposes the underlying express request. Fall
|
|
68
|
-
// back to req._.req for older shapes.
|
|
69
|
-
const httpReq = req;
|
|
70
|
-
const hdrs = httpReq.http?.req?.headers ?? httpReq._?.req?.headers;
|
|
71
|
-
const v = hdrs?.[name];
|
|
72
|
-
return Array.isArray(v) ? v[0] : v;
|
|
60
|
+
function capContext(req) {
|
|
61
|
+
const event = String(req.event ?? '');
|
|
62
|
+
const target = req.target?.name;
|
|
63
|
+
return {
|
|
64
|
+
event,
|
|
65
|
+
...(target ? { target } : {}),
|
|
66
|
+
headers: getAllHeaders(req),
|
|
67
|
+
};
|
|
73
68
|
}
|
|
74
69
|
function getResourceUrl(req, opts) {
|
|
75
70
|
if (opts.resourceUrl)
|
|
@@ -77,13 +72,39 @@ function getResourceUrl(req, opts) {
|
|
|
77
72
|
const httpReq = req;
|
|
78
73
|
return httpReq.http?.req?.originalUrl ?? httpReq.http?.req?.url ?? `cap://${req.event}`;
|
|
79
74
|
}
|
|
75
|
+
function getHttpRes(req) {
|
|
76
|
+
return req.http?.res;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Emit a 402 with the canonical x402 v2 body on the wire.
|
|
80
|
+
*
|
|
81
|
+
* When an Express response is reachable we write the body ourselves so
|
|
82
|
+
* third-party x402 clients see `{ x402Version: 2, accepts: [...] }` at
|
|
83
|
+
* the top level rather than CAP's OData-wrapped `{ error: { message:
|
|
84
|
+
* "<v2-json-string>", ... } }`. The `req.reject` call is always made:
|
|
85
|
+
* its synchronous throw is what terminates CAP's handler pipeline (so
|
|
86
|
+
* the gated `on` handler never runs); CAP's own render attempt no-ops
|
|
87
|
+
* because `headersSent` is already true. Validated against `@sap/cds ^9`.
|
|
88
|
+
*
|
|
89
|
+
* For non-HTTP transports (event invocations, `$batch` reuse, tests
|
|
90
|
+
* without `http.res`) we skip the direct write and let `req.reject` do
|
|
91
|
+
* the whole job — the wrap is irrelevant when there's no HTTP buyer.
|
|
92
|
+
*/
|
|
93
|
+
function send402(req, body) {
|
|
94
|
+
const httpRes = getHttpRes(req);
|
|
95
|
+
if (httpRes && !httpRes.headersSent) {
|
|
96
|
+
httpRes.status(402).json(body);
|
|
97
|
+
}
|
|
98
|
+
req
|
|
99
|
+
.reject(402, JSON.stringify(body));
|
|
100
|
+
}
|
|
80
101
|
/**
|
|
81
102
|
* Attach the x402 gate to a CAP ApplicationService. Returns the service
|
|
82
103
|
* (chainable) so callers can fluently wire multiple middlewares.
|
|
83
104
|
*
|
|
84
105
|
* The gate registers as `srv.before('*', ...)` which fires for every
|
|
85
106
|
* event on the service. We filter inside the handler based on
|
|
86
|
-
* `routePricing
|
|
107
|
+
* `routePricing`, registering per-entity would lose actions, and
|
|
87
108
|
* per-event arrays don't support the `'*'` fallback we want.
|
|
88
109
|
*/
|
|
89
110
|
function gateService(srv, opts) {
|
|
@@ -97,12 +118,40 @@ function gateService(srv, opts) {
|
|
|
97
118
|
throw new Error('gateService: priceUnits or routePricing is required');
|
|
98
119
|
}
|
|
99
120
|
const facilitator = opts.facilitator ?? (0, adapter_1.localFacilitator)();
|
|
121
|
+
const receiptsEntity = (0, receipts_1.resolveReceiptsEntity)(opts.receipts);
|
|
122
|
+
const grantsEntity = (0, grants_1.resolveGrantsEntity)(opts.grants);
|
|
123
|
+
const grantTtlSeconds = (0, grants_1.resolveGrantTtl)(opts.grants);
|
|
100
124
|
srv.before('*', async function x402CapGate(req) {
|
|
101
|
-
|
|
102
|
-
|
|
125
|
+
let options;
|
|
126
|
+
try {
|
|
127
|
+
options = await (0, pricing_1.resolvePrice)(opts, capContext(req));
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
log.error('x402 CAP gate pricing resolver threw', err);
|
|
131
|
+
req
|
|
132
|
+
.reject(500, 'x402 pricing error');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (options == null)
|
|
103
136
|
return; // unmapped → pass through
|
|
104
|
-
|
|
105
|
-
|
|
137
|
+
// ─── Grant short-circuit ────────────────────────────────────────────
|
|
138
|
+
// If the buyer presents a valid X-PAYMENT-GRANT for THIS route, skip
|
|
139
|
+
// the whole 402 + verify+settle pipeline. We run the grant check
|
|
140
|
+
// AFTER pricing resolution so passes-through paths don't hit the DB,
|
|
141
|
+
// but BEFORE building the requirements body so we save the wasted
|
|
142
|
+
// work when a grant is valid.
|
|
143
|
+
if (grantsEntity) {
|
|
144
|
+
const grantToken = getHeader(req, 'x-payment-grant');
|
|
145
|
+
if (grantToken) {
|
|
146
|
+
const route = getResourceUrl(req, opts);
|
|
147
|
+
const result = await (0, grants_1.lookupGrant)(grantsEntity, grantToken, route);
|
|
148
|
+
if (result.kind === 'valid')
|
|
149
|
+
return; // bypass gate entirely
|
|
150
|
+
// expired / not-found → fall through to the payment path
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const requirementsBody = (0, requirements_1.buildPaymentRequirementsMulti)({
|
|
154
|
+
options,
|
|
106
155
|
asset: opts.asset,
|
|
107
156
|
payTo: opts.payTo,
|
|
108
157
|
network: opts.network,
|
|
@@ -126,11 +175,21 @@ function gateService(srv, opts) {
|
|
|
126
175
|
}
|
|
127
176
|
if (opts.allowNoTtl)
|
|
128
177
|
processArgs.allowNoTtl = true;
|
|
129
|
-
|
|
130
|
-
|
|
178
|
+
// Chain receipts INSERT before the user's onAccepted so consumers
|
|
179
|
+
// can read back the persisted row inside their hook if they want to.
|
|
180
|
+
// Receipts errors are swallowed inside `persistReceipt`, so the
|
|
181
|
+
// user's onAccepted still runs.
|
|
182
|
+
if (receiptsEntity || opts.onAccepted) {
|
|
183
|
+
processArgs.onAccepted = async (claim) => {
|
|
184
|
+
if (receiptsEntity) {
|
|
185
|
+
await (0, receipts_1.persistReceipt)(receiptsEntity, claim, getResourceUrl(req, opts));
|
|
186
|
+
}
|
|
187
|
+
if (opts.onAccepted)
|
|
188
|
+
await opts.onAccepted(claim, req);
|
|
189
|
+
};
|
|
131
190
|
}
|
|
132
191
|
// ─── Run the pipeline. Only the orchestrator's internal errors are
|
|
133
|
-
// trapped here
|
|
192
|
+
// trapped here, `req.reject` MUST be called outside this catch
|
|
134
193
|
// because it throws synchronously, and re-catching that throw
|
|
135
194
|
// would translate the 402 into a 500. ─────────────────────────
|
|
136
195
|
let result;
|
|
@@ -145,12 +204,22 @@ function gateService(srv, opts) {
|
|
|
145
204
|
return;
|
|
146
205
|
}
|
|
147
206
|
// ─── Apply the result. From here on, `req.reject` is called once
|
|
148
|
-
// and we let its synchronous throw bubble
|
|
207
|
+
// and we let its synchronous throw bubble, CAP's dispatcher
|
|
149
208
|
// wraps it into the OData error response.
|
|
150
209
|
if (result.kind === 'accepted') {
|
|
151
210
|
req.payment = result.payment;
|
|
152
211
|
const httpRes = req.http?.res;
|
|
153
212
|
httpRes?.setHeader('X-PAYMENT-RESPONSE', result.paymentResponseB64);
|
|
213
|
+
// Issue a grant token, the buyer's next request can short-circuit
|
|
214
|
+
// the 402 + settle path until expiry. Best-effort: a failed
|
|
215
|
+
// issue just means no header gets set; the response is unaffected.
|
|
216
|
+
if (grantsEntity) {
|
|
217
|
+
const grant = await (0, grants_1.issueGrant)(grantsEntity, result.payment, getResourceUrl(req, opts), grantTtlSeconds);
|
|
218
|
+
if (grant) {
|
|
219
|
+
httpRes?.setHeader('X-PAYMENT-GRANT', grant.token);
|
|
220
|
+
httpRes?.setHeader('X-PAYMENT-GRANT-EXPIRES', grant.expiresAt);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
154
223
|
return;
|
|
155
224
|
}
|
|
156
225
|
// rejected | pending → 402 with the requirements body
|
|
@@ -164,8 +233,7 @@ function gateService(srv, opts) {
|
|
|
164
233
|
if (result.txHash)
|
|
165
234
|
body.transaction = result.txHash;
|
|
166
235
|
}
|
|
167
|
-
req
|
|
168
|
-
.reject(402, JSON.stringify(body));
|
|
236
|
+
send402(req, body);
|
|
169
237
|
});
|
|
170
238
|
return srv;
|
|
171
239
|
}
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* without paying (e.g. OData `$metadata`, `$batch` previews).
|
|
7
7
|
*
|
|
8
8
|
* Two pricing modes:
|
|
9
|
-
* 1. `priceUnits`
|
|
10
|
-
* 2. `routePricing
|
|
9
|
+
* 1. `priceUnits` , single price for everything under this mount.
|
|
10
|
+
* 2. `routePricing`, { 'EntityOrActionName': 'priceUnits' }, keyed
|
|
11
11
|
* by the last URL segment (with OData function
|
|
12
12
|
* args stripped). Unmapped paths pass through.
|
|
13
13
|
*
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { Request, RequestHandler } from 'express';
|
|
20
20
|
import { type Facilitator } from '../facilitator/adapter';
|
|
21
|
-
import type { AssetTransferMethod, PaymentClaim, Network } from '../core/types';
|
|
21
|
+
import type { AssetTransferMethod, PaymentClaim, Network, PriceSpec, PriceResolver } from '../core/types';
|
|
22
22
|
declare module 'express-serve-static-core' {
|
|
23
23
|
interface Request {
|
|
24
24
|
payment?: PaymentClaim;
|
|
@@ -32,13 +32,18 @@ export interface X402MiddlewareOptions {
|
|
|
32
32
|
/** v2 asset string: 'lovelace' or '<policy>.<nameHex>'. */
|
|
33
33
|
asset: string;
|
|
34
34
|
/**
|
|
35
|
-
* Single price for everything under this mount.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Single price for everything under this mount. May be a scalar, a
|
|
36
|
+
* `RouteOption`, or a `RouteOption[]` (multi-accept).
|
|
37
|
+
* Coexists with `routePricing`: routePricing wins where defined,
|
|
38
|
+
* falls back to priceUnits otherwise.
|
|
38
39
|
*/
|
|
39
|
-
priceUnits?:
|
|
40
|
-
/**
|
|
41
|
-
|
|
40
|
+
priceUnits?: PriceSpec;
|
|
41
|
+
/**
|
|
42
|
+
* Per-route prices keyed by the URL's last segment, OR a dynamic
|
|
43
|
+
* `PriceResolver` function. Resolver returning `null` passes through;
|
|
44
|
+
* otherwise returns scalar / `RouteOption` / `RouteOption[]`.
|
|
45
|
+
*/
|
|
46
|
+
routePricing?: Record<string, PriceSpec> | PriceResolver;
|
|
42
47
|
/** Regex of paths that bypass payment (default: $metadata, $batch, root, /index). */
|
|
43
48
|
skipPaths?: RegExp;
|
|
44
49
|
/** Shown in `accepts[0].resource.description`. */
|
|
@@ -63,7 +68,7 @@ export interface X402MiddlewareOptions {
|
|
|
63
68
|
onAccepted?: (claim: PaymentClaim, req: Request) => void | Promise<void>;
|
|
64
69
|
/**
|
|
65
70
|
* Facilitator implementation handling verify+settle. Default
|
|
66
|
-
* `localFacilitator()
|
|
71
|
+
* `localFacilitator()`, runs the pipeline in-process via
|
|
67
72
|
* `@odatano/core`. Use `httpFacilitator({ url, apiKey })` to delegate
|
|
68
73
|
* to a hosted service.
|
|
69
74
|
*/
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* without paying (e.g. OData `$metadata`, `$batch` previews).
|
|
8
8
|
*
|
|
9
9
|
* Two pricing modes:
|
|
10
|
-
* 1. `priceUnits`
|
|
11
|
-
* 2. `routePricing
|
|
10
|
+
* 1. `priceUnits` , single price for everything under this mount.
|
|
11
|
+
* 2. `routePricing`, { 'EntityOrActionName': 'priceUnits' }, keyed
|
|
12
12
|
* by the last URL segment (with OData function
|
|
13
13
|
* args stripped). Unmapped paths pass through.
|
|
14
14
|
*
|
|
@@ -26,20 +26,19 @@ const cds_1 = __importDefault(require("@sap/cds"));
|
|
|
26
26
|
const requirements_1 = require("../core/requirements");
|
|
27
27
|
const adapter_1 = require("../facilitator/adapter");
|
|
28
28
|
const errors_1 = require("../core/errors");
|
|
29
|
+
const pricing_1 = require("./pricing");
|
|
29
30
|
const log = cds_1.default.log('x402');
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
return opts.priceUnits != null ? String(opts.priceUnits) : null;
|
|
31
|
+
function expressContext(req) {
|
|
32
|
+
// Last URL segment with OData function-args stripped:
|
|
33
|
+
// /odata/v4/price/getBestPrice(pair='ADA-USD') → getBestPrice
|
|
34
|
+
const segment = (req.path.split('/').pop() ?? '').split('(')[0] ?? '';
|
|
35
|
+
return {
|
|
36
|
+
event: segment,
|
|
37
|
+
path: req.path,
|
|
38
|
+
method: req.method,
|
|
39
|
+
headers: req.headers,
|
|
40
|
+
query: req.query,
|
|
41
|
+
};
|
|
43
42
|
}
|
|
44
43
|
/** Build the Express middleware. */
|
|
45
44
|
function x402Middleware(opts) {
|
|
@@ -58,11 +57,11 @@ function x402Middleware(opts) {
|
|
|
58
57
|
try {
|
|
59
58
|
if (skipPaths.test(req.path))
|
|
60
59
|
return next();
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
60
|
+
const options = await (0, pricing_1.resolvePrice)(opts, expressContext(req));
|
|
61
|
+
if (options == null)
|
|
63
62
|
return next(); // unmapped path = pass through
|
|
64
|
-
const requirementsBody = (0, requirements_1.
|
|
65
|
-
|
|
63
|
+
const requirementsBody = (0, requirements_1.buildPaymentRequirementsMulti)({
|
|
64
|
+
options,
|
|
66
65
|
asset: opts.asset,
|
|
67
66
|
payTo: opts.payTo,
|
|
68
67
|
network: opts.network,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CAP-backed time-limited grants.
|
|
3
|
+
*
|
|
4
|
+
* Used by `gateService` when its `grants` option is set. The accepted-
|
|
5
|
+
* payment hook issues a grant token + expiry, returns the token via
|
|
6
|
+
* the `X-PAYMENT-GRANT` response header. The before-* hook checks the
|
|
7
|
+
* inbound `X-PAYMENT-GRANT` header against the table, route-scoped and
|
|
8
|
+
* time-windowed; a valid grant bypasses the 402 + verify+settle pipeline.
|
|
9
|
+
*
|
|
10
|
+
* Trade-offs vs. JWT:
|
|
11
|
+
* - Opaque tokens require one DB lookup per request, JWT would be
|
|
12
|
+
* self-validating. We prefer opaque so revocation is trivial
|
|
13
|
+
* (`DELETE FROM X402Grants WHERE token = …`) and clock skew is a
|
|
14
|
+
* non-issue (the DB owns `now`).
|
|
15
|
+
* - Lookup adds latency (~ms on SQLite, < ms on HANA). Negligible
|
|
16
|
+
* compared to the seconds-long settle path it replaces.
|
|
17
|
+
*
|
|
18
|
+
* Failure modes:
|
|
19
|
+
* - Issue failure: logged, gate still serves the response (the buyer
|
|
20
|
+
* paid, we don't punish them for our DB hiccup). They simply won't
|
|
21
|
+
* have a grant for next time.
|
|
22
|
+
* - Lookup failure: treated as `not-found`; the gate falls through to
|
|
23
|
+
* the normal payment path. Buyers retry their grant; on real DB
|
|
24
|
+
* failures, payments still work, just without the subscription
|
|
25
|
+
* short-circuit.
|
|
26
|
+
*/
|
|
27
|
+
import type { PaymentClaim } from '../core/types';
|
|
28
|
+
export declare const DEFAULT_GRANTS_ENTITY = "odatano.x402.X402Grants";
|
|
29
|
+
export declare const DEFAULT_GRANT_TTL_SECONDS = 3600;
|
|
30
|
+
/** Resolve entity name from the `grants` option. */
|
|
31
|
+
export declare function resolveGrantsEntity(grants: boolean | {
|
|
32
|
+
ttlSeconds?: number;
|
|
33
|
+
entity?: string;
|
|
34
|
+
} | undefined): string | null;
|
|
35
|
+
/** Resolve TTL (seconds) from the `grants` option. */
|
|
36
|
+
export declare function resolveGrantTtl(grants: boolean | {
|
|
37
|
+
ttlSeconds?: number;
|
|
38
|
+
entity?: string;
|
|
39
|
+
} | undefined): number;
|
|
40
|
+
export interface IssueGrantResult {
|
|
41
|
+
token: string;
|
|
42
|
+
expiresAt: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Issue a new grant for an accepted payment. Returns `null` if the
|
|
46
|
+
* INSERT failed; the caller continues without setting the
|
|
47
|
+
* `X-PAYMENT-GRANT` response header (graceful degradation).
|
|
48
|
+
*/
|
|
49
|
+
export declare function issueGrant(entityName: string, claim: PaymentClaim, route: string, ttlSeconds: number): Promise<IssueGrantResult | null>;
|
|
50
|
+
export type GrantLookupResult = {
|
|
51
|
+
kind: 'valid';
|
|
52
|
+
} | {
|
|
53
|
+
kind: 'expired';
|
|
54
|
+
} | {
|
|
55
|
+
kind: 'not-found';
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Look up a grant by token, scoped to a specific route. The route check
|
|
59
|
+
* is strict equality, a grant for `/Quotes` will not unlock `/getBestPrice`.
|
|
60
|
+
* Any DB error is logged and surfaces as `not-found`; the gate then runs
|
|
61
|
+
* its normal payment path, ensuring DB problems never deny paying buyers.
|
|
62
|
+
*/
|
|
63
|
+
export declare function lookupGrant(entityName: string, token: string, route: string): Promise<GrantLookupResult>;
|
|
64
|
+
//# sourceMappingURL=grants.d.ts.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CAP-backed time-limited grants.
|
|
4
|
+
*
|
|
5
|
+
* Used by `gateService` when its `grants` option is set. The accepted-
|
|
6
|
+
* payment hook issues a grant token + expiry, returns the token via
|
|
7
|
+
* the `X-PAYMENT-GRANT` response header. The before-* hook checks the
|
|
8
|
+
* inbound `X-PAYMENT-GRANT` header against the table, route-scoped and
|
|
9
|
+
* time-windowed; a valid grant bypasses the 402 + verify+settle pipeline.
|
|
10
|
+
*
|
|
11
|
+
* Trade-offs vs. JWT:
|
|
12
|
+
* - Opaque tokens require one DB lookup per request, JWT would be
|
|
13
|
+
* self-validating. We prefer opaque so revocation is trivial
|
|
14
|
+
* (`DELETE FROM X402Grants WHERE token = …`) and clock skew is a
|
|
15
|
+
* non-issue (the DB owns `now`).
|
|
16
|
+
* - Lookup adds latency (~ms on SQLite, < ms on HANA). Negligible
|
|
17
|
+
* compared to the seconds-long settle path it replaces.
|
|
18
|
+
*
|
|
19
|
+
* Failure modes:
|
|
20
|
+
* - Issue failure: logged, gate still serves the response (the buyer
|
|
21
|
+
* paid, we don't punish them for our DB hiccup). They simply won't
|
|
22
|
+
* have a grant for next time.
|
|
23
|
+
* - Lookup failure: treated as `not-found`; the gate falls through to
|
|
24
|
+
* the normal payment path. Buyers retry their grant; on real DB
|
|
25
|
+
* failures, payments still work, just without the subscription
|
|
26
|
+
* short-circuit.
|
|
27
|
+
*/
|
|
28
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
29
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
30
|
+
};
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.DEFAULT_GRANT_TTL_SECONDS = exports.DEFAULT_GRANTS_ENTITY = void 0;
|
|
33
|
+
exports.resolveGrantsEntity = resolveGrantsEntity;
|
|
34
|
+
exports.resolveGrantTtl = resolveGrantTtl;
|
|
35
|
+
exports.issueGrant = issueGrant;
|
|
36
|
+
exports.lookupGrant = lookupGrant;
|
|
37
|
+
const cds_1 = __importDefault(require("@sap/cds"));
|
|
38
|
+
const crypto_1 = require("crypto");
|
|
39
|
+
const log = cds_1.default.log('x402');
|
|
40
|
+
exports.DEFAULT_GRANTS_ENTITY = 'odatano.x402.X402Grants';
|
|
41
|
+
exports.DEFAULT_GRANT_TTL_SECONDS = 3600;
|
|
42
|
+
/** Resolve entity name from the `grants` option. */
|
|
43
|
+
function resolveGrantsEntity(grants) {
|
|
44
|
+
if (!grants)
|
|
45
|
+
return null;
|
|
46
|
+
if (grants === true)
|
|
47
|
+
return exports.DEFAULT_GRANTS_ENTITY;
|
|
48
|
+
return grants.entity ?? exports.DEFAULT_GRANTS_ENTITY;
|
|
49
|
+
}
|
|
50
|
+
/** Resolve TTL (seconds) from the `grants` option. */
|
|
51
|
+
function resolveGrantTtl(grants) {
|
|
52
|
+
if (!grants || grants === true)
|
|
53
|
+
return exports.DEFAULT_GRANT_TTL_SECONDS;
|
|
54
|
+
return grants.ttlSeconds ?? exports.DEFAULT_GRANT_TTL_SECONDS;
|
|
55
|
+
}
|
|
56
|
+
function generateToken() {
|
|
57
|
+
// 32 bytes, base64url-encoded (44 chars without padding).
|
|
58
|
+
return (0, crypto_1.randomBytes)(32).toString('base64url');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Issue a new grant for an accepted payment. Returns `null` if the
|
|
62
|
+
* INSERT failed; the caller continues without setting the
|
|
63
|
+
* `X-PAYMENT-GRANT` response header (graceful degradation).
|
|
64
|
+
*/
|
|
65
|
+
async function issueGrant(entityName, claim, route, ttlSeconds) {
|
|
66
|
+
const token = generateToken();
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
const expiresAt = new Date(now + ttlSeconds * 1000).toISOString();
|
|
69
|
+
try {
|
|
70
|
+
await INSERT.into(entityName).entries({
|
|
71
|
+
ID: cds_1.default.utils.uuid(),
|
|
72
|
+
token,
|
|
73
|
+
route,
|
|
74
|
+
payerAddr: claim.payerAddr ?? null,
|
|
75
|
+
txHash: claim.txHash,
|
|
76
|
+
asset: claim.asset,
|
|
77
|
+
network: claim.network,
|
|
78
|
+
issuedAt: new Date(now).toISOString(),
|
|
79
|
+
expiresAt,
|
|
80
|
+
});
|
|
81
|
+
return { token, expiresAt };
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
log.warn(`x402 grant INSERT into ${entityName} failed (non-fatal):`, err?.message ?? err);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Look up a grant by token, scoped to a specific route. The route check
|
|
90
|
+
* is strict equality, a grant for `/Quotes` will not unlock `/getBestPrice`.
|
|
91
|
+
* Any DB error is logged and surfaces as `not-found`; the gate then runs
|
|
92
|
+
* its normal payment path, ensuring DB problems never deny paying buyers.
|
|
93
|
+
*/
|
|
94
|
+
async function lookupGrant(entityName, token, route) {
|
|
95
|
+
if (!token)
|
|
96
|
+
return { kind: 'not-found' };
|
|
97
|
+
try {
|
|
98
|
+
const row = await SELECT.one
|
|
99
|
+
.from(entityName)
|
|
100
|
+
.where({ token, route });
|
|
101
|
+
if (!row)
|
|
102
|
+
return { kind: 'not-found' };
|
|
103
|
+
const expiresMs = row.expiresAt ? Date.parse(row.expiresAt) : 0;
|
|
104
|
+
if (!Number.isFinite(expiresMs) || expiresMs <= Date.now()) {
|
|
105
|
+
return { kind: 'expired' };
|
|
106
|
+
}
|
|
107
|
+
return { kind: 'valid' };
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
log.warn(`x402 grant SELECT from ${entityName} failed (non-fatal):`, err?.message ?? err);
|
|
111
|
+
return { kind: 'not-found' };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared pricing resolution for the CAP and Express middlewares.
|
|
3
|
+
*
|
|
4
|
+
* The two integrations differ in how they extract the routing key
|
|
5
|
+
* (`req.event` vs. last URL segment) and how they read headers, but
|
|
6
|
+
* once they've built a `PricingContext` the resolution logic is identical:
|
|
7
|
+
*
|
|
8
|
+
* 1. If `routePricing` is a function, invoke it with the context.
|
|
9
|
+
* Sync or async; null return = pass through.
|
|
10
|
+
* 2. If `routePricing` is a static map, look up `target` then `event`.
|
|
11
|
+
* Miss falls back to `priceUnits`. Both miss = pass through.
|
|
12
|
+
* 3. The matched value can be a scalar (single price in default asset),
|
|
13
|
+
* a `RouteOption` (single price with overrides), or `RouteOption[]`
|
|
14
|
+
* (multi-accept). Scalars and single RouteOptions are widened to
|
|
15
|
+
* length-1 arrays so downstream code only deals with one shape.
|
|
16
|
+
*
|
|
17
|
+
* Returns `RouteOption[]` (≥ 1 entry) or `null` for pass-through.
|
|
18
|
+
*
|
|
19
|
+
* The middlewares pass the returned array straight to
|
|
20
|
+
* `buildPaymentRequirementsMulti()`; length-1 still produces a single-
|
|
21
|
+
* entry 402 body, indistinguishable from the pre-v0.3 output.
|
|
22
|
+
*/
|
|
23
|
+
import type { PriceSpec, PriceResolver, PricingContext, RouteOption } from '../core/types';
|
|
24
|
+
export interface PricingOptions {
|
|
25
|
+
/** Single price, applies when `routePricing` misses. */
|
|
26
|
+
priceUnits?: PriceSpec;
|
|
27
|
+
/**
|
|
28
|
+
* Per-event prices keyed by route name, OR a dynamic resolver function.
|
|
29
|
+
* Function returns null to skip the gate entirely.
|
|
30
|
+
*/
|
|
31
|
+
routePricing?: Record<string, PriceSpec> | PriceResolver;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve pricing for a request. Returns the option array to gate the
|
|
35
|
+
* request with, or `null` if the request should pass through ungated.
|
|
36
|
+
*
|
|
37
|
+
* Async to support dynamic resolvers that hit a DB / cache. Awaiting a
|
|
38
|
+
* sync return is a JS-engine no-op, so the sync path stays fast.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolvePrice(opts: PricingOptions, ctx: PricingContext): Promise<RouteOption[] | null>;
|
|
41
|
+
//# sourceMappingURL=pricing.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared pricing resolution for the CAP and Express middlewares.
|
|
4
|
+
*
|
|
5
|
+
* The two integrations differ in how they extract the routing key
|
|
6
|
+
* (`req.event` vs. last URL segment) and how they read headers, but
|
|
7
|
+
* once they've built a `PricingContext` the resolution logic is identical:
|
|
8
|
+
*
|
|
9
|
+
* 1. If `routePricing` is a function, invoke it with the context.
|
|
10
|
+
* Sync or async; null return = pass through.
|
|
11
|
+
* 2. If `routePricing` is a static map, look up `target` then `event`.
|
|
12
|
+
* Miss falls back to `priceUnits`. Both miss = pass through.
|
|
13
|
+
* 3. The matched value can be a scalar (single price in default asset),
|
|
14
|
+
* a `RouteOption` (single price with overrides), or `RouteOption[]`
|
|
15
|
+
* (multi-accept). Scalars and single RouteOptions are widened to
|
|
16
|
+
* length-1 arrays so downstream code only deals with one shape.
|
|
17
|
+
*
|
|
18
|
+
* Returns `RouteOption[]` (≥ 1 entry) or `null` for pass-through.
|
|
19
|
+
*
|
|
20
|
+
* The middlewares pass the returned array straight to
|
|
21
|
+
* `buildPaymentRequirementsMulti()`; length-1 still produces a single-
|
|
22
|
+
* entry 402 body, indistinguishable from the pre-v0.3 output.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.resolvePrice = resolvePrice;
|
|
26
|
+
function widenToOptions(spec) {
|
|
27
|
+
if (Array.isArray(spec)) {
|
|
28
|
+
if (spec.length === 0) {
|
|
29
|
+
throw new Error('resolvePrice: route option array must be non-empty');
|
|
30
|
+
}
|
|
31
|
+
return spec;
|
|
32
|
+
}
|
|
33
|
+
if (typeof spec === 'object') {
|
|
34
|
+
// RouteOption shape
|
|
35
|
+
return [spec];
|
|
36
|
+
}
|
|
37
|
+
// scalar (string | number | bigint) , default-asset shorthand
|
|
38
|
+
return [{ amount: spec }];
|
|
39
|
+
}
|
|
40
|
+
function lookupStatic(map, ctx) {
|
|
41
|
+
// `target` is more specific (CAP entity name) than `event` (verb), so
|
|
42
|
+
// try the entity segment first. Express only fills `event`.
|
|
43
|
+
if (ctx.target) {
|
|
44
|
+
const entitySegment = ctx.target.split('.').pop() ?? '';
|
|
45
|
+
if (entitySegment && map[entitySegment] != null)
|
|
46
|
+
return map[entitySegment];
|
|
47
|
+
}
|
|
48
|
+
if (map[ctx.event] != null)
|
|
49
|
+
return map[ctx.event];
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve pricing for a request. Returns the option array to gate the
|
|
54
|
+
* request with, or `null` if the request should pass through ungated.
|
|
55
|
+
*
|
|
56
|
+
* Async to support dynamic resolvers that hit a DB / cache. Awaiting a
|
|
57
|
+
* sync return is a JS-engine no-op, so the sync path stays fast.
|
|
58
|
+
*/
|
|
59
|
+
async function resolvePrice(opts, ctx) {
|
|
60
|
+
// ─── 1. Function-form routePricing ─────────────────────────────────
|
|
61
|
+
if (typeof opts.routePricing === 'function') {
|
|
62
|
+
const spec = await opts.routePricing(ctx);
|
|
63
|
+
if (spec == null)
|
|
64
|
+
return null;
|
|
65
|
+
return widenToOptions(spec);
|
|
66
|
+
}
|
|
67
|
+
// ─── 2. Static-map routePricing ────────────────────────────────────
|
|
68
|
+
if (opts.routePricing) {
|
|
69
|
+
const hit = lookupStatic(opts.routePricing, ctx);
|
|
70
|
+
if (hit != null)
|
|
71
|
+
return widenToOptions(hit);
|
|
72
|
+
// Map present but no key matched. Fall through to priceUnits.
|
|
73
|
+
}
|
|
74
|
+
// ─── 3. Fallback to flat priceUnits ────────────────────────────────
|
|
75
|
+
if (opts.priceUnits != null)
|
|
76
|
+
return widenToOptions(opts.priceUnits);
|
|
77
|
+
return null;
|
|
78
|
+
}
|