@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
@@ -0,0 +1,49 @@
1
+ name: tests
2
+
3
+ # Run on every push to main and every pull request. Manual dispatch
4
+ # lets us re-run the workflow from the Actions tab without a commit.
5
+ on:
6
+ push:
7
+ branches: [main]
8
+ pull_request:
9
+ workflow_dispatch:
10
+
11
+ jobs:
12
+ test:
13
+ name: Node ${{ matrix.node }} on ubuntu-latest
14
+ runs-on: ubuntu-latest
15
+
16
+ strategy:
17
+ # One Node failing should not cancel the other matrix legs;
18
+ # often a regression hits one version only.
19
+ fail-fast: false
20
+ matrix:
21
+ # Matches ODATANO core's support range.
22
+ node: ['20.x', '22.x']
23
+
24
+ steps:
25
+ - name: Checkout
26
+ uses: actions/checkout@v4
27
+
28
+ - name: Setup Node ${{ matrix.node }}
29
+ uses: actions/setup-node@v4
30
+ with:
31
+ node-version: ${{ matrix.node }}
32
+ cache: 'npm'
33
+
34
+ - name: Install dependencies
35
+ # `npm ci` enforces the lockfile and refuses to mutate it.
36
+ # Faster and more deterministic than `npm install` for CI.
37
+ run: npm ci
38
+
39
+ - name: Lint
40
+ run: npm run lint
41
+
42
+ - name: Type-check + build
43
+ # tsc -p tsconfig.build.json emits .js/.d.ts next to .ts.
44
+ # We don't ship those emits from CI; the step just verifies
45
+ # the build passes against the declared types.
46
+ run: npm run build
47
+
48
+ - name: Run tests
49
+ run: npm test
package/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@odatano/x402` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/) and the project adheres to [Semantic Versioning](https://semver.org/).
4
+
5
+ **Pre-1.0 caveat:** minor versions may include breaking changes until `1.0.0`.
6
+
7
+ ## [0.3.0] - 2026-05-15
8
+
9
+ ### Added
10
+ - **`createFacilitatorRouter()`** , reference HTTP facilitator. Returns an Express `Router` exposing `POST /verify-settle`, `GET /supported`, and an open `GET /healthz` liveness probe. Composable with any auth scheme via the `auth(req)` hook; defaults to `localFacilitator()`. Facilitator-side audit hooks (`onRejected`, `onPending`) fill the gap left by `onAccepted` (which is invoked client-side by `httpFacilitator()`). See [`examples/facilitator-server/`](examples/facilitator-server/) and [`docs/facilitator-protocol.md`](docs/facilitator-protocol.md#reference-implementation).
11
+ - **Multi-accept payment options** , `routePricing` (and `priceUnits`) now accept `RouteOption[]` so a single route can offer e.g. "0.5 ADA *or* 0.1 USDM". The buyer picks one implicitly by which `(payTo, asset)` the payment tx credits; new `pickRequirement()` selector in `srv/core/validate.ts` routes the tx to the matching entry before the six strict checks run. Single-entry behaviour is bit-identical to v0.2. New builder: `buildPaymentRequirementsMulti()`.
12
+ - **Dynamic `PriceResolver`** , `routePricing` can be a function `(PricingContext) => PriceSpec | null | Promise<...>`. Returning `null` passes the request through ungated, enabling free-tier, role-based, or per-payload pricing. `PricingContext` exposes `event`, `target` (CAP), `path`/`method`/`query` (Express), and `headers`. See [`docs/usage.md`](docs/usage.md#pricespec-and-priceresolver).
13
+ - **Receipts persistence (CAP)** , new `receipts?: boolean | { entity?: string }` option on `gateService`. When set, one INSERT per accepted payment, post-settle, pre-response. Default entity `odatano.x402.X402Receipts` ships in `db/x402-receipts.cds` and is auto-discovered by CAP. INSERT failures are logged and never block the response. See [`docs/usage.md`](docs/usage.md#receipts-persistence-receipts).
14
+ - **Subscription / time-limited grants (CAP)** , new `grants?: boolean | { ttlSeconds?: number; entity?: string }` option on `gateService`. On accepted payment the gate issues an opaque token and returns it via `X-PAYMENT-GRANT` / `X-PAYMENT-GRANT-EXPIRES` response headers; subsequent requests presenting the token on `X-PAYMENT-GRANT` bypass the 402 + verify+settle pipeline until expiry. Default TTL 3600s. Grants are single-route (strict URL equality). Default entity `odatano.x402.X402Grants` ships in `db/x402-grants.cds`. DB failures during issue or lookup are swallowed: failing DB never denies a paying buyer their response. See [`docs/usage.md`](docs/usage.md#subscription--time-limited-grants-grants).
15
+ - **Typed client errors** , new `X402PaymentError` class (with `kind`, `code`, `accepts`, `httpStatus`, `serverError`, `cause` fields). Thrown by `x402Fetch` and `x402Axios` to surface payment failures. Pay-handler errors are ALWAYS wrapped (with the original on `.cause`); add `errorOnFailure: true` to opt into typed throws on unrecovered 402s instead of the previous return-the-response / re-throw-AxiosError behaviour. Helpers `parseErrorCode` and `paymentErrorFromBody` are exported for consumers wrapping the wrappers. See [`docs/usage.md`](docs/usage.md#client-side-errors-x402paymenterror).
16
+ - **Browser-buyer example** , `examples/browser-buyer/` Vite scaffold showing CIP-30 wallet + `x402Fetch` wiring. Documents the typical "unsigned-from-server, signed-by-wallet" architecture (server exposes `POST /pay/intent` via `buildUnsignedPaymentTx`; browser signs via CIP-30). Includes CORS notes for cross-origin deployments.
17
+
18
+ ### Fixed
19
+ - **`x402Fetch` / `x402Axios` now interop with `gateService`** out of the box. CAP's `req.reject(402, body)` wraps the canonical v2 body inside its standard OData error envelope (`{ error: { message: "<json>", code: "402", ... } }`), so previous client wrappers saw `body.x402Version === undefined` and bailed without retrying. Both clients now defensively unwrap the OData envelope before validating shape. Reported by the CHAINFEED team; matches the workaround they were shipping in `scripts/buyer-pay-and-fetch.ts`. New helper `unwrapCapEnvelope` is exported for consumers wrapping the wrappers.
20
+
21
+ ### Known issues
22
+ - The CAP `gateService` still emits 402 responses wrapped in CAP's OData error envelope on the wire (because `req.reject` is the only documented abort path). Third-party x402 clients hitting a CAP-gated server will see the wrapped shape; only `@odatano/x402`'s own clients unwrap it. Direct-write-to-`req.http.res` is the planned symmetric fix but needs validation against `@sap/cds` ^9 internals; tracked for v0.3.1.
23
+ - `PaymentClaim.payTo` , verified recipient address now populated on the claim (was previously only on the requirements entry). Useful for `onAccepted` audit and receipts.
24
+
25
+ ### Changed
26
+ - Test count: 177 → 230 (HTTP-server round-trips, multi-accept + dynamic-pricing across `requirements`/`validate`/`verify`/`cap`/`express`, 4 receipts cases, 5 grants cases, 13 client-error cases).
27
+ - `srv/middleware/{cap,express}.ts` now emit `accepts[]` via `buildPaymentRequirementsMulti()`; single-entry callers are unaffected (one-entry array produces a body byte-identical to v0.2).
28
+ - `srv/facilitator/verify.ts` decodes the envelope BEFORE selecting a requirements entry; multi-accept depends on knowing which `(payTo, asset)` the tx actually credited.
29
+
30
+ ## [0.2.0] - 2026-05-15
31
+
32
+ ### Added
33
+ - **Client helpers**: `x402Fetch`, `x402Axios`, `createBridgePayHandler`, `encodePaymentEnvelope` for symmetric server + client usage. See [`docs/usage.md`](docs/usage.md#5-client-side-auto-handle-402-x402fetch--x402axios).
34
+ - **Facilitator adapter pattern**: `Facilitator` interface, `localFacilitator()` (default, in-process via `@odatano/core`), `httpFacilitator()` for delegating verify+settle to a hosted service. HTTP wire format documented in [`docs/facilitator-protocol.md`](docs/facilitator-protocol.md).
35
+ - **`facilitator` option** on `gateService` and `x402Middleware` for swapping in the local default, an HTTP delegate, or a mock for tests.
36
+ - **GitHub Actions CI** (`.github/workflows/test.yaml`): runs lint + build + tests on Node 20.x and 22.x for every push to `main` and every pull request.
37
+
38
+ ### Changed
39
+ - Test count: 144 → 177 (added 4 client suites and 2 facilitator-adapter suites).
40
+
41
+ ### Notes
42
+ - `0.1.0` consumers upgrade without code changes. The new `facilitator` option defaults to the previous in-process behaviour, so existing call sites are untouched.
43
+
44
+ ## [0.1.0] - 2026-05-13
45
+
46
+ ### Added
47
+ - Initial release. Cardano-x402-v2 payment gating for SAP CAP and Express.
48
+ - `gateService(srv, opts)` for CAP `before('*')` integration.
49
+ - `x402Middleware(opts)` for plain Express routes.
50
+ - Facilitator pipeline: decode, validate (six mandatory checks), `checkNonceUnspent`, `settle` (submit + poll-until-confirmed), `onAccepted` audit callback.
51
+ - Helpers: `verifyConfirmedPayment` (post-paid flow), `buildUnsignedPaymentTx` (browser-buyer flow).
52
+ - CAP plugin auto-discovery via `cds-plugin.js`.
53
+ - 144 unit tests across 13 suites.
54
+
55
+ ### Spec compatibility
56
+ - Implements Cardano-x402-**v2** only. v1 envelopes are rejected with `unsupported_version`; v1-style network strings (`cardano-preprod` with hyphen) are rejected with `invalid_network_format`.
57
+ - v1 and v2 facilitators cannot share a route: they use different header names (`X-PAYMENT` vs `PAYMENT-SIGNATURE`) and incompatible 402 bodies. To migrate from v1, replace the middleware in one commit; clients must upgrade simultaneously.
package/README.md CHANGED
@@ -1,32 +1,25 @@
1
1
  # @odatano/x402
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@odatano/x402?color=cb3837&logo=npm)](https://www.npmjs.com/package/@odatano/x402)
4
+ [![tests](https://github.com/ODATANO/x402/actions/workflows/test.yaml/badge.svg)](https://github.com/ODATANO/x402/actions/workflows/test.yaml)
5
+ [![@odatano/core](https://img.shields.io/github/package-json/dependency-version/ODATANO/x402/peer/@odatano/core?label=%40odatano%2Fcore&color=0e7c66)](https://www.npmjs.com/package/@odatano/core)
6
+ [![spec](https://img.shields.io/badge/Cardano--x402-v2-blueviolet)](https://github.com/masumi-network/x402-cardano)
7
+
3
8
  x402 payment gating for SAP CAP applications, backed by Cardano.
4
9
 
5
- Wire a single `before('*')` hook into your CAP service and every gated request returns **HTTP 402 Payment Required** until the caller proves on-chain settlement. Asset-agnostic pay in ADA, USDM, or any native asset.
10
+ Wire a single `before('*')` hook into your CAP service. Every gated request returns **HTTP 402 Payment Required** until the caller proves on-chain settlement. Asset-agnostic: pay in ADA, USDM, or any native asset.
6
11
 
7
12
  Implements the **Cardano-x402-v2** spec on top of [`@odatano/core`](https://www.npmjs.com/package/@odatano/core).
8
13
 
9
- ---
10
-
11
- ## What is x402?
12
-
13
- [x402](https://www.x402.org/) is the dormant HTTP `402 Payment Required` status code, revived. Servers respond `402` with a machine-readable body describing the price, asset, and recipient. Clients build, sign, and submit a payment, then retry the request with a `PAYMENT-SIGNATURE` header. Settlement happens on-chain.
14
-
15
- The original x402 spec is Coinbase / EVM-flavoured. The **Cardano-x402-v2** spec (in progress at [masumi-network/x402-cardano](https://github.com/masumi-network/x402-cardano)) adapts it to Cardano's UTxO model. This library is a from-scratch v2 implementation in TypeScript.
16
-
17
- ---
18
-
19
14
  ## Install
20
15
 
21
16
  ```bash
