@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.
Files changed (64) hide show
  1. package/.github/workflows/test.yaml +49 -0
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +29 -281
  4. package/cds-plugin.js +2 -0
  5. package/db/x402-grants.cds +49 -0
  6. package/db/x402-receipts.cds +44 -0
  7. package/package.json +11 -4
  8. package/srv/bridge.d.ts +9 -12
  9. package/srv/bridge.js +10 -13
  10. package/srv/cds-augment.d.ts +17 -0
  11. package/srv/client/axios.d.ts +38 -0
  12. package/srv/client/axios.js +107 -0
  13. package/srv/client/envelope.d.ts +33 -0
  14. package/srv/client/envelope.js +52 -0
  15. package/srv/client/errors.d.ts +107 -0
  16. package/srv/client/errors.js +144 -0
  17. package/srv/client/fetch.d.ts +30 -0
  18. package/srv/client/fetch.js +141 -0
  19. package/srv/client/pay-handlers.d.ts +41 -0
  20. package/srv/client/pay-handlers.js +47 -0
  21. package/srv/client/types.d.ts +56 -0
  22. package/srv/client/types.js +10 -0
  23. package/srv/core/asset.d.ts +1 -1
  24. package/srv/core/decode.d.ts +2 -2
  25. package/srv/core/decode.js +5 -5
  26. package/srv/core/errors.js +3 -3
  27. package/srv/core/network.d.ts +1 -1
  28. package/srv/core/network.js +1 -1
  29. package/srv/core/requirements.d.ts +37 -5
  30. package/srv/core/requirements.js +43 -4
  31. package/srv/core/types.d.ts +68 -6
  32. package/srv/core/types.js +3 -3
  33. package/srv/core/validate.d.ts +31 -7
  34. package/srv/core/validate.js +84 -9
  35. package/srv/facilitator/adapter.d.ts +69 -0
  36. package/srv/facilitator/adapter.js +52 -0
  37. package/srv/facilitator/http.d.ts +43 -0
  38. package/srv/facilitator/http.js +99 -0
  39. package/srv/facilitator/nonce.d.ts +4 -4
  40. package/srv/facilitator/nonce.js +4 -4
  41. package/srv/facilitator/server.d.ts +68 -0
  42. package/srv/facilitator/server.js +167 -0
  43. package/srv/facilitator/settle.d.ts +2 -2
  44. package/srv/facilitator/settle.js +4 -4
  45. package/srv/facilitator/verify.d.ts +5 -5
  46. package/srv/facilitator/verify.js +19 -5
  47. package/srv/helpers/build-unsigned-tx.d.ts +5 -5
  48. package/srv/helpers/build-unsigned-tx.js +3 -3
  49. package/srv/helpers/verify-confirmed.d.ts +1 -1
  50. package/srv/helpers/verify-confirmed.js +1 -1
  51. package/srv/index.d.ts +11 -2
  52. package/srv/index.js +23 -3
  53. package/srv/middleware/cap.d.ts +53 -8
  54. package/srv/middleware/cap.js +87 -43
  55. package/srv/middleware/express.d.ts +22 -9
  56. package/srv/middleware/express.js +21 -21
  57. package/srv/middleware/grants.d.ts +64 -0
  58. package/srv/middleware/grants.js +113 -0
  59. package/srv/middleware/pricing.d.ts +41 -0
  60. package/srv/middleware/pricing.js +78 -0
  61. package/srv/middleware/receipts.d.ts +38 -0
  62. package/srv/middleware/receipts.js +68 -0
  63. package/srv/plugin.d.ts +2 -2
  64. package/srv/plugin.js +2 -2
@@ -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)` the response is built by
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 verify_1 = require("../facilitator/verify");
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
- * Resolve the routePricing key for a CAP request.
50
+ * Build the `PricingContext` for `resolvePrice` from a CAP request.
40
51
  *
41
- * CAP fires two distinct shapes:
42
- * - CRUD on an entity: req.event === 'READ' | 'CREATE' | 'UPDATE' | 'DELETE'
43
- * req.target.name === '<Service>.<Entity>'
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
- * routePricing keys are meant to be human-readable identifiers (entity
48
- * names or action names), so we try the entity name first (more specific)
49
- * and fall back to the event verb. Either match wins; both miss falls
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 pickPriceUnits(req, opts) {
53
- if (opts.routePricing) {
54
- const event = String(req.event ?? '');
55
- const targetName = req.target?.name ?? '';
56
- const entitySegment = targetName.split('.').pop() ?? '';
57
- const price = opts.routePricing[entitySegment] ?? opts.routePricing[event];
58
- if (price != null)
59
- return String(price);
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` registering per-entity would lose actions, and
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
- const priceUnits = pickPriceUnits(req, opts);
101
- if (priceUnits == null)
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
- const requirementsBody = (0, requirements_1.buildPaymentRequirements)({
104
- amount: priceUnits,
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
- if (opts.onAccepted) {
129
- processArgs.onAccepted = (claim) => opts.onAccepted(claim, req);
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 `req.reject` MUST be called outside this catch
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 (0, verify_1.process)(processArgs);
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 CAP's dispatcher
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` single price for everything under this mount.
10
- * 2. `routePricing` { 'EntityOrActionName': 'priceUnits' }, keyed
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 type { AssetTransferMethod, PaymentClaim, Network } from '../core/types';
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. Mutually exclusive
35
- * with `routePricing` (but coexistable: routePricing wins where
36
- * defined, falls back to priceUnits otherwise).
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?: string | number | bigint;
39
- /** Per-route prices keyed by the URL's last segment. */
40
- routePricing?: Record<string, string | number | bigint>;
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` single price for everything under this mount.
11
- * 2. `routePricing` { 'EntityOrActionName': 'priceUnits' }, keyed
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 verify_1 = require("../facilitator/verify");
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 pickPriceUnits(req, opts) {
31
- if (opts.routePricing) {
32
- // Last URL segment, OData function-args stripped:
33
- // /odata/v4/price/getBestPrice(pair='ADA-USD') getBestPrice
34
- const segment = (req.path.split('/').pop() ?? '').split('(')[0];
35
- const price = opts.routePricing[segment];
36
- if (price != null)
37
- return String(price);
38
- if (opts.priceUnits != null)
39
- return String(opts.priceUnits);
40
- return null; // unmapped under routePricing = pass through
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 priceUnits = pickPriceUnits(req, opts);
61
- if (priceUnits == null)
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.buildPaymentRequirements)({
64
- amount: priceUnits,
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 (0, verify_1.process)(processArgs);
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