From 95e2d4d6d65822ac82b466fdccef2194a4886f58 Mon Sep 17 00:00:00 2001 From: fernando-mendoza <55415857+fernando-mendoza@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:21:49 -0600 Subject: [PATCH] feat(examples): OPA policy engine behind the pre-sign hook (#142) Co-Authored-By: Claude Opus 5.5 --- examples/x402-policy-opa/README.md | 65 + examples/x402-policy-opa/package-lock.json | 1185 +++++++++++++++++ examples/x402-policy-opa/package.json | 20 + examples/x402-policy-opa/policy/.manifest | 1 + examples/x402-policy-opa/policy/x402.rego | 34 + .../x402-policy-opa/policy/x402_test.rego | 27 + examples/x402-policy-opa/src/demo.ts | 21 + examples/x402-policy-opa/src/opa-policy.ts | 106 ++ .../test/hook-integration.test.ts | 201 +++ .../x402-policy-opa/test/opa-policy.test.ts | 111 ++ examples/x402-policy-opa/tsconfig.json | 15 + 11 files changed, 1786 insertions(+) create mode 100644 examples/x402-policy-opa/README.md create mode 100644 examples/x402-policy-opa/package-lock.json create mode 100644 examples/x402-policy-opa/package.json create mode 100644 examples/x402-policy-opa/policy/.manifest create mode 100644 examples/x402-policy-opa/policy/x402.rego create mode 100644 examples/x402-policy-opa/policy/x402_test.rego create mode 100644 examples/x402-policy-opa/src/demo.ts create mode 100644 examples/x402-policy-opa/src/opa-policy.ts create mode 100644 examples/x402-policy-opa/test/hook-integration.test.ts create mode 100644 examples/x402-policy-opa/test/opa-policy.test.ts create mode 100644 examples/x402-policy-opa/tsconfig.json diff --git a/examples/x402-policy-opa/README.md b/examples/x402-policy-opa/README.md new file mode 100644 index 0000000..f100acc --- /dev/null +++ b/examples/x402-policy-opa/README.md @@ -0,0 +1,65 @@ +# x402 policy: Open Policy Agent behind the pre-sign hook + +A small, runnable bridge between the SDK's [pre-sign policy hook](../../packages/sdk/README.md#pre-sign-policy-hook-experimental) (`initX402({ policy })`, experimental since 0.16.0) and an [Open Policy Agent](https://www.openpolicyagent.org/) server. Every `x402Fetch()` sends the decoded payment to OPA before signing, and OPA's answer becomes `ALLOW` or `DENY`. + +**This is a reference, not a guarantee.** Read [What this does not solve](#what-this-does-not-solve) before relying on it. + +## What it does + +- **Asks OPA with the decoded context.** `evaluate()` POSTs `{ input: { amount, destination, asset, network, agent } }` to OPA's Data API (`/v1/data/nirium/x402/decision?provenance`). The values come from `ctx.authorization` (what is actually signed) and `ctx.signer`. +- **Ships a small Rego policy** ([`policy/x402.rego`](policy/x402.rego)): a per-payment cap (1 USDC, 7 decimals) and a destination allowlist, with Rego unit tests in [`policy/x402_test.rego`](policy/x402_test.rego). +- **Fails closed.** OPA unreachable, a non-200 answer, a body that is not JSON, an undefined rule, a result that is not `{ allow: boolean, reason?: string }`, or no bundle revision: `evaluate()` throws, and the SDK signs nothing. Nothing in the example catches those errors. A slow OPA is cut by the SDK's deadline (`timeoutMs`): the request gets the hook's abort signal. +- **Bound, short-lived ALLOW.** An `ALLOW` echoes the exact `ctx.contextHash` and carries `expiresAt = now + ttlMs` (default 2 s). `requireExpiry: true` is set on the hook. +- **Bundle revision as policy version.** With `?provenance`, OPA reports the revision of each loaded bundle (`provenance.bundles..revision`, taken from the bundle's `.manifest`). That revision is the `policyVersion` of every decision, and `currentVersion()` asks OPA for it again right before signing. If the bundle changed in between, the SDK refuses with `STALE`. + +## Run OPA + +From this directory, with Docker: + +```bash +docker run --rm -p 8181:8181 -v "$PWD/policy:/policy:ro" -w / \ + openpolicyagent/opa run --server --addr :8181 --watch --bundle policy +``` + +Or with the `opa` binary: `opa run --server --addr 127.0.0.1:8181 --watch --bundle policy`. + +OPA names a bundle loaded with `--bundle` after the path you pass, so `--bundle policy` reports it as `policy`. If you pass another path, set `bundle` in the config to match. `--watch` reloads the bundle when the files change; bump `revision` in `policy/.manifest` on every change, so that a decision made before it is refused as `STALE`. + +```bash +opa test policy -v # Rego unit tests +``` + +## Use + +```typescript +import { OpaPolicy } from './src/opa-policy.ts'; + +const opa = new OpaPolicy({ + url: 'http://127.0.0.1:8181', + path: 'nirium/x402/decision', // default + bundle: 'policy', // default: name OPA reports in provenance + ttlMs: 2_000, +}); + +agent.initX402({ signer, network: 'stellar:pubnet', policy: opa.hook() }); +``` + +Requires Node >= 22.12 (npm 10), the same as the SDK. + +```bash +npm install +npm test # node:test via tsx, OPA is mocked, no network +npm run typecheck +OPA_URL=http://127.0.0.1:8181 npm start # demo against the OPA started above +``` + +The SDK is referenced as `file:../../packages/sdk`. `src/` imports only its types; `test/hook-integration.test.ts` also runs the SDK's real pre-sign gate (`createPolicyGatedSigner` from the built `dist/`, the same one `initX402({ policy })` installs) with a mocked OPA, so build the SDK once (`cd ../../packages/sdk && npm install --legacy-peer-deps && npm run build`, as the SDK CI does) before `typecheck`. + +## What this does not solve + +These are the limits listed in the SDK README (from [#96](https://github.com/nirium-protocol/nirium/issues/96)). This example does not remove them. + +- **L01, pending-capacity reservation.** The Rego policy only looks at one payment. Two concurrent requests are evaluated independently, so an aggregate limit (daily budget, total exposure) is not held here; OPA is stateless per query. For a per-agent daily cap with atomic check-and-commit, see [`x402-policy-daily-budget`](../x402-policy-daily-budget/). +- **L02, same-process signer access.** This is a check on the agent side. Any code in the same process that holds the raw signer (or replaces its `signAuthEntry`) can sign without asking OPA. Nothing on-chain enforces the policy. +- **Version check is not atomic with signing.** As the SDK README says, `currentVersion()` narrows the stale-ALLOW window; it does not close it. It also only detects a change if the bundle revision changes: editing the Rego without bumping `.manifest` is invisible. +- **The OPA connection is yours to secure.** The example uses plain HTTP to a local OPA and no authentication. Anyone who can answer on that URL decides what gets signed. diff --git a/examples/x402-policy-opa/package-lock.json b/examples/x402-policy-opa/package-lock.json new file mode 100644 index 0000000..0c79e28 --- /dev/null +++ b/examples/x402-policy-opa/package-lock.json @@ -0,0 +1,1185 @@ +{ + "name": "nirium-x402-policy-opa", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nirium-x402-policy-opa", + "version": "0.1.0", + "dependencies": { + "nirium": "file:../../packages/sdk" + }, + "devDependencies": { + "@stellar/stellar-sdk": "^16.4.1", + "@types/node": "^22.10.0", + "tsx": "^4.21.0", + "typescript": "^5.7.0" + } + }, + "../../packages/sdk": { + "name": "nirium", + "version": "0.16.1", + "license": "Apache-2.0", + "dependencies": { + "@stellar/mpp": "^0.7.1", + "@stellar/stellar-sdk": "^16.3.0", + "@x402/fetch": "^2.28.0", + "@x402/stellar": "^2.28.0", + "mppx": "^0.6.31", + "viem": "^2.55.19", + "ws": "^8.21.3" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^26.2.0", + "@types/ws": "^8.18.1", + "@x402/core": "^2.28.0", + "jest": "^30.4.2", + "ts-jest": "^29.4.12", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/ed25519": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.2.0.tgz", + "integrity": "sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@stellar/js-xdr": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", + "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9.0.0" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.4.1.tgz", + "integrity": "sha512-0kT+ShOUE6dc0smLEsajuizss1hvlGDsCooM88FqgxNOCKpY8IuQPzCs75RNKMSMITz+dX7KZ5qGhQ2EWGMTTg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "4.0.0", + "axios": "1.20.0", + "base32.js": "^0.1.0", + "bignumber.js": "^11.1.4", + "buffer": "^6.0.3", + "commander": "^14.0.3", + "eventsource": "^4.1.0", + "feaxios": "^0.0.23", + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", + "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/eventsource": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", + "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/feaxios": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nirium": { + "resolved": "../../packages/sdk", + "link": true + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/smol-toml": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", + "integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/examples/x402-policy-opa/package.json b/examples/x402-policy-opa/package.json new file mode 100644 index 0000000..01f4fc9 --- /dev/null +++ b/examples/x402-policy-opa/package.json @@ -0,0 +1,20 @@ +{ + "name": "nirium-x402-policy-opa", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/demo.ts", + "typecheck": "tsc --noEmit", + "test": "tsx --test test/*.test.ts" + }, + "dependencies": { + "nirium": "file:../../packages/sdk" + }, + "devDependencies": { + "@stellar/stellar-sdk": "^16.4.1", + "@types/node": "^22.10.0", + "tsx": "^4.21.0", + "typescript": "^5.7.0" + } +} diff --git a/examples/x402-policy-opa/policy/.manifest b/examples/x402-policy-opa/policy/.manifest new file mode 100644 index 0000000..c694c0a --- /dev/null +++ b/examples/x402-policy-opa/policy/.manifest @@ -0,0 +1 @@ +{"revision": "opa-example-r1", "roots": ["nirium"]} diff --git a/examples/x402-policy-opa/policy/x402.rego b/examples/x402-policy-opa/policy/x402.rego new file mode 100644 index 0000000..14e173e --- /dev/null +++ b/examples/x402-policy-opa/policy/x402.rego @@ -0,0 +1,34 @@ +# Reference policy for examples/x402-policy-opa (#142). +# Input: {amount, destination, asset, network, agent} (amount in base units, as a string). +package nirium.x402 + +# Per-payment cap, in the asset's base units (1 USDC with 7 decimals). +max_amount := 10000000 + +# Destinations this agent may pay. +allowed_destinations := { + "GMERCHANTALLOWED", +} + +default decision := {"allow": false, "reason": "no rule allowed this payment"} + +decision := {"allow": true} if { + amount_ok + destination_ok +} + +decision := {"allow": false, "reason": "amount over per-payment cap"} if { + not amount_ok +} + +decision := {"allow": false, "reason": "destination not in allowlist"} if { + amount_ok + not destination_ok +} + +amount_ok if { + regex.match(`^[1-9][0-9]*$`, input.amount) + to_number(input.amount) <= max_amount +} + +destination_ok if input.destination in allowed_destinations diff --git a/examples/x402-policy-opa/policy/x402_test.rego b/examples/x402-policy-opa/policy/x402_test.rego new file mode 100644 index 0000000..ec47199 --- /dev/null +++ b/examples/x402-policy-opa/policy/x402_test.rego @@ -0,0 +1,27 @@ +package nirium.x402_test + +import data.nirium.x402 + +ok := {"amount": "5000000", "destination": "GMERCHANTALLOWED", "asset": "CUSDC", "network": "stellar:testnet", "agent": "GAGENT"} + +test_allow if x402.decision.allow with input as ok + +test_deny_over_cap if { + d := x402.decision with input as object.union(ok, {"amount": "10000001"}) + not d.allow + d.reason == "amount over per-payment cap" +} + +test_allow_at_cap if x402.decision.allow with input as object.union(ok, {"amount": "10000000"}) + +test_deny_destination if { + d := x402.decision with input as object.union(ok, {"destination": "GOTHER"}) + not d.allow + d.reason == "destination not in allowlist" +} + +test_deny_non_integer if not x402.decision.allow with input as object.union(ok, {"amount": "1.5"}) + +test_deny_zero if not x402.decision.allow with input as object.union(ok, {"amount": "0"}) + +test_deny_no_input if not x402.decision.allow diff --git a/examples/x402-policy-opa/src/demo.ts b/examples/x402-policy-opa/src/demo.ts new file mode 100644 index 0000000..8216342 --- /dev/null +++ b/examples/x402-policy-opa/src/demo.ts @@ -0,0 +1,21 @@ +// Demo against a running OPA (see README). No signer, fake contexts. +// OPA_URL=http://127.0.0.1:8181 npm start +import type { X402PolicyContext } from 'nirium'; +import { OpaPolicy } from './opa-policy.ts'; + +const ctx = (amount: string, to: string, nonce: string) => ({ + contextHash: `hash-${nonce}`, + signer: 'GAGENT', + requirements: { network: 'stellar:testnet' }, + authorization: { amount, to, contractId: 'CUSDC', nonce }, +}) as unknown as X402PolicyContext; + +const policy = new OpaPolicy({ url: process.env.OPA_URL ?? 'http://127.0.0.1:8181' }); +const signal = AbortSignal.timeout(2_000); +console.log('bundle revision:', await policy.currentVersion({ signal })); +for (const [amt, to, n] of [['5000000', 'GMERCHANTALLOWED', 'a'], ['20000000', 'GMERCHANTALLOWED', 'b'], ['5000000', 'GOTHER', 'c']] as const) { + const d = await policy.evaluate(ctx(amt, to, n), { signal }); + console.log(amt, to, '=>', d.decision, d.reason ?? '', d.policyVersion); +} +// Real use: +// agent.initX402({ signer, network: 'stellar:pubnet', policy: policy.hook() }); diff --git a/examples/x402-policy-opa/src/opa-policy.ts b/examples/x402-policy-opa/src/opa-policy.ts new file mode 100644 index 0000000..26df3ad --- /dev/null +++ b/examples/x402-policy-opa/src/opa-policy.ts @@ -0,0 +1,106 @@ +// Pre-sign policy hook backed by an Open Policy Agent server (#142). +// Reference example; see README for what it does NOT solve. +// +// Fail-closed by construction: anything other than a well-formed OPA answer +// throws, and the SDK turns a throw (or a timeout) into "nothing signed". +// Nothing here catches those errors. + +import type { X402PolicyContext, X402PolicyDecision, X402PolicyHook } from 'nirium'; + +export interface OpaPolicyConfig { + /** OPA base URL, e.g. http://127.0.0.1:8181 */ + url: string; + /** Data API path of the decision rule. Default 'nirium/x402/decision'. */ + path?: string; + /** Bundle name as OPA reports it in provenance (the --bundle argument). Default 'policy'. */ + bundle?: string; + /** How long an ALLOW stays valid, in ms. Keep it short. Default 2000. */ + ttlMs?: number; + /** Injectable fetch and clock, for tests. */ + fetch?: typeof fetch; + now?: () => number; +} + +/** What is sent to OPA as `input`. */ +export interface OpaInput { + amount: string; + destination: string; + asset: string; + network: string; + agent: string; +} + +export function toOpaInput(ctx: X402PolicyContext): OpaInput { + return { + amount: ctx.authorization.amount, + destination: ctx.authorization.to, + asset: ctx.authorization.contractId, + network: ctx.requirements.network, + agent: ctx.signer, + }; +} + +export class OpaPolicy { + private readonly cfg: Required>; + private readonly fetch: typeof fetch; + private readonly now: () => number; + + constructor(config: OpaPolicyConfig) { + this.cfg = { path: 'nirium/x402/decision', bundle: 'policy', ttlMs: 2_000, ...config }; + this.fetch = config.fetch ?? globalThis.fetch; + this.now = config.now ?? Date.now; + } + + private endpoint(): string { + return `${this.cfg.url.replace(/\/+$/, '')}/v1/data/${this.cfg.path}?provenance`; + } + + /** POST to the Data API; returns the parsed body or throws. */ + private async query(input: OpaInput | undefined, signal: AbortSignal): Promise<{ result: unknown; revision: string }> { + const res = await this.fetch(this.endpoint(), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input === undefined ? {} : { input }), + signal, + }); + if (res.status !== 200) throw new Error(`OPA answered HTTP ${res.status}`); + const body: any = await res.json(); // throws on non-JSON + const revision = body?.provenance?.bundles?.[this.cfg.bundle]?.revision; + if (typeof revision !== 'string' || revision === '') { + throw new Error(`OPA answer has no revision for bundle "${this.cfg.bundle}"`); + } + return { result: body.result, revision }; + } + + async evaluate(ctx: X402PolicyContext, opts: { signal: AbortSignal }): Promise { + const { result, revision } = await this.query(toOpaInput(ctx), opts.signal); + const r = result as { allow?: unknown; reason?: unknown } | undefined; + if (typeof r !== 'object' || r === null || typeof r.allow !== 'boolean') { + throw new Error('OPA result is not {allow: boolean}'); // undefined rule, wrong shape, ... + } + if (r.reason !== undefined && typeof r.reason !== 'string') throw new Error('OPA reason is not a string'); + if (!r.allow) return { decision: 'DENY', reason: (r.reason as string) ?? 'denied by OPA', policyVersion: revision }; + return { + decision: 'ALLOW', + contextHash: ctx.contextHash, // exact echo: binds this ALLOW to this authorization + policyVersion: revision, + expiresAt: this.now() + this.cfg.ttlMs, + decisionId: `${revision}:${ctx.authorization.nonce}`, + }; + } + + /** The bundle revision OPA has loaded right now. */ + async currentVersion(opts: { signal: AbortSignal }): Promise { + return (await this.query(undefined, opts.signal)).revision; + } + + /** The object to pass as `initX402({ policy })`. */ + hook(extra: Partial> = {}): X402PolicyHook { + return { + evaluate: (ctx, opts) => this.evaluate(ctx, opts), + currentVersion: (_ctx, opts) => this.currentVersion(opts), + requireExpiry: true, + ...extra, + }; + } +} diff --git a/examples/x402-policy-opa/test/hook-integration.test.ts b/examples/x402-policy-opa/test/hook-integration.test.ts new file mode 100644 index 0000000..5b465c8 --- /dev/null +++ b/examples/x402-policy-opa/test/hook-integration.test.ts @@ -0,0 +1,201 @@ +/** + * OpaPolicy wired into the SDK's REAL pre-sign gate: the same + * `createPolicyGatedSigner` that `initX402({ policy })` wraps around the + * signer (packages/sdk/src/index.ts). OPA is a mocked fetch: no network, + * no RPC, nothing broadcast. Each test builds the transfer auth entry that + * ExactStellarScheme would ask to sign and runs it through stellar-sdk's + * `authorizeEntry`, which is what `signAuthEntries` calls internally. + * + * The gate is not part of the public API (initX402 needs a real 402 server + * and RPC to reach it), so it is imported from the built SDK's dist. Build + * the SDK first (see README). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Keypair, Networks, xdr, Address, nativeToScVal, authorizeEntry, hash } from '@stellar/stellar-sdk'; +import { createPolicyGatedSigner, X402PolicyError } from 'nirium/dist/x402-policy.js'; +import type { X402PolicyHook, X402PolicyRecord } from 'nirium'; +import { OpaPolicy } from '../src/opa-policy.ts'; + +const payer = Keypair.random(); +const merchant = Keypair.random().publicKey(); +const USDC_TESTNET = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; +const OPA = 'http://opa.test:8181'; + +const reqs = (amount: string) => ({ + scheme: 'exact', network: 'stellar:testnet', asset: USDC_TESTNET, payTo: merchant, amount, maxTimeoutSeconds: 60, +}); + +let nonce = 1n; +function transferEntry(amount: string) { + return new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(new xdr.SorobanAddressCredentials({ + address: new Address(payer.publicKey()).toScAddress(), + nonce: xdr.Int64.fromString(String(nonce++)), + signatureExpirationLedger: 0, + signature: xdr.ScVal.scvVoid(), + })), + rootInvocation: new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(new xdr.InvokeContractArgs({ + contractAddress: new Address(USDC_TESTNET).toScAddress(), + functionName: 'transfer', + args: [ + nativeToScVal(payer.publicKey(), { type: 'address' }), + nativeToScVal(merchant, { type: 'address' }), + nativeToScVal(amount, { type: 'i128' }), + ], + })), + subInvocations: [], + }), + }); +} + +/** + * Mock OPA that evaluates the same rule as policy/x402.rego (cap 10_000_000, + * allowlist = [merchant]). `mode` switches failure behaviour per test. + */ +function mockOpa(opts: { + mode?: 'ok' | 'down' | 'http500' | 'malformed' | 'hang' | 'hangIgnoringAbort'; + revisions?: string[]; // revision reported on the 1st, 2nd, ... request +} = {}) { + const requests: any[] = []; + const fetch = (async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)); + requests.push(body); + const revs = opts.revisions ?? ['r1']; + const revision = revs[Math.min(requests.length - 1, revs.length - 1)]; + switch (opts.mode ?? 'ok') { + case 'down': throw new TypeError('fetch failed (ECONNREFUSED)'); + case 'http500': return new Response('boom', { status: 500 }); + case 'malformed': return Response.json({ provenance: { bundles: { policy: { revision } } }, result: { allow: 'yes' } }); + case 'hang': return new Promise((_, reject) => { + init.signal?.addEventListener('abort', () => reject(init.signal!.reason)); + }); + case 'hangIgnoringAbort': return new Promise(() => { /* never settles */ }); + } + const input = body.input; + let result: unknown; + if (input) { + const amountOk = /^[1-9][0-9]*$/.test(input.amount) && Number(input.amount) <= 10_000_000; + const destOk = input.destination === merchant; + result = !amountOk ? { allow: false, reason: 'amount over per-payment cap' } + : !destOk ? { allow: false, reason: 'destination not in allowlist' } : { allow: true }; + } + return Response.json({ provenance: { version: '1.22.0', bundles: { policy: { revision } } }, result }); + }) as unknown as typeof globalThis.fetch; + return { fetch, requests }; +} + +/** Runs one payment through the real gate. Returns outcome + whether the base signer was reached. */ +async function pay(hook: X402PolicyHook, amount = '5000000') { + const records: X402PolicyRecord[] = []; + let baseCalls = 0; + const base = { + address: payer.publicKey(), + signAuthEntry: async (preimageXdr: string) => { + baseCalls++; + const pre = xdr.HashIdPreimage.fromXDR(preimageXdr, 'base64'); + return { signedAuthEntry: payer.sign(hash(pre.toXDR())).toString('base64'), signerAddress: payer.publicKey() }; + }, + }; + const gated = createPolicyGatedSigner( + base, { ...hook, onDecision: (r) => records.push(r) }, + () => ({ url: 'https://api.example/paid', method: 'GET', requirements: reqs(amount) }), + ); + let outcome = 'SIGNED'; + let error: X402PolicyError | undefined; + try { + await authorizeEntry(transferEntry(amount), async (preimage: any) => { + const { signedAuthEntry } = await gated.signAuthEntry(preimage.toXDR('base64'), { address: payer.publicKey() }); + return Buffer.from(signedAuthEntry, 'base64'); + }, 1_000_000, Networks.TESTNET); + } catch (e) { + if (!(e instanceof X402PolicyError)) throw e; + outcome = e.outcome; + error = e; + } + return { outcome, baseCalls, records, error }; +} + +test('allow: OPA ALLOW passes every gate check and the base signer signs', async () => { + const { fetch, requests } = mockOpa(); + const r = await pay(new OpaPolicy({ url: OPA, fetch }).hook()); + assert.equal(r.outcome, 'SIGNED'); + assert.equal(r.baseCalls, 1); + assert.deepEqual(r.records.map((x) => x.outcome), ['ALLOWED', 'SIGNED']); + // OPA saw what is signed, decoded from the real preimage. + assert.deepEqual(requests[0].input, { + amount: '5000000', destination: merchant, asset: USDC_TESTNET, network: 'stellar:testnet', agent: payer.publicKey(), + }); + assert.equal(requests.length, 2); // decision + currentVersion +}); + +test('deny (over cap): no signature', async () => { + const { fetch } = mockOpa(); + const r = await pay(new OpaPolicy({ url: OPA, fetch }).hook(), '20000000'); + assert.equal(r.outcome, 'DENY'); + assert.equal(r.baseCalls, 0); + assert.match(r.error!.message, /amount over per-payment cap/); +}); + +for (const [mode, expected] of [ + ['down', 'ENGINE_ERROR'], ['http500', 'ENGINE_ERROR'], ['malformed', 'ENGINE_ERROR'], +] as const) { + test(`OPA ${mode}: ${expected}, no signature`, async () => { + const { fetch } = mockOpa({ mode }); + const r = await pay(new OpaPolicy({ url: OPA, fetch }).hook()); + assert.equal(r.outcome, expected); + assert.equal(r.baseCalls, 0); + }); +} + +test('slow OPA (honours abort): the hook deadline ends in TIMEOUT, no signature', async () => { + const { fetch } = mockOpa({ mode: 'hang' }); + const t0 = Date.now(); + const r = await pay(new OpaPolicy({ url: OPA, fetch }).hook({ timeoutMs: 150 })); + assert.equal(r.outcome, 'TIMEOUT'); + assert.equal(r.baseCalls, 0); + assert.ok(Date.now() - t0 < 2_000); +}); + +test('slow OPA (ignores abort): still TIMEOUT, no signature', async () => { + const { fetch } = mockOpa({ mode: 'hangIgnoringAbort' }); + const r = await pay(new OpaPolicy({ url: OPA, fetch }).hook({ timeoutMs: 150 })); + assert.equal(r.outcome, 'TIMEOUT'); + assert.equal(r.baseCalls, 0); +}); + +test('bundle revision changes between decision and signing: STALE, no signature', async () => { + const { fetch } = mockOpa({ revisions: ['r1', 'r2'] }); + const r = await pay(new OpaPolicy({ url: OPA, fetch }).hook()); + assert.equal(r.outcome, 'STALE'); + assert.equal(r.baseCalls, 0); + assert.match(r.error!.message, /issued under policy r1, current is r2/); +}); + +test('the gate itself rejects an ALLOW with a wrong contextHash (UNBOUND)', async () => { + const { fetch } = mockOpa(); + const hook = new OpaPolicy({ url: OPA, fetch }).hook(); + const tampered: X402PolicyHook = { ...hook, evaluate: async (c, o) => ({ ...(await hook.evaluate(c, o)), contextHash: 'other' }) }; + const r = await pay(tampered); + assert.equal(r.outcome, 'UNBOUND'); + assert.equal(r.baseCalls, 0); +}); + +test('the gate itself rejects an ALLOW without expiresAt (requireExpiry)', async () => { + const { fetch } = mockOpa(); + const hook = new OpaPolicy({ url: OPA, fetch }).hook(); + const noExpiry: X402PolicyHook = { + ...hook, evaluate: async (c, o) => { const d = await hook.evaluate(c, o); delete d.expiresAt; return d; }, + }; + const r = await pay(noExpiry); + assert.equal(r.outcome, 'EXPIRED'); + assert.equal(r.baseCalls, 0); +}); + +test('an ALLOW whose ttl already elapsed is refused (EXPIRED)', async () => { + const { fetch } = mockOpa(); + const r = await pay(new OpaPolicy({ url: OPA, fetch, ttlMs: 1, now: () => Date.now() - 10 }).hook()); + assert.equal(r.outcome, 'EXPIRED'); + assert.equal(r.baseCalls, 0); +}); diff --git a/examples/x402-policy-opa/test/opa-policy.test.ts b/examples/x402-policy-opa/test/opa-policy.test.ts new file mode 100644 index 0000000..840b36d --- /dev/null +++ b/examples/x402-policy-opa/test/opa-policy.test.ts @@ -0,0 +1,111 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { X402PolicyContext } from 'nirium'; +import { OpaPolicy } from '../src/opa-policy.ts'; + +const T0 = Date.UTC(2026, 10, 2, 12, 0, 0); +const URL = 'http://opa.test:8181'; +const sig = () => ({ signal: new AbortController().signal }); + +function ctx(amount = '5000000', to = 'GMERCHANTALLOWED', nonce = 'n1'): X402PolicyContext { + return { + contextVersion: 1, + url: 'https://api.example/paid', + method: 'GET', + requirements: { scheme: 'exact', network: 'stellar:testnet', asset: 'CUSDC', payTo: to, amount, maxTimeoutSeconds: 60 }, + authorization: { + preimageXdr: '', preimageType: 'legacy', payloadHash: `p-${nonce}`, networkPassphrase: 'Test SDF Network ; September 2015', + nonce, signatureExpirationLedger: 1, contractId: 'CUSDC', functionName: 'transfer', + from: 'GAGENT', to, amount, + }, + signer: 'GAGENT', + contextHash: `ctx-${nonce}`, + }; +} + +/** Mock OPA: no network. `answer` builds the response for each request. */ +function mockOpa(answer: (input: any) => Response | Promise) { + const calls: { url: string; input: any }[] = []; + const fetch = (async (url: string, init: RequestInit) => { + const input = JSON.parse(String(init.body)).input; + calls.push({ url, input }); + return answer(input); + }) as unknown as typeof globalThis.fetch; + return { fetch, calls }; +} + +const opaBody = (result: unknown, revision = 'r1') => + Response.json({ provenance: { version: '1.22.0', bundles: { policy: { revision } } }, result }); + +test('ALLOW echoes the exact contextHash, a short expiresAt and the bundle revision', async () => { + const { fetch, calls } = mockOpa(() => opaBody({ allow: true }, 'rev-7')); + const p = new OpaPolicy({ url: URL, fetch, ttlMs: 1500, now: () => T0 }); + const d = await p.evaluate(ctx(), sig()); + assert.equal(d.decision, 'ALLOW'); + assert.equal(d.contextHash, 'ctx-n1'); + assert.equal(d.expiresAt, T0 + 1500); + assert.equal(d.policyVersion, 'rev-7'); + assert.equal(calls[0].url, `${URL}/v1/data/nirium/x402/decision?provenance`); + assert.deepEqual(calls[0].input, { + amount: '5000000', destination: 'GMERCHANTALLOWED', asset: 'CUSDC', network: 'stellar:testnet', agent: 'GAGENT', + }); +}); + +test('DENY carries the OPA reason and no contextHash', async () => { + const { fetch } = mockOpa(() => opaBody({ allow: false, reason: 'destination not in allowlist' })); + const d = await new OpaPolicy({ url: URL, fetch }).evaluate(ctx('1', 'GOTHER'), sig()); + assert.equal(d.decision, 'DENY'); + assert.equal(d.reason, 'destination not in allowlist'); + assert.equal(d.contextHash, undefined); +}); + +test('OPA down: evaluate throws (the SDK signs nothing)', async () => { + const fetch = (async () => { throw new TypeError('fetch failed: ECONNREFUSED'); }) as unknown as typeof globalThis.fetch; + await assert.rejects(new OpaPolicy({ url: URL, fetch }).evaluate(ctx(), sig()), /ECONNREFUSED/); +}); + +test('non-200 from OPA throws', async () => { + const { fetch } = mockOpa(() => new Response('{"code":"internal_error"}', { status: 500 })); + await assert.rejects(new OpaPolicy({ url: URL, fetch }).evaluate(ctx(), sig()), /HTTP 500/); +}); + +for (const [name, res] of [ + ['not JSON', () => new Response('proxy error', { status: 200 })], + ['undefined rule (no result)', () => Response.json({ provenance: { bundles: { policy: { revision: 'r1' } } } })], + ['allow is a string', () => opaBody({ allow: 'true' })], + ['result is a bare boolean', () => opaBody(true)], + ['reason is not a string', () => opaBody({ allow: false, reason: 42 })], + ['no provenance', () => Response.json({ result: { allow: true } })], + ['revision for another bundle', () => Response.json({ provenance: { bundles: { other: { revision: 'r1' } } }, result: { allow: true } })], + ['empty revision', () => opaBody({ allow: true }, '')], +] as const) { + test(`malformed answer (${name}) throws, never ALLOW`, async () => { + const { fetch } = mockOpa(res); + await assert.rejects(new OpaPolicy({ url: URL, fetch }).evaluate(ctx(), sig())); + }); +} + +test('stale revision: a bundle update between decision and signing fails closed', async () => { + let revision = 'r1'; + const { fetch, calls } = mockOpa(() => opaBody({ allow: true }, revision)); + const hook = new OpaPolicy({ url: URL, fetch }).hook(); + const c = ctx(); + const d = await hook.evaluate(c, sig()); + assert.equal(d.decision, 'ALLOW'); + assert.equal(await hook.currentVersion!(c, sig()), d.policyVersion, 'unchanged bundle: versions match'); + revision = 'r2'; // OPA loads a new bundle before signing + const live = await hook.currentVersion!(c, sig()); + assert.notEqual(live, d.policyVersion, 'the SDK compares these and refuses with STALE'); + assert.equal(calls[1].input, undefined, 'currentVersion sends no payment input'); + assert.equal(hook.requireExpiry, true); +}); + +test('slow OPA: the abort signal from the SDK deadline cancels the request', async () => { + const fetch = ((_u: string, init: RequestInit) => new Promise((_res, rej) => { + init.signal!.addEventListener('abort', () => rej(init.signal!.reason)); + })) as unknown as typeof globalThis.fetch; + const ac = new AbortController(); + const pending = new OpaPolicy({ url: URL, fetch }).evaluate(ctx(), { signal: ac.signal }); + ac.abort(new Error('policy deadline')); + await assert.rejects(pending, /policy deadline/); +}); diff --git a/examples/x402-policy-opa/tsconfig.json b/examples/x402-policy-opa/tsconfig.json new file mode 100644 index 0000000..dacb45e --- /dev/null +++ b/examples/x402-policy-opa/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}