22
17
  npm install @odatano/x402 @odatano/core
23
18
  ```
24
19
 
25
- `@odatano/core` (the Cardano bridge) is a peer dependency install whichever version meets `>=1.7.8`.
20
+ `@odatano/core` (the Cardano bridge) is a peer dependency. Install whichever version meets `>=1.7.8`.
26
21
 
27
- ---
28
-
29
- ## Quick Start — CAP service gate
22
+ ## Quick Start
30
23
 
31
24
  ```typescript
32
25
  // srv/prices-service.ts
@@ -38,16 +31,10 @@ export class PricesService extends cds.ApplicationService {
38
31
  gateService(this, {
39
32
  payTo: 'addr_test1...your-preprod-address...',
40
33
  network: 'cardano:preprod',
41
- asset: 'lovelace', // or '<policy>.<nameHex>' for native tokens
34
+ asset: 'lovelace', // or '<policy>.<nameHex>' for native tokens
42
35
  routePricing: {
43
- Quotes: '500000', // 0.5 ADA per Quotes read
44
- getBestPrice: '1000000', // 1 ADA per getBestPrice action call
45
- },
46
- description: 'Synthetic price feed',
47
- onAccepted: async (claim, req) => {
48
- // Optional audit — runs once per accepted payment, after settlement.
49
- // Errors here are logged but don't block the response.
50
- console.log(`paid ${claim.amountUnits} ${claim.asset} (tx=${claim.txHash})`);
36
+ Quotes: '500000', // 0.5 ADA per Quotes read
37
+ getBestPrice: '1000000', // 1 ADA per getBestPrice action call
51
38
  },
52
39
  });
53
40
  return super.init();
@@ -71,280 +58,41 @@ Configure the Cardano backend in `package.json`:
71
58
  }
