@odatano/x402 0.1.0 → 0.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/.github/workflows/test.yaml +49 -0
- package/CHANGELOG.md +57 -0
- package/README.md +29 -281
- package/cds-plugin.js +2 -0
- package/db/x402-grants.cds +49 -0
- package/db/x402-receipts.cds +44 -0
- package/package.json +11 -4
- package/srv/bridge.d.ts +9 -12
- package/srv/bridge.js +10 -13
- package/srv/cds-augment.d.ts +17 -0
- package/srv/client/axios.d.ts +38 -0
- package/srv/client/axios.js +107 -0
- package/srv/client/envelope.d.ts +33 -0
- package/srv/client/envelope.js +52 -0
- package/srv/client/errors.d.ts +107 -0
- package/srv/client/errors.js +144 -0
- package/srv/client/fetch.d.ts +30 -0
- package/srv/client/fetch.js +141 -0
- package/srv/client/pay-handlers.d.ts +41 -0
- package/srv/client/pay-handlers.js +47 -0
- package/srv/client/types.d.ts +56 -0
- package/srv/client/types.js +10 -0
- 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 +69 -0
- package/srv/facilitator/adapter.js +52 -0
- package/srv/facilitator/http.d.ts +43 -0
- package/srv/facilitator/http.js +99 -0
- 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 +11 -2
- package/srv/index.js +23 -3
- package/srv/middleware/cap.d.ts +53 -8
- package/srv/middleware/cap.js +87 -43
- package/srv/middleware/express.d.ts +22 -9
- package/srv/middleware/express.js +21 -21
- 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:
|
|
@@ -32,44 +32,39 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
32
32
|
exports.gateService = gateService;
|
|
33
33
|
const cds_1 = __importDefault(require("@sap/cds"));
|
|
34
34
|
const requirements_1 = require("../core/requirements");
|
|
35
|
-
const
|
|
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)
|
|
@@ -83,7 +78,7 @@ function getResourceUrl(req, opts) {
|
|
|
83
78
|
*
|
|
84
79
|
* The gate registers as `srv.before('*', ...)` which fires for every
|
|
85
80
|
* event on the service. We filter inside the handler based on
|
|
86
|
-
* `routePricing
|
|
81
|
+
* `routePricing`, registering per-entity would lose actions, and
|
|
87
82
|
* per-event arrays don't support the `'*'` fallback we want.
|
|
88
83
|
*/
|
|
89
84
|
function gateService(srv, opts) {
|
|
@@ -96,12 +91,41 @@ function gateService(srv, opts) {
|
|
|
96
91
|
if (opts.priceUnits == null && !opts.routePricing) {
|
|
97
92
|
throw new Error('gateService: priceUnits or routePricing is required');
|
|
98
93
|
}
|
|
94
|
+
const facilitator = opts.facilitator ?? (0, adapter_1.localFacilitator)();
|
|
95
|
+
const receiptsEntity = (0, receipts_1.resolveReceiptsEntity)(opts.receipts);
|
|
96
|
+
const grantsEntity = (0, grants_1.resolveGrantsEntity)(opts.grants);
|
|
97
|
+
const grantTtlSeconds = (0, grants_1.resolveGrantTtl)(opts.grants);
|
|
99
98
|
srv.before('*', async function x402CapGate(req) {
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
let options;
|
|
100
|
+
try {
|
|
101
|
+
options = await (0, pricing_1.resolvePrice)(opts, capContext(req));
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
log.error('x402 CAP gate pricing resolver threw', err);
|
|
105
|
+
req
|
|
106
|
+
.reject(500, 'x402 pricing error');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (options == null)
|
|
102
110
|
return; // unmapped → pass through
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
// ─── Grant short-circuit ────────────────────────────────────────────
|
|
112
|
+
// If the buyer presents a valid X-PAYMENT-GRANT for THIS route, skip
|
|
113
|
+
// the whole 402 + verify+settle pipeline. We run the grant check
|
|
114
|
+
// AFTER pricing resolution so passes-through paths don't hit the DB,
|
|
115
|
+
// but BEFORE building the requirements body so we save the wasted
|
|
116
|
+
// work when a grant is valid.
|
|
117
|
+
if (grantsEntity) {
|
|
118
|
+
const grantToken = getHeader(req, 'x-payment-grant');
|
|
119
|
+
if (grantToken) {
|
|
120
|
+
const route = getResourceUrl(req, opts);
|
|
121
|
+
const result = await (0, grants_1.lookupGrant)(grantsEntity, grantToken, route);
|
|
122
|
+
if (result.kind === 'valid')
|
|
123
|
+
return; // bypass gate entirely
|
|
124
|
+
// expired / not-found → fall through to the payment path
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const requirementsBody = (0, requirements_1.buildPaymentRequirementsMulti)({
|
|
128
|
+
options,
|
|
105
129
|
asset: opts.asset,
|
|
106
130
|
payTo: opts.payTo,
|
|
107
131
|
network: opts.network,
|
|
@@ -125,16 +149,26 @@ function gateService(srv, opts) {
|
|
|
125
149
|
}
|
|
126
150
|
if (opts.allowNoTtl)
|
|
127
151
|
processArgs.allowNoTtl = true;
|
|
128
|
-
|
|
129
|
-
|
|
152
|
+
// Chain receipts INSERT before the user's onAccepted so consumers
|
|
153
|
+
// can read back the persisted row inside their hook if they want to.
|
|
154
|
+
// Receipts errors are swallowed inside `persistReceipt`, so the
|
|
155
|
+
// user's onAccepted still runs.
|
|
156
|
+
if (receiptsEntity || opts.onAccepted) {
|
|
157
|
+
processArgs.onAccepted = async (claim) => {
|
|
158
|
+
if (receiptsEntity) {
|
|
159
|
+
await (0, receipts_1.persistReceipt)(receiptsEntity, claim, getResourceUrl(req, opts));
|
|
160
|
+
}
|
|
161
|
+
if (opts.onAccepted)
|
|
162
|
+
await opts.onAccepted(claim, req);
|
|
163
|
+
};
|
|
130
164
|
}
|
|
131
165
|
// ─── Run the pipeline. Only the orchestrator's internal errors are
|
|
132
|
-
// trapped here
|
|
166
|
+
// trapped here, `req.reject` MUST be called outside this catch
|
|
133
167
|
// because it throws synchronously, and re-catching that throw
|
|
134
168
|
// would translate the 402 into a 500. ─────────────────────────
|
|
135
169
|
let result;
|
|
136
170
|
try {
|
|
137
|
-
result = await
|
|
171
|
+
result = await facilitator.verifyAndSettle(processArgs);
|
|
138
172
|
}
|
|
139
173
|
catch (err) {
|
|
140
174
|
log.error('x402 CAP gate internal error', err);
|
|
@@ -144,12 +178,22 @@ function gateService(srv, opts) {
|
|
|
144
178
|
return;
|
|
145
179
|
}
|
|
146
180
|
// ─── Apply the result. From here on, `req.reject` is called once
|
|
147
|
-
// and we let its synchronous throw bubble
|
|
181
|
+
// and we let its synchronous throw bubble, CAP's dispatcher
|
|
148
182
|
// wraps it into the OData error response.
|
|
149
183
|
if (result.kind === 'accepted') {
|
|
150
184
|
req.payment = result.payment;
|
|
151
185
|
const httpRes = req.http?.res;
|
|
152
186
|
httpRes?.setHeader('X-PAYMENT-RESPONSE', result.paymentResponseB64);
|
|
187
|
+
// Issue a grant token, the buyer's next request can short-circuit
|
|
188
|
+
// the 402 + settle path until expiry. Best-effort: a failed
|
|
189
|
+
// issue just means no header gets set; the response is unaffected.
|
|
190
|
+
if (grantsEntity) {
|
|
191
|
+
const grant = await (0, grants_1.issueGrant)(grantsEntity, result.payment, getResourceUrl(req, opts), grantTtlSeconds);
|
|
192
|
+
if (grant) {
|
|
193
|
+
httpRes?.setHeader('X-PAYMENT-GRANT', grant.token);
|
|
194
|
+
httpRes?.setHeader('X-PAYMENT-GRANT-EXPIRES', grant.expiresAt);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
153
197
|
return;
|
|
154
198
|
}
|
|
155
199
|
// rejected | pending → 402 with the requirements body
|
|
@@ -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
|
*
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* can extract the code without breaking wire format.
|
|
18
18
|
*/
|
|
19
19
|
import type { Request, RequestHandler } from 'express';
|
|
20
|
-
import
|
|
20
|
+
import { type Facilitator } from '../facilitator/adapter';
|
|
21
|
+
import type { AssetTransferMethod, PaymentClaim, Network, PriceSpec, PriceResolver } from '../core/types';
|
|
21
22
|
declare module 'express-serve-static-core' {
|
|
22
23
|
interface Request {
|
|
23
24
|
payment?: PaymentClaim;
|
|
@@ -31,13 +32,18 @@ export interface X402MiddlewareOptions {
|
|
|
31
32
|
/** v2 asset string: 'lovelace' or '<policy>.<nameHex>'. */
|
|
32
33
|
asset: string;
|
|
33
34
|
/**
|
|
34
|
-
* Single price for everything under this mount.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
37
39
|
*/
|
|
38
|
-
priceUnits?:
|
|
39
|
-
/**
|
|
40
|
-
|
|
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;
|
|
41
47
|
/** Regex of paths that bypass payment (default: $metadata, $batch, root, /index). */
|
|
42
48
|
skipPaths?: RegExp;
|
|
43
49
|
/** Shown in `accepts[0].resource.description`. */
|
|
@@ -60,6 +66,13 @@ export interface X402MiddlewareOptions {
|
|
|
60
66
|
* block serving the response.
|
|
61
67
|
*/
|
|
62
68
|
onAccepted?: (claim: PaymentClaim, req: Request) => void | Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Facilitator implementation handling verify+settle. Default
|
|
71
|
+
* `localFacilitator()`, runs the pipeline in-process via
|
|
72
|
+
* `@odatano/core`. Use `httpFacilitator({ url, apiKey })` to delegate
|
|
73
|
+
* to a hosted service.
|
|
74
|
+
*/
|
|
75
|
+
facilitator?: Facilitator;
|
|
63
76
|
}
|
|
64
77
|
/** Build the Express middleware. */
|
|
65
78
|
export declare function x402Middleware(opts: X402MiddlewareOptions): RequestHandler;
|
|
@@ -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
|
*
|
|
@@ -24,22 +24,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
24
24
|
exports.x402Middleware = x402Middleware;
|
|
25
25
|
const cds_1 = __importDefault(require("@sap/cds"));
|
|
26
26
|
const requirements_1 = require("../core/requirements");
|
|
27
|
-
const
|
|
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) {
|
|
@@ -53,15 +52,16 @@ function x402Middleware(opts) {
|
|
|
53
52
|
throw new Error('x402Middleware: priceUnits or routePricing is required');
|
|
54
53
|
}
|
|
55
54
|
const skipPaths = opts.skipPaths ?? /(^\/?$|\$metadata|\$batch|^\/?\?|^\/index)/i;
|
|
55
|
+
const facilitator = opts.facilitator ?? (0, adapter_1.localFacilitator)();
|
|
56
56
|
return async function x402Express(req, res, next) {
|
|
57
57
|
try {
|
|
58
58
|
if (skipPaths.test(req.path))
|
|
59
59
|
return next();
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
60
|
+
const options = await (0, pricing_1.resolvePrice)(opts, expressContext(req));
|
|
61
|
+
if (options == null)
|
|
62
62
|
return next(); // unmapped path = pass through
|
|
63
|
-
const requirementsBody = (0, requirements_1.
|
|
64
|
-
|
|
63
|
+
const requirementsBody = (0, requirements_1.buildPaymentRequirementsMulti)({
|
|
64
|
+
options,
|
|
65
65
|
asset: opts.asset,
|
|
66
66
|
payTo: opts.payTo,
|
|
67
67
|
network: opts.network,
|
|
@@ -88,7 +88,7 @@ function x402Middleware(opts) {
|
|
|
88
88
|
if (opts.onAccepted) {
|
|
89
89
|
processArgs.onAccepted = (claim) => opts.onAccepted(claim, req);
|
|
90
90
|
}
|
|
91
|
-
const result = await
|
|
91
|
+
const result = await facilitator.verifyAndSettle(processArgs);
|
|
92
92
|
if (result.kind === 'accepted') {
|
|
93
93
|
res.setHeader('X-PAYMENT-RESPONSE', result.paymentResponseB64);
|
|
94
94
|
req.payment = result.payment;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CAP-backed persistence for accepted x402 payments.
|
|
3
|
+
*
|
|
4
|
+
* Called from `gateService` when its `receipts` option is set. One INSERT
|
|
5
|
+
* per accepted payment; runs AFTER settle confirms and BEFORE the 200
|
|
6
|
+
* response is served to the buyer. Best-effort: any INSERT failure is
|
|
7
|
+
* logged and SWALLOWED, the canonical record is on chain and we never
|
|
8
|
+
* want a flaky DB to deny a paying buyer their response.
|
|
9
|
+
*
|
|
10
|
+
* Entity shape: see `db/x402-receipts.cds`. The schema is shipped in the
|
|
11
|
+
* plugin's `db/`; CAP auto-discovers it when `@odatano/x402` is in
|
|
12
|
+
* node_modules. Consumers wanting a custom shape can pass
|
|
13
|
+
* `receipts: { entity: 'my.namespace.MyTable' }`, the table needs to
|
|
14
|
+
* carry the columns we INSERT below.
|
|
15
|
+
*
|
|
16
|
+
* Idempotency: txHash carries `@assert.unique`. A duplicate INSERT
|
|
17
|
+
* (e.g. settle returning twice for the same buyer) hits a unique-key
|
|
18
|
+
* violation and we log + continue. Buyers' UX is unaffected.
|
|
19
|
+
*/
|
|
20
|
+
import type { PaymentClaim } from '../core/types';
|
|
21
|
+
/** Canonical entity name shipped by the plugin. */
|
|
22
|
+
export declare const DEFAULT_RECEIPTS_ENTITY = "odatano.x402.X402Receipts";
|
|
23
|
+
/**
|
|
24
|
+
* Insert one receipt for an accepted payment. Returns a promise that
|
|
25
|
+
* always resolves (never throws), errors are logged.
|
|
26
|
+
*
|
|
27
|
+
* The `route` argument is the resource URL the buyer paid for, the same
|
|
28
|
+
* value embedded in `accepts[0].resource.url`. We pass it explicitly
|
|
29
|
+
* rather than re-deriving it inside this module so the persisted route
|
|
30
|
+
* matches what the 402 advertised, even when the consumer set a custom
|
|
31
|
+
* `resourceUrl` builder.
|
|
32
|
+
*/
|
|
33
|
+
export declare function persistReceipt(entityName: string, claim: PaymentClaim, route: string): Promise<void>;
|
|
34
|
+
/** Resolve the entity name from the `receipts` option. */
|
|
35
|
+
export declare function resolveReceiptsEntity(receipts: boolean | {
|
|
36
|
+
entity?: string;
|
|
37
|
+
} | undefined): string | null;
|
|
38
|
+
//# sourceMappingURL=receipts.d.ts.map
|