@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +5 -0
  3. package/cds-plugin.js +2 -0
  4. package/db/x402-grants.cds +49 -0
  5. package/db/x402-receipts.cds +44 -0
  6. package/package.json +4 -3
  7. package/srv/bridge.d.ts +9 -12
  8. package/srv/bridge.js +10 -13
  9. package/srv/client/axios.d.ts +1 -1
  10. package/srv/client/axios.js +45 -8
  11. package/srv/client/envelope.d.ts +1 -1
  12. package/srv/client/envelope.js +1 -1
  13. package/srv/client/errors.d.ts +86 -0
  14. package/srv/client/errors.js +107 -0
  15. package/srv/client/fetch.d.ts +2 -2
  16. package/srv/client/fetch.js +71 -11
  17. package/srv/client/pay-handlers.d.ts +4 -4
  18. package/srv/client/pay-handlers.js +3 -3
  19. package/srv/client/types.d.ts +19 -7
  20. package/srv/client/types.js +3 -3
  21. package/srv/core/asset.d.ts +1 -1
  22. package/srv/core/decode.d.ts +2 -2
  23. package/srv/core/decode.js +5 -5
  24. package/srv/core/errors.js +3 -3
  25. package/srv/core/network.d.ts +1 -1
  26. package/srv/core/network.js +1 -1
  27. package/srv/core/requirements.d.ts +37 -5
  28. package/srv/core/requirements.js +43 -4
  29. package/srv/core/types.d.ts +68 -6
  30. package/srv/core/types.js +3 -3
  31. package/srv/core/validate.d.ts +31 -7
  32. package/srv/core/validate.js +84 -9
  33. package/srv/facilitator/adapter.d.ts +8 -8
  34. package/srv/facilitator/adapter.js +5 -5
  35. package/srv/facilitator/http.d.ts +4 -4
  36. package/srv/facilitator/http.js +5 -5
  37. package/srv/facilitator/nonce.d.ts +4 -4
  38. package/srv/facilitator/nonce.js +4 -4
  39. package/srv/facilitator/server.d.ts +68 -0
  40. package/srv/facilitator/server.js +167 -0
  41. package/srv/facilitator/settle.d.ts +2 -2
  42. package/srv/facilitator/settle.js +4 -4
  43. package/srv/facilitator/verify.d.ts +5 -5
  44. package/srv/facilitator/verify.js +19 -5
  45. package/srv/helpers/build-unsigned-tx.d.ts +5 -5
  46. package/srv/helpers/build-unsigned-tx.js +3 -3
  47. package/srv/helpers/verify-confirmed.d.ts +1 -1
  48. package/srv/helpers/verify-confirmed.js +1 -1
  49. package/srv/index.d.ts +4 -2
  50. package/srv/index.js +9 -3
  51. package/srv/middleware/cap.d.ts +47 -9
  52. package/srv/middleware/cap.js +111 -43
  53. package/srv/middleware/express.d.ts +15 -10
  54. package/srv/middleware/express.js +18 -19
  55. package/srv/middleware/grants.d.ts +64 -0
  56. package/srv/middleware/grants.js +113 -0
  57. package/srv/middleware/pricing.d.ts +41 -0
  58. package/srv/middleware/pricing.js +78 -0
  59. package/srv/middleware/receipts.d.ts +38 -0
  60. package/srv/middleware/receipts.js +68 -0
  61. package/srv/plugin.d.ts +2 -2
  62. 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:
@@ -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
- * 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)
@@ -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` registering per-entity would lose actions, and
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
- const priceUnits = pickPriceUnits(req, opts);
102
- if (priceUnits == null)
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
- const requirementsBody = (0, requirements_1.buildPaymentRequirements)({
105
- amount: priceUnits,
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
- if (opts.onAccepted) {
130
- processArgs.onAccepted = (claim) => opts.onAccepted(claim, req);
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 `req.reject` MUST be called outside this catch
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 CAP's dispatcher
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` 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
  *
@@ -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. Mutually exclusive
36
- * with `routePricing` (but coexistable: routePricing wins where
37
- * 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.
38
39
  */
39
- priceUnits?: string | number | bigint;
40
- /** Per-route prices keyed by the URL's last segment. */
41
- 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;
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()` runs the pipeline in-process via
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` 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
  *
@@ -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 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) {
@@ -58,11 +57,11 @@ function x402Middleware(opts) {
58
57
  try {
59
58
  if (skipPaths.test(req.path))
60
59
  return next();
61
- const priceUnits = pickPriceUnits(req, opts);
62
- if (priceUnits == null)
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.buildPaymentRequirements)({
65
- amount: priceUnits,
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
+ }