72
59
  ```
73
60
 
74
- That's it `cds watch` and every request to a gated route gets:
75
-
76
- ```http
77
- HTTP/1.1 402 Payment Required
78
- Content-Type: application/json
79
- ```
80
- ```json
81
- {
82
- "x402Version": 2,
83
- "error": "PAYMENT-SIGNATURE header is required",
84
- "accepts": [{
85
- "scheme": "exact",
86
- "network": "cardano:preprod",
87
- "asset": "lovelace",
88
- "amount": "500000",
89
- "payTo": "addr_test1...",
90
- "resource": {
91
- "url": "/odata/v4/prices/Quotes",
92
- "description": "Synthetic price feed",
93
- "mimeType": "application/json"
94
- },
95
- "assetTransferMethod": "default",
96
- "maxTimeoutSeconds": 600
97
- }]
98
- }
99
- ```
100
-
101
- A working version of this is in [`examples/cap-app/`](examples/cap-app/).
102
-
103
- ---
104
-
105
- ## Usage patterns
106
-
107
- ### 1. CAP service gate (`gateService`)
108
-
109
- For OData-served entities and bound/unbound actions. Pricing keys can be entity names (for CRUD) or action names — the gate tries both.
110
-
111
- ```typescript
112
- gateService(this, {
113
- payTo, network, asset,
114
- routePricing: { Quotes: '500000', getBestPrice: '1000000' },
115
- });
116
- ```
117
-
118
- When a payment is accepted, the verified `PaymentClaim` is stashed on `req.payment` for downstream handlers, and an `X-PAYMENT-RESPONSE` header is set on the response.
119
-
120
- ### 2. Express middleware (`x402Middleware`)
121
-
122
- For plain Express routes (e.g. mounted alongside CAP via `cds.on('bootstrap', app => …)`):
123
-
124
- ```typescript
125
- import { x402Middleware } from '@odatano/x402';
126
-
127
- app.use('/api/premium', x402Middleware({
128
- payTo, network, asset,
129
- priceUnits: '1000000',
130
- skipPaths: /(\$metadata|^\/?$)/i,
131
- onAccepted: async (claim, req) => { /* audit */ },
132
- }));
133
- ```
134
-
135
- ### 3. Programmatic — verify a post-paid tx
136
-
137
- For subscription / pre-paid flows where the buyer hands you a tx hash:
138
-
139
- ```typescript
140
- import { verifyConfirmedPayment } from '@odatano/x402';
141
-
142
- const result = await verifyConfirmedPayment({
143
- txHash: 'ab8f…',
144
- requiredAmount: '1000000',
145
- asset: 'lovelace',
146
- payTo: 'addr_test1...',
147
- network: 'cardano:preprod',
148
- });
149
-
150
- if (result.ok) {
151
- // result.amountUnits is what was actually paid (may exceed requiredAmount)
152
- } else {
153
- // result.code: 'pending' | 'wrong_asset' | 'insufficient_amount' | ...
154
- }
155
- ```
156
-
157
- ### 4. Programmatic — server-side unsigned-tx builder for browser buyers
158
-
159
- When the buyer's CIP-30 wallet can sign but not coin-select:
160
-
161
- ```typescript
162
- import { buildUnsignedPaymentTx, buildPaymentRequirements, flatRequirements } from '@odatano/x402';
163
-
164
- const body = buildPaymentRequirements({ amount: '1000000', asset: 'lovelace', payTo, network: 'cardano:preprod', resource: '/r' });
165
- const requirements = flatRequirements(body);
166
-
167
- const { unsignedTxCborHex, txHashHex, nonceRef } = await buildUnsignedPaymentTx({
168
- buyerBech32: 'addr_test1...buyer...',
169
- requirements,
170
- });
171
-
172
- // Browser signs unsignedTxCborHex via CIP-30, then assembles the
173
- // PAYMENT-SIGNATURE envelope with `nonceRef` as payload.nonce.
174
- ```
175
-
176
- ---
177
-
178
- ## Configuration reference
179
-
180
- ### `gateService(srv, options)` / `x402Middleware(options)`
181
-
182
- | Option | Type | Required | Default | Notes |
183
- |---|---|---|---|---|
184
- | `payTo` | `string` (bech32) | yes | — | Recipient address |
185
- | `network` | `'cardano:mainnet' \| 'cardano:preprod' \| 'cardano:preview'` | yes | — | **v2 uses colon separator** (v1 hyphen rejected) |
186
- | `asset` | `string` | yes | — | `'lovelace'` for ADA, or `'<policyIdHex>.<assetNameHex>'` for native tokens |
187
- | `priceUnits` | `string \| number \| bigint` | one of priceUnits / routePricing | — | Single price for everything under the mount |
188
- | `routePricing` | `Record<string, string \| number \| bigint>` | one of priceUnits / routePricing | — | Per-entity / per-action prices. Unmapped keys pass through unless `priceUnits` is also set |
189
- | `skipPaths` | `RegExp` | no | matches `$metadata`, `$batch`, root, `/index` | Express only — paths to bypass |
190
- | `description` | `string` | no | `''` | Embedded in `accepts[0].resource.description` |
191
- | `mimeType` | `string` | no | `'application/json'` | Embedded in `accepts[0].resource.mimeType` |
192
- | `assetTransferMethod` | `'default' \| 'masumi' \| 'script'` | no | `'default'` | v2 field; MVP supports only `default` |
193
- | `maxTimeoutSeconds` | `number` | no | `600` | Buyer-side TTL hint |
194
- | `extra` | `Record<string, unknown>` | no | — | Free-form extras (decimals, fingerprint, UI hints) |
195
- | `settlePollBudgetMs` | `number` | no | `60_000` | How long to poll for chain confirmation before returning `402 pending` |
196
- | `allowNoTtl` | `boolean` | no | `false` | If `true`, accept txs with no validity-range upper bound |
197
- | `onAccepted` | `(claim, req) => void \| Promise<void>` | no | — | Audit callback. Errors logged, never block response |
198
- | `resourceUrl` | `(req) => string` | no (CAP only) | derives from `req.http.req.originalUrl` | Override the resource URL emitted in the 402 body |
199
-
200
- ---
201
-
202
- ## The buyer flow
203
-
204
- ```
205
- Server Buyer (browser / CLI)
206
- │ │
207
- │ ◄── GET /odata/v4/prices/Quotes ─────│
208
- │ │
209
- │ ──── 402 + accepts[0] (price, payTo) ►│
210
- │ │
211
- │ ┌────────┴────────┐
212
- │ │ build tx │
213
- │ │ sign via CIP-30 │
214
- │ │ base64-encode │
215
- │ └────────┬────────┘
216
- │ │
217
- │ ◄── GET /odata/v4/prices/Quotes ─────│
218
- │ PAYMENT-SIGNATURE: <base64> │
219
- │ │
220
- ┌──────┴──────┐ │
221
- │ 6 checks │ │
222
- │ + submit │ │
223
- │ + poll │ │
224
- │ + onAccepted│ │
225
- └──────┬──────┘ │
226
- │ │
227
- │ ─────── 200 OK + data ───────────────►│
228
- │ X-PAYMENT-RESPONSE: <base64> │
229
- ```
230
-
231
- `PAYMENT-SIGNATURE` envelope shape:
232
-
233
- ```json
234
- {
235
- "x402Version": 2,
236
- "scheme": "exact",
237
- "network": "cardano:preprod",
238
- "payload": {
239
- "transaction": "<base64-CBOR of signed tx>",
240
- "nonce": "<txHash>#<outputIndex>"
241
- }
242
- }
243
- ```
244
-
245
- The `nonce` references a UTxO that **must also appear as an input of the payment tx**. Once the tx settles, that UTxO is consumed — replay defense is on-chain, no DB table needed.
246
-
247
- ---
248
-
249
- ## Six mandatory facilitator checks
61
+ `cds watch`, then probe a gated route: it returns `402` with a v2-shape body. A working example lives in [`examples/cap-app/`](examples/cap-app/).
250
62
 
251
- Every accepted payment passes all six (in order):
63
+ ## What's in the box
252
64
 
253
- | # | Check | Code on failure |
254
- |---|---|---|
255
- | 1 | Network matches requirements | `network_mismatch` |
256
- | 2 | At least one output to `payTo` | `wrong_recipient` |
257
- | 3 | Sum of payTo outputs for asset ≥ required | `insufficient_amount` |
258
- | 4 | Exact policy + asset-name match | `wrong_asset` |
259
- | 5 | Nonce UTxO referenced as tx input **and** unspent on chain | `nonce_not_referenced` / `replay_detected` |
260
- | 6 | Validity-range upper bound still in future | `expired_ttl` |
65
+ - **`gateService(srv, opts)`** for CAP services and **`x402Middleware(opts)`** for plain Express routes.
66
+ - **`x402Fetch` / `x402Axios`** wrappers that auto-handle 402 on the client side.
67
+ - **`Facilitator` adapter:** `localFacilitator()` (default, in-process via `@odatano/core`) or `httpFacilitator()` to delegate verify+settle to a hosted service.
68
+ - **Helpers:** `buildUnsignedPaymentTx` (browser-buyer flow), `verifyConfirmedPayment` (post-paid / subscription).
261
69
 
262
- Plus a sanity guard: tx has at least one vkey witness → `unsigned_transaction`.
70
+ ## Documentation
263
71
 
264
- Rejected requests get `402` with an `error` field of the form `"<base> (<code>): <reason>"` so clients can parse the code without breaking wire format.
265
-
266
- ---
267
-
268
- ## Architecture
269
-
270
- ```
271
- ┌──────────────────┐
272
- consumer │ CAP application │
273
- │ │
274
- │ gateService(this,│
275
- │ { … }) │
276
- └────────┬─────────┘
277
-
278
-
279
- ┌──────────────────┐ bridge.ts
280
- │ @odatano/x402 │ ──────────────┐
281
- │ │ │
282
- │ core/ ──────┤ pure logic │
283
- │ facilitator/ ────┤ chain-touching│
284
- │ middleware/ ────┤ Express + CAP │
285
- │ helpers/ ────┤ tx-build, │
286
- │ │ verify-post-paid
287
- └──────────────────┘ │
288
-
289
- ┌────────────────────────┐
290
- │ @odatano/core 1.7.8 │
291
- │ ┌──────┬──────┬─────┐ │
292
- │ │ Blkf │Koios │Ogms │ │
293
- │ └──────┴──────┴─────┘ │
294
- └────────────────────────┘
295
-
296
-
297
- Cardano network
298
- ```
299
-
300
- Pure modules (`core/*`) are decoupled from the bridge and can be unit-tested without any chain backend. The facilitator orchestrates `decode → validate → checkNonceUnspent → settle → onAccepted`.
301
-
302
- ---
72
+ | Doc | Covers |
73
+ |---|---|
74
+ | [`docs/usage.md`](docs/usage.md) | All five usage patterns + full configuration reference |
75
+ | [`docs/protocol.md`](docs/protocol.md) | Buyer-flow diagram, `PAYMENT-SIGNATURE` envelope, the six mandatory facilitator checks |
76
+ | [`docs/architecture.md`](docs/architecture.md) | Module layout, pure-vs-chain split, plugin auto-discovery |
77
+ | [`docs/facilitator-protocol.md`](docs/facilitator-protocol.md) | HTTP wire format for the hosted-facilitator pattern (`httpFacilitator()`) |
78
+ | [`CHANGELOG.md`](CHANGELOG.md) | Versioned changes, latest first |
303
79
 
304
80
  ## Requirements
305
81
 
306
82
  - Node.js 22+
307
83
  - `@sap/cds >= 9` (peer)
308
84
  - `@odatano/core >= 1.7.8` (peer)
309
- - `express ^4` (peer) only required if you use `x402Middleware`
85
+ - `express ^4` (peer), only if you use `x402Middleware`
310
86
  - A Cardano backend reachable via `@odatano/core` (Blockfrost / Koios / Ogmios)
311
87
 
312
- ---
313
-
314
88
  ## Development
315
89
 
316
90
  ```bash
317
- npm install # Workspace install covers root + examples/*
318
- npm run build # tsc emits .js/.d.ts next to .ts (outDir: .)
319
- npm test # 144 tests, ~12s
320
- npm run test:coverage # Coverage report
321
- npm run watch # cds watch — for the root project itself
322
- ```
323
-
324
- The example app:
325
-
326
- ```bash
327
- cd examples/cap-app
328
- npm start # cds-serve with in-memory SQLite
329
- ```
330
-
331
- Then probe:
332
-
333
- ```bash
334
- curl -s http://localhost:4004/odata/v4/prices/Quotes | jq .
335
- # → 402 with v2 body
91
+ npm install # Workspace install: covers root + examples/*
92
+ npm run build # tsc, emits .js/.d.ts next to .ts (outDir: .)
93
+ npm test # 177 tests, ~13s
336
94
  ```
337
95
 
338
- ---
339
-
340
- ## Versioning + spec compatibility
341
-
342
- - **Library:** `0.1.0` (pre-1.0; expect minor breakages between minors until 1.0)
343
- - **Spec:** Cardano-x402-**v2** only. v1 envelopes are rejected with `unsupported_version`. v1-style network strings (`cardano-preprod`) are rejected with `invalid_network_format`.
344
- - **Coexistence:** A v1 facilitator and a v2 facilitator can't share the same route — they use different header names (`X-PAYMENT` vs `PAYMENT-SIGNATURE`) and incompatible 402 bodies. To migrate from v1, replace the middleware in one commit; clients must upgrade simultaneously.
345
-
346
- ---
347
-
348
96
  ## License
349
97
 
350
98
  Apache-2.0.
package/cds-plugin.js CHANGED
@@ -7,4 +7,6 @@
7
7
  * (outDir: ".") so this require path resolves both in dev (tsx) and
8
8
  * after `npm run build`.
9
9
  */
10
+ // CAP loads this file as a CommonJS entrypoint, so `require()` is required here.
11
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
10
12
  module.exports = require('./srv/plugin');
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Time-limited access grants issued after an accepted x402 payment.
3
+ *
4
+ * Pattern: pay once, get N seconds of free access to the same route.
5
+ * The server issues an opaque random token on `accepted`, returns it via
6
+ * the `X-PAYMENT-GRANT` response header, and the buyer presents it on
7
+ * subsequent requests via `X-PAYMENT-GRANT` request header. Until the
8
+ * grant expires, the gate skips the 402 + verify+settle pipeline.
9
+ *
10
+ * Replay defense: each grant is single-route + time-bound. A grant
11
+ * issued for `/Quotes` cannot be used against `/getBestPrice` (cheap
12
+ * route boundary, the picker enforces equality). Stolen tokens are
13
+ * useful only until expiry.
14
+ *
15
+ * Cleanup: expired rows accumulate. The `lookupGrant` helper checks
16
+ * `expiresAt < now` and reports `expired`; consumers may run their own
17
+ * cleanup (`DELETE … WHERE expiresAt < now()`) on whatever schedule
18
+ * fits. The library does NOT auto-prune.
19
+ */
20
+
21
+ namespace odatano.x402;
22
+
23
+ entity X402Grants {
24
+ key id : UUID;
25
+
26
+ @description: 'Opaque random token, base64url 32 bytes.'
27
+ token : String(64) @assert.unique;
28
+
29
+ @description: 'Resource URL this grant unlocks (exact match).'
30
+ route : String(500);
31
+
32
+ @description: 'Sender address from the original payment (if resolved).'
33
+ payerAddr : String(120);
34
+
35
+ @description: 'Tx hash of the payment that bought this grant.'
36
+ txHash : String(64);
37
+
38
+ @description: 'Asset of the underlying payment, audit only.'
39
+ asset : String(120);
40
+
41
+ @description: 'cardano:mainnet | cardano:preprod | cardano:preview.'
42
+ network : String(20);
43
+
44
+ @description: 'Server-side timestamp at issue.'
45
+ issuedAt : Timestamp;
46
+
47
+ @description: 'Server-side timestamp when the grant becomes invalid.'
48
+ expiresAt : Timestamp;
49
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Optional persistence for accepted x402 payments.
3
+ *
4
+ * Enabled per-service by setting `receipts: true` on `gateService(opts)`.
5
+ * The gate writes one row per accepted payment, after settle confirms,
6
+ * BEFORE the response is served. Insert failures are logged and never
7
+ * block the response, the canonical record is on chain regardless.
8
+ *
9
+ * Namespace and entity name are stable, consumers can SELECT against
10
+ * `odatano.x402.X402Receipts` directly or extend it in their own model.
11
+ */
12
+
13
+ namespace odatano.x402;
14
+
15
+ entity X402Receipts {
16
+ key id : UUID;
17
+
18
+ @description: 'Lowercase 64-char hex of the buyer''s settled payment tx.'
19
+ txHash : String(64) @assert.unique;
20
+
21
+ @description: 'Sender address (first input bech32) if the facilitator resolved it.'
22
+ payerAddr : String(120);
23
+
24
+ @description: 'Recipient bech32 the route required.'
25
+ payTo : String(120);
26
+
27
+ @description: 'v2 asset string, ''lovelace'' or ''<policy>.<nameHex>''.'
28
+ asset : String(120);
29
+
30
+ @description: 'Amount paid in raw units, BigInt-safe string.'
31
+ amount : String(32);
32
+
33
+ @description: 'cardano:mainnet | cardano:preprod | cardano:preview.'
34
+ network : String(20);
35
+
36
+ @description: 'Resource URL the buyer paid for (request originalUrl or cap://<event>).'
37
+ route : String(500);
38
+
39
+ @description: '<txHash>#<index> of the UTxO that was the replay nonce.'
40
+ nonceRef : String(80);
41
+
42
+ @description: 'Server-side timestamp when the receipt was written (post-settle).'
43
+ at : Timestamp;
44
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@odatano/x402",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "x402 Cardano-v2 payment library for SAP CAP applications",
5
5
  "license": "Apache-2.0",
6
6
  "main": "srv/index.js",
7
- "types": "srv/index.d.ts",
7
+ "types": "srv/cds-augment.d.ts",
8
8
  "publishConfig": {
9
9
  "access": "public"
10
10
  },
@@ -26,13 +26,19 @@
26
26
  "@emurgo/cardano-serialization-lib-nodejs": "^15.0.3"
27
27
  },
28
28
  "peerDependencies": {
29
+ "@cap-js/cds-types": ">=0.15",
29
30
  "@odatano/core": ">=1.7.8",
30
31
  "@sap/cds": ">=9",
31
32
  "express": "^4"
32
33
  },
34
+ "peerDependenciesMeta": {
35
+ "@cap-js/cds-types": {
36
+ "optional": true
37
+ }
38
+ },
33
39
  "devDependencies": {
34
- "@cap-js/cds-types": "^0.15.0",
35
40
  "@cap-js/cds-typer": "^0.38.0",
41
+ "@cap-js/cds-types": "^0.15.0",
36
42
  "@cap-js/sqlite": "^2",
37
43
  "@odatano/core": "^1.7.8",
38
44
  "@odatano/x402": ".",
@@ -47,6 +53,7 @@
47
53
  "jest": "^29",
48
54
  "ts-jest": "^29",
49
55
  "tsx": "^4",
50
- "typescript": "^5"
56
+ "typescript": "^5",
57
+ "typescript-eslint": "^8.59.3"
51
58
  }
52
59
  }
package/srv/bridge.d.ts CHANGED
@@ -3,18 +3,15 @@
3
3
  *
4
4
  * The x402 modules (facilitator, helpers, middleware) all import from
5
5
  * here so the underlying ODATANO surface is the only thing they couple
6
- * to and so renames in core (`getTransaction` → `getTransactionByHash`)
6
+ * to, and so renames in core (`getTransaction` → `getTransactionByHash`)
7
7
  * stay isolated to this file.
8
8
  *
9
- * Two methods needed by Cardano-x402-v2 are NOT (yet) first-class on
10
- * `@odatano/core@1.7.7`:
11
- * - `isUtxoUnspent(txHash, outputIndex)` for replay-defense check 5b
12
- * - `getCurrentSlot()` for TTL check 6
9
+ * Two methods specific to Cardano-x402-v2 are first-class on
10
+ * `@odatano/core` since `1.7.8` (our minimum peer):
11
+ * - `isUtxoUnspent(txHash, outputIndex)` for replay-defense check 5b
12
+ * - `getCurrentSlot()` for TTL check 6
13
13
  *
14
- * Both are implemented here as **shims** on top of existing core
15
- * methods, so x402 works against an unmodified 1.7.7. When ODATANO
16
- * exposes either method natively (planned ≥1.7.8), we can swap the
17
- * shim for a direct call without touching downstream code.
14
+ * Both are called through directly here; no shim layer remains.
18
15
  */
19
16
  export interface BridgeAsset {
20
17
  unit: string;
@@ -49,18 +46,18 @@ export declare function getProtocolParameters(): Promise<unknown>;
49
46
  export declare function submitTransaction(signedCborHex: string): Promise<string>;
50
47
  /**
51
48
  * Current chain tip slot. First-class method on `CardanoClient` since
52
- * `@odatano/core@1.7.8` wraps `getLatestBlock().slot` with a
49
+ * `@odatano/core@1.7.8`, wraps `getLatestBlock().slot` with a
53
50
  * `ProviderUnavailableError` translation so consumers don't deal with
54
51
  * `null` slots.
55
52
  */
56
53
  export declare function getCurrentSlot(): Promise<number>;
57
54
  /**
58
55
  * Check whether a UTxO is still unspent. First-class method since
59
- * `@odatano/core@1.7.8` backed by `consumed_by` (Blockfrost) /
56
+ * `@odatano/core@1.7.8`, backed by `consumed_by` (Blockfrost) /
60
57
  * `is_spent` (Koios) / `queryLedgerState/utxo` (Ogmios).
61
58
  *
62
59
  * Returns `false` for txs that don't exist on chain or for
63
- * out-of-range output indices both are "not spendable" from the
60
+ * out-of-range output indices, both are "not spendable" from the
64
61
  * caller's perspective.
65
62
  */
66
63
  export declare function isUtxoUnspent(txHash: string, outputIndex: number): Promise<boolean>;