diff --git a/examples/supervisor-middleware-payment-gate/.gitignore b/examples/supervisor-middleware-payment-gate/.gitignore new file mode 100644 index 0000000000..f06235c460 --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/examples/supervisor-middleware-payment-gate/PR.md b/examples/supervisor-middleware-payment-gate/PR.md new file mode 100644 index 0000000000..56320993b1 --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/PR.md @@ -0,0 +1,36 @@ +Title: Add supervisor middleware example: payment gate for agents that move money + +Branch: examples/supervisor-middleware-payment-gate + +Commit message (sign with -s from the Axiru GitHub identity): + + examples: add supervisor middleware payment gate + + Adds a self-contained supervisor middleware example for sandboxes whose + agents hold Stripe credentials. Network policy decides whether the + sandbox may reach api.stripe.com; this middleware decides whether a + specific money-moving call should happen, in the PRE_CREDENTIALS phase. + + It parses refunds, credits, transfers, payouts, dispute updates and + issuing approvals from the request body and evaluates a deterministic + policy: per-transfer ceiling, hold threshold, daily cap per sandbox, + duplicate window, optional counterparty allowlist. On allow it writes a + Stripe Idempotency-Key bound to the decision id so a retry cannot become + a second refund. On repeated denials it emits a quarantine_recommended + finding. It fails closed. Protos are vendored from proto/ at this commit. + + Signed-off-by: Axiru + +PR description: + +This example shows a supervisor middleware for agents that hold Stripe credentials. OpenShell already controls whether the sandbox may reach api.stripe.com. This middleware decides whether a specific money-moving call should happen: it parses refunds, credits, transfers, and payouts from the request body, evaluates a deterministic policy (per-transfer ceiling, hold threshold, daily cap per sandbox, duplicate window, counterparty allowlist), and returns DECISION_ALLOW or DECISION_DENY in the PRE_CREDENTIALS phase. On allow it writes a Stripe Idempotency-Key tied to the decision so a retry cannot become a second refund. On repeated denials it emits a quarantine_recommended finding. It fails closed. + +It is included because payment tools are where an agent mistake becomes a loss with no attacker involved (a parsing error that refunds the whole balance, a duplicate refund after a retry), and the sandbox boundary alone does not see the amount. The evaluator is a pure function with no model in the decision path, so decisions replay bit for bit. + +The example is self-contained (Node.js, two runtime dependencies for gRPC) and mirrors the layout of supervisor-middleware-content-guard. Tests cover the parser, pass-through, allow with idempotency pinning, duplicate denial, unspecified amount, and the quarantine signal. Protos are vendored from proto/ at this commit. + +Checklist before opening: +- Read CONTRIBUTING.md and STYLEGUIDE.md; add SPDX headers to src files if maintainers require them on examples (README already has one). +- Run the local gateway smoke path from the content guard README with this service on port 50052 and this policy.yaml; paste the gateway log lines for one allow and one deny into the PR. +- Commit with -s from the Axiru account. Do not sign with a personal name until after 19 Oct 2026. +- Open the PR from a fork under the AxiruAI org. diff --git a/examples/supervisor-middleware-payment-gate/README.md b/examples/supervisor-middleware-payment-gate/README.md new file mode 100644 index 0000000000..853cba9bf2 --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/README.md @@ -0,0 +1,92 @@ + + +# Supervisor Middleware Payment Gate + +> [!WARNING] +> Supervisor middleware is a research preview. Its policy and service contracts may change without compatibility guarantees. Use it only to prototype and evaluate middleware integrations. + +This example is a supervisor middleware for sandboxes whose agents hold Stripe credentials. OpenShell network policy decides whether the sandbox may reach `api.stripe.com` at all. This middleware decides whether a specific money-moving call should happen. + +It parses refunds, customer credits, transfers, payouts, dispute updates, and issuing approvals out of the Stripe request body, evaluates a deterministic policy (per-transfer ceiling, hold threshold, daily cap per sandbox, duplicate window for the same customer and charge, optional counterparty allowlist), and returns `DECISION_ALLOW` or `DECISION_DENY` in the `PRE_CREDENTIALS` phase. Reads and non-money endpoints pass through. + +On allow it writes a Stripe `Idempotency-Key` bound to the decision id, so a retry of the same decision cannot become a second refund. On deny it returns a reason code and a one-sentence rationale. A refund with no `amount` is a full refund in Stripe's API; the middleware denies it and asks for an explicit amount. After three denials from one sandbox in 24 hours it adds a `quarantine_recommended` finding with severity `critical`. Any internal error is a deny, and `policy.yaml` sets `on_error: fail_closed`. + +The evaluator is a pure function: policy, intent, prior decisions, and a timestamp in; verdict, reason codes, and a SHA-256 fingerprint out. There is no model in the decision path and the agent's free-text reason is recorded but never evaluated, so the decision cannot be talked into anything by a prompt. Same inputs, same fingerprint. + +Why a payment gate belongs here: most agent money losses have no attacker. A parsing bug that refunds a whole balance, a duplicate refund after a retry that looked like a failure, a loop of small refunds. The sandbox boundary sees the host; it does not see the amount. This example adds the amount. + +## Prerequisites + +Node.js 20 or later on the host for the middleware service. For the smoke run, the same prerequisites as the content guard example: `cargo`, `curl`, `jq`, `mise`, Docker or Podman, and the repository's mise tools. + +## Run the service + +```shell +cd examples/supervisor-middleware-payment-gate +npm install && npm run build +AXIRU_MW_BIND=0.0.0.0:50052 npm start +``` + +Bind to all host interfaces so a local containerized gateway and sandbox supervisor can reach it. + +Add the service registration to your local gateway TOML (see `gateway.toml.snippet`): + +```toml +[[openshell.supervisor.middleware]] +name = "axiru-payment-gate" +grpc_endpoint = "http://host.openshell.internal:50052" +allow_insecure_transport = true +max_payload_bytes = 262144 +timeout = "2s" +``` + +Create a sandbox with the included policy: + +```shell +openshell sandbox create --name support-agent --policy examples/supervisor-middleware-payment-gate/policy.yaml +``` + +The policy allows only the listed Stripe money-moving endpoints and reads, routes every allowed call through the middleware, and fails closed. Adjust the thresholds under `network_middlewares.axiru-payment-gate.config` (values are minor units, so `50000` is 500.00 USD) and list the binaries your agent actually uses. + +## Try it from inside the sandbox + +Inside the sandbox, with a Stripe test key available to the agent: + +```shell +# In policy: 45.00 USD refund, first one on this charge. Expect a normal Stripe response. +curl -s https://api.stripe.com/v1/refunds -u "$STRIPE_KEY": -d charge=ch_test_1 -d amount=4500 -d customer=cus_test_1 + +# Same charge, same customer, inside the 30-day duplicate window. Expect the gateway to deny with reason_code AXIRU_DENY. +curl -s https://api.stripe.com/v1/refunds -u "$STRIPE_KEY": -d charge=ch_test_1 -d amount=4500 -d customer=cus_test_1 + +# 250,000.00 USD transfer. Expect a deny with AMOUNT_EXCEEDS_TRANSFER_CEILING in the audit log metadata. +curl -s https://api.stripe.com/v1/transfers -u "$STRIPE_KEY": -d amount=25000000 -d currency=usd -d destination=acct_test_x +``` + +Every evaluation lands in the gateway audit log with `axiru.decision_id`, `axiru.verdict`, `axiru.policy`, `axiru.fingerprint`, and `axiru.reason_codes` metadata, and a finding of type `axiru.decision`. + +## Tests + +```shell +npm test +``` + +Covers the Stripe parser, pass-through for non-Stripe hosts, allow with idempotency pinning, duplicate denial inside the window, denial of an unspecified amount, and the quarantine signal after repeated denials. + +## Layout + +- `src/gate.ts`: types, the pure evaluator, and a small `Gate` class with a session ledger and optional hosted mode. +- `src/stripe.ts`: maps Stripe API requests to payment intents. +- `src/middleware.ts`: the evaluation logic, separated from gRPC so it can be unit tested. +- `src/server.ts`, `src/cli.ts`: the `openshell.middleware.v1.SupervisorMiddleware` gRPC service. +- `proto/`: vendored from this repository's `proto/` directory at the commit this example was added. +- `policy.yaml`: reference sandbox policy. + +## Limits + +The local evaluator cannot see the original charge amount from a refund request, so the cumulative rule "refunds never exceed the charge" is only enforced when the middleware runs in hosted mode (set `AXIRU_API_KEY`) or when a Stripe read is placed in front of it. Only Stripe is mapped; adding a rail is one regex and one field mapping in `src/stripe.ts`. This example handles form-encoded and JSON request bodies for the listed endpoints and is not a general payments proxy. + +A maintained version of this middleware, plus the same gate as an MCP server and as plugins for other agent runtimes, lives at https://github.com/AxiruAI/axiru-gate. diff --git a/examples/supervisor-middleware-payment-gate/gateway.toml.snippet b/examples/supervisor-middleware-payment-gate/gateway.toml.snippet new file mode 100644 index 0000000000..76d9c418d1 --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/gateway.toml.snippet @@ -0,0 +1,8 @@ +# Add to the OpenShell gateway TOML to register the Axiru middleware service. +# Run the service first: AXIRU_MW_BIND=0.0.0.0:50052 npx @axiru/openshell-middleware +[[openshell.supervisor.middleware]] +name = "axiru-payment-gate" +grpc_endpoint = "http://host.openshell.internal:50052" +allow_insecure_transport = true # local only; use TLS in production +max_payload_bytes = 262144 +timeout = "2s" diff --git a/examples/supervisor-middleware-payment-gate/package-lock.json b/examples/supervisor-middleware-payment-gate/package-lock.json new file mode 100644 index 0000000000..2a6b11837f --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/package-lock.json @@ -0,0 +1,1806 @@ +{ + "name": "openshell-example-payment-gate", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openshell-example-payment-gate", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.4", + "@grpc/proto-loader": "^0.7.13" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.6.3", + "vitest": "^2.1.8" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "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/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/examples/supervisor-middleware-payment-gate/package.json b/examples/supervisor-middleware-payment-gate/package.json new file mode 100644 index 0000000000..3305c3edbd --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/package.json @@ -0,0 +1,23 @@ +{ + "name": "openshell-example-payment-gate", + "private": true, + "version": "0.1.0", + "description": "OpenShell supervisor middleware example: a deterministic payment gate for agents that move money through Stripe.", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/cli.js", + "test": "vitest run" + }, + "dependencies": { + "@grpc/grpc-js": "^1.12.4", + "@grpc/proto-loader": "^0.7.13" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.6.3", + "vitest": "^2.1.8" + }, + "engines": { "node": ">=20" }, + "license": "Apache-2.0" +} diff --git a/examples/supervisor-middleware-payment-gate/policy.yaml b/examples/supervisor-middleware-payment-gate/policy.yaml new file mode 100644 index 0000000000..5694d12170 --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/policy.yaml @@ -0,0 +1,71 @@ +# Axiru payment gate for NVIDIA OpenShell +# Reference sandbox policy for agents that move money through Stripe. +# +# Two layers: +# 1. network_policies: the agent can reach api.stripe.com only for the listed calls. +# Everything else on Stripe is denied by default. Wallet RPCs and x402 endpoints are not listed, so they are unreachable. +# 2. network_middlewares: every allowed Stripe request passes through the Axiru middleware, which reads the amount, +# charge, and counterparty, evaluates policy, and returns allow or deny. on_error: fail_closed means an unreachable +# gate is a denied payment, never an allowed one. +# +# The agent does not have to call anything. It cannot skip the check because the sandbox routes the call through it. +# +# openshell sandbox create --name support-agent --policy policy.yaml + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/bin, /usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +network_middlewares: + axiru-payment-gate: + name: Axiru payment gate + middleware: axiru-payment-gate + order: 10 + on_error: fail_closed + config: + policy_id: support-agent-stripe + per_transfer_ceiling_minor: 50000 + hold_above_minor: 10000 + daily_cap_per_agent_minor: 100000 + duplicate_window_days: 30 + endpoints: + include: + - api.stripe.com + +network_policies: + stripe-money-moves: + name: Stripe money-moving calls, gated by Axiru + endpoints: + - host: api.stripe.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: POST + path: /v1/refunds + - allow: + method: POST + path: /v1/charges/*/refunds + - allow: + method: POST + path: /v1/customers/*/balance_transactions + - allow: + method: POST + path: /v1/transfers + - allow: + method: POST + path: /v1/payouts + - allow: + method: GET + path: /v1/* + binaries: + - path: /usr/bin/curl + - path: /usr/bin/node + - path: /usr/bin/python3 diff --git a/examples/supervisor-middleware-payment-gate/proto/extension.proto b/examples/supervisor-middleware-payment-gate/proto/extension.proto new file mode 100644 index 0000000000..7c505f1ae3 --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/proto/extension.proto @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.extension.v1; + +// Version of an OpenShell extension protocol contract. +// +// The major version changes when peers cannot safely interoperate. Minor +// versions add optional fields or capabilities and remain compatible when both +// peers' required capabilities are satisfied. +message ProtocolVersion { + uint32 major = 1; + uint32 minor = 2; +} + +// Non-secret metadata exchanged by the gateway and an extension before use. +message PeerMetadata { + // Extension-family protocol contract version, distinct from the build. + ProtocolVersion protocol_version = 1; + + // Stable human-readable implementation identity, such as "openshell/docker". + string implementation_name = 2; + + // Implementation or build version used only for diagnostics. + string implementation_version = 3; + + // Optional namespaced capabilities this peer understands. + repeated string supported_capabilities = 4; + + // Capabilities that the opposite peer must advertise. + repeated string required_capabilities = 5; +} diff --git a/examples/supervisor-middleware-payment-gate/proto/options.proto b/examples/supervisor-middleware-payment-gate/proto/options.proto new file mode 100644 index 0000000000..7669e2fe1f --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/proto/options.proto @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.options.v1; + +import "google/protobuf/descriptor.proto"; + +// Per-method authorization rule. Consumed at runtime by the gateway's +// descriptor-pool-based auth table to enforce auth mode, role, and scope. +message AuthorizationRule { + // Authentication mode: "bearer", "sandbox", "dual", or "unauthenticated". + string auth_mode = 1; + // Minimum workspace-level role required (checked by handler via + // authorize_workspace): "user" or "admin". Mutually exclusive with + // global_role. + string workspace_role = 2; + // Global role required (checked by middleware via OIDC claims): + // "platform_admin". Mutually exclusive with workspace_role. + string global_role = 3; + // Required OIDC scope on the bearer path (e.g. "sandbox:read"). + string scope = 4; +} + +extend google.protobuf.MethodOptions { + // Authorization metadata for a gRPC method. + AuthorizationRule authorization = 50000; +} + +// Marks a protobuf field whose value must not cross generic observation or +// extension boundaries such as gateway interceptors. +extend google.protobuf.FieldOptions { + bool secret = 50001; +} diff --git a/examples/supervisor-middleware-payment-gate/proto/supervisor_middleware.proto b/examples/supervisor-middleware-payment-gate/proto/supervisor_middleware.proto new file mode 100644 index 0000000000..52793d905f --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/proto/supervisor_middleware.proto @@ -0,0 +1,720 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.middleware.v1; + +import "google/protobuf/struct.proto"; +import "google/protobuf/duration.proto"; +import "extension.proto"; + +// SupervisorMiddleware discovers and configures one operator-run middleware. +// It evaluates HTTP requests and WebSocket messages before credentials. +// Phase-specific services share the same registration. +service SupervisorMiddleware { + // Describe returns the service manifest and declared bindings. + rpc Describe(MiddlewareDescribeRequest) returns (MiddlewareManifest); + + // ValidateConfig checks service-specific configuration for one binding. + rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse); + + // EvaluateHttpRequest returns an allow, deny, or mutation decision for one + // buffered HTTP request. + rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); + + // EvaluateWebSocketSession opens one ordered, phase-specific stream for a + // single middleware stage and WebSocket upgrade attempt. The current + // implementation supports client-to-upstream text messages at + // PRE_CREDENTIALS; PRE_RETURN is reserved for upstream-to-client messages. + // A request may go unanswered when the session terminates. For every opened + // stage stream, OpenShell attempts at most one session_end before closing the + // stream when its transport is still writable. + rpc EvaluateWebSocketSession(stream WebSocketSessionEvent) + returns (stream WebSocketSessionEventResult); +} + +message MiddlewareDescribeRequest { + // Gateway or supervisor protocol metadata. Middleware must reject unmet + // requirements before accepting traffic. + openshell.extension.v1.PeerMetadata gateway = 1; +} + +// HttpResponsePreReturn evaluates one response for one middleware stage before +// OpenShell returns it to the sandbox. +service HttpResponsePreReturn { + // Evaluate starts with preflight and may continue with selected body units + // and trailers. A body unit marked end_of_stream ends body inspection, not + // the event stream. Trailers and one best-effort session_end may follow. + rpc Evaluate(stream HttpResponseEvent) + returns (stream HttpResponseEventResult); +} + +// MiddlewareManifest describes one middleware service and its bindings. +message MiddlewareManifest { + // Human-readable middleware service name used only for diagnostics. This is + // not required to match an operator-owned registration name. + string name = 1; + // Deprecated diagnostic compatibility field. Use + // extension.implementation_version. + string service_version = 2; + // Bindings exposed by this middleware service. + repeated MiddlewareBinding bindings = 3; + // Exact JWT audience this service verifies on inbound OpenShell calls. + // After authenticated Describe succeeds, OpenShell rejects the registration + // unless this matches the operator-configured audience. A strict verifier may + // reject an incorrect audience before returning this manifest. Empty skips + // this post-authentication consistency check. + string expected_audience = 4; + // Supervisor-middleware protocol metadata. Required for negotiation. + openshell.extension.v1.PeerMetadata extension = 5; +} + +// MiddlewareBinding declares one operation and phase supported by a service. +message MiddlewareBinding { + reserved 4; + reserved "timeout"; + // Supported operation. + SupervisorMiddlewareOperation operation = 1; + // Supported phase. + SupervisorMiddlewarePhase phase = 2; + // Maximum request body, WebSocket message, or response body unit/replacement. + // Required for payload-bearing operations. + uint64 max_payload_bytes = 3; + // Optional binding-specific RPC timeout. Empty uses the operator-configured + // service timeout, or the 500ms platform default when that is also omitted. + // A non-empty value may shorten but cannot extend the operator timeout. + // Values must be between 10ms and 30s. + google.protobuf.Duration request_timeout = 104; +} + +// ValidateConfigRequest contains one policy configuration to validate. +message ValidateConfigRequest { + // Service-specific policy configuration. + google.protobuf.Struct config = 1; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 2; +} + +// ValidateConfigResponse reports whether a policy configuration is accepted. +message ValidateConfigResponse { + // True when the service accepts the configuration. + bool valid = 1; + // Human-readable validation failure reason. Empty when valid is true. + string reason = 2; +} + +// HttpRequestEvaluation contains one buffered HTTP request to evaluate. +message HttpRequestEvaluation { + // Evaluation phase selected for this request. + SupervisorMiddlewarePhase phase = 1; + // Sandbox and request identity available to the supervisor. + // The encoded context is limited to 4 KiB. + RequestContext context = 2; + // Validated service-specific policy configuration. + // The encoded configuration is limited to 64 KiB. + google.protobuf.Struct config = 3; + // Destination and HTTP request target. + // The encoded target is limited to 32 KiB. + HttpRequestTarget target = 4; + // HTTP request headers before OpenShell injects credentials, in wire + // order. Repeated header names are preserved as separate entries. Protected + // credential, routing, framing, and hop-by-hop headers are omitted. + // At most 128 lines and 64 KiB of encoded headers are included. + repeated HttpHeader headers = 5; + // Buffered request body, limited to 4 MiB. Empty for a bodyless request. + bytes body = 6; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 7; +} + +// HttpHeader is one HTTP header line. +message HttpHeader { + // Lowercased header name. + string name = 1; + // Header value with surrounding whitespace trimmed. + string value = 2; +} + +// One ordered response event. A stream starts with preflight, may continue with +// body units ending in end_of_stream, may then include trailers, and may end +// with one best-effort session_end. +message HttpResponseEvent { + oneof event { + // Initial response head and request context. + HttpResponsePreflight preflight = 1; + // Next normalized body unit. + HttpResponseBodyUnit body = 2; + // Normalized trailers after the final body result. + HttpResponseTrailers trailers = 4; + // Optional terminal notification. + MiddlewareSessionEnd session_end = 3; + } +} + +// Each preflight, body, and trailers event requires one ordered result. +// session_end has no result. +message HttpResponseEventResult { + oneof result { + // Result for preflight. + HttpResponsePreflightResult preflight_result = 1; + // Result for the next body unit. + HttpResponseBodyResult body_result = 2; + // Result for response trailers. + HttpResponseTrailersResult trailers_result = 3; + } +} + +// HttpResponsePreflight exposes the current final response head to one stage. +message HttpResponsePreflight { + // Request identity. request_id links request and response evaluations. + // Limited to 4 KiB encoded. + RequestContext context = 1; + // Admitted request target with a redacted query. Limited to 32 KiB encoded. + HttpRequestTarget target = 2; + // Final non-informational upstream status. Upgrades are not evaluated. + uint32 status_code = 3; + // Response headers after prior stages, in wire order. Repeated names remain + // separate. Credential, routing, and hop-by-hop headers are omitted. + // Content-Length, Content-Encoding, and Content-Range retain their read-only + // upstream values. OpenShell may recompute or remove Content-Length later. + // Limited to 128 lines and 64 KiB encoded. + repeated HttpHeader headers = 4; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 5; + // Validated service configuration. Limited to 64 KiB encoded. + google.protobuf.Struct config = 6; + // Effective minimum of platform, registration, and binding limits. Applies to + // whole-body input/replacement and each stream input/replacement. Stream + // inputs use at most min(64 KiB, max_payload_bytes). + uint64 max_payload_bytes = 7; + // Modes derived independently for this stage. OpenShell first determines + // response-shape eligibility from the original final response head, then + // applies this stage's effective max_payload_bytes. Different stages may + // receive different lists. HEADERS_ONLY is always present and is the only + // mode for bodyless, partial, encoded, or no-transform responses. For an + // otherwise eligible response, a known body larger than this stage's limit + // omits WHOLE_BODY_BYTES. An eligible unknown-length response may select + // WHOLE_BODY_BYTES and later fail with whole_body_over_capacity according to + // this stage's on_error. STREAM_BYTES is omitted when + // max_payload_bytes is zero. Selecting an unlisted mode fails according to + // on_error. + repeated HttpResponseBodyMode permitted_body_modes = 8; +} + +// Selects skip, inspect, or block. Diagnostic fields apply to every action. +// Invalid diagnostics make the entire result a middleware failure handled +// according to on_error. +message HttpResponsePreflightResult { + oneof action { + // Deliver unchanged without invoking on_error. + HttpResponsePreflightSkip skip = 1; + // Inspect with the selected body mode and mutations. + HttpResponsePreflightInspect inspect = 2; + // Prevent delivery to the sandbox. + HttpResponseBlockDelivery block_delivery = 7; + } + // Service diagnostic, never sent to the sandbox or security logs. Maximum + // 4 KiB. + string reason = 3; + // Optional audit code using the HttpRequestResult.reason_code format and + // 64-byte maximum. Returned to the sandbox only for block_delivery. + string reason_code = 4; + // Up to 32 audit-safe findings, each limited to 4 KiB encoded. + repeated Finding findings = 5; + // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. + map metadata = 6; +} + +// Ends this stage successfully without body inspection. +message HttpResponsePreflightSkip {} + +// Blocks delivery as a successful decision regardless of on_error. OpenShell +// evaluates results in policy order. Once it accepts a valid block, it stops +// later middleware evaluation and ends every still-writable opened stage with +// MIDDLEWARE_DENIAL. A failure handled earlier may already have stopped +// evaluation, so a later block does not override it. An invalid block result +// is a middleware failure handled according to on_error. The upstream request +// has already run; blocking its response does not reject or roll back that +// request. +// +// Before response commitment, including at preflight and during +// WHOLE_BODY_BYTES, OpenShell replaces the upstream response with the canonical +// 403 Forbidden middleware-denial response. Its JSON body has +// error = "middleware_denied" and includes a validated reason_code when the +// result supplies one. OpenShell never returns the free-form reason or writes it +// to security logs. For HEAD, OpenShell sends the canonical response headers +// and Content-Length but no body. It closes the downstream connection after the +// denial response. +// +// After response commitment, including during STREAM_BYTES, OpenShell aborts +// downstream delivery. It does not inject an error body, a terminating chunk, +// or an error trailer. OpenShell does not reuse the upstream connection. +message HttpResponseBlockDelivery {} + +// Selects body inspection and response-header mutations. +message HttpResponsePreflightInspect { + // Required mode from permitted_body_modes. Invalid values fail according to + // on_error. + HttpResponseBodyMode body_mode = 1; + // Ordered mutations applied atomically before the next stage. Only visible + // end-to-end headers may change. Routing, credential, framing, coding, range, + // and hop-by-hop headers are protected; integrity headers may only be removed. + // Limited to 64 operations, 32 KiB of name/value data, and 64 KiB encoded. + repeated HeaderMutation header_mutations = 2; +} + +// Controls which response-body units a stage receives. +enum HttpResponseBodyMode { + // Invalid value handled according to on_error. + HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0; + // Inspect only the response head. + HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; + // Buffer the normalized body as one final unit before committing the head. + // Input and replacement must fit max_payload_bytes. Capacity failures use + // whole_body_over_capacity and follow this stage's on_error. + HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; + // Receive normalized units ending with end_of_stream. Each input is at most + // min(64 KiB, max_payload_bytes), and each replacement must fit + // max_payload_bytes. Each result fully accounts for its input unit; V1 does + // not permit retaining input across units. The full body may exceed the + // limit. STREAM_BYTES has no total response-lifetime deadline. + HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; +} + +// One normalized body unit. Boundaries have no transport or application +// meaning. +message HttpResponseBodyUnit { + // Contiguous and stage-local, starting at 1. + uint64 sequence = 1; + oneof payload { + // Bytes without transfer framing. A body-capable response with no body bytes + // has present empty data in sequence 1. STREAM_BYTES input size is at most + // min(64 KiB, max_payload_bytes). A unit may be shorter to preserve + // flushing. + bytes data = 2; + } + // Marks the final body unit. Every normally completed body inspection receives + // exactly one. For a body-capable response with no body bytes, this is the + // empty sequence-1 unit. OpenShell does not read ahead, so it may send an empty + // final unit after the last nonempty unit. Trailers and session_end may + // follow. A stage ended by skip_remaining, block, or failure receives no + // later final unit. + bool end_of_stream = 3; +} + +// Result for one body unit. Units are processed in lockstep; V1 does not +// support ownership transfer or cross-unit retention. Diagnostic fields apply +// to every action. Invalid diagnostics make the entire result a middleware +// failure handled according to on_error. OpenShell retains the current input +// until it validates the result, so fail-open can continue from the last input +// OpenShell still owns. +message HttpResponseBodyResult { + // Must match the next unit. Zero, gaps, duplicates, and regressions fail. + uint64 sequence = 1; + // Exactly one explicit action is required. + oneof action { + // Forward the input unit unchanged. + HttpResponseBodyPassThrough pass_through = 2; + // Replace the complete input unit. + HttpResponseBodyTransform transform = 3; + // Stop delivery. See HttpResponseBlockDelivery. + HttpResponseBlockDelivery block_delivery = 8; + // Finalize this unit and stop inspecting. + HttpResponseBodySkipRemaining skip_remaining = 9; + } + // Service diagnostic, never sent to the sandbox or security logs. Maximum + // 4 KiB. + string reason = 4; + // Optional audit code using the HttpRequestResult.reason_code format and + // 64-byte maximum. When OpenShell accepts block_delivery before response + // commitment, it includes this code in the canonical denial response. It is + // never returned after commitment. + string reason_code = 5; + // Up to 32 audit-safe findings, each limited to 4 KiB encoded. + repeated Finding findings = 6; + // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. + map metadata = 7; +} + +// Preserves the input unit. +message HttpResponseBodyPassThrough {} + +// Finalizes this unit and ends the stage. This stage receives no later body or +// trailer events. The current and later units continue through other stages. +// For WHOLE_BODY_BYTES, this equals its nested action. +message HttpResponseBodySkipRemaining { + // Exactly one action for the current unit. + oneof current { + // Forward the current unit unchanged. + HttpResponseBodyPassThrough pass_through = 1; + // Replace the current unit. + HttpResponseBodyTransform transform = 2; + } +} + +// Replaces the complete input unit. +message HttpResponseBodyTransform { + // Required replacement, limited to max_payload_bytes. Present empty data + // deletes the input unit. The replacement fully accounts for this input unit; + // middleware must not retain input bytes for a later unit in V1. + oneof replacement { + // Normalized replacement bytes. + bytes data = 1; + } +} + +// The current normalized response trailers in wire order. Repeated names stay +// as separate fields. A stage that completes WHOLE_BODY_BYTES or STREAM_BYTES +// receives exactly one trailers event after its final body result, including +// when this set is empty. SKIP, HEADERS_ONLY, semantically bodyless responses, +// and stages ended by block, failure, or skip_remaining receive no trailers. +message HttpResponseTrailers { + repeated HttpHeader headers = 1; +} + +// Applies ordered trailer mutations atomically. An empty mutation list +// preserves the current trailers. A write may target only a case-insensitive +// name present in the trailers event; V1 cannot create a trailer name. Removal +// of an absent name is a no-op. Credential, routing, framing, coding, range, +// hop-by-hop, and connection-nominated fields are protected. Diagnostic fields +// apply whether mutations are empty or nonempty. Invalid diagnostics or +// mutations make the entire result a middleware failure handled according to +// on_error. +message HttpResponseTrailersResult { + // At most 64 operations, 32 KiB of validated name/value data, and 64 KiB + // encoded are accepted. + repeated HeaderMutation trailer_mutations = 1; + // Service diagnostic, never sent to the sandbox or security logs. Maximum + // 4 KiB. + string reason = 2; + // Optional audit code using the HttpRequestResult.reason_code format and + // 64-byte maximum. Never sent to the sandbox. + string reason_code = 3; + // Up to 32 audit-safe findings, each limited to 4 KiB encoded. + repeated Finding findings = 4; + // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. + map metadata = 5; +} + +// Stable reason OpenShell ended a middleware stage stream. +enum MiddlewareSessionEndReason { + // Invalid reason. + MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED = 0; + // Evaluation completed. + MIDDLEWARE_SESSION_END_REASON_NORMAL = 1; + // The sandbox peer disconnected. + MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT = 2; + // A policy reload replaced the active middleware chain. + MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD = 3; + // A stage denied the operation or blocked the response. + MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; + // A selected stage failed. + MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; + // A proxied or middleware protocol was violated. + MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR = 6; + // Evaluation was canceled for another reason. + MIDDLEWARE_SESSION_END_REASON_CANCELLATION = 7; + // Upstream rejected or failed before a valid response or upgrade. + MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE = 8; + // Network policy denied the operation. + MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL = 9; + // The stage successfully declined inspection during preflight. + MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED = 10; + // Upstream disconnected after a valid response or upgrade. + MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT = 11; +} + +// Best-effort terminal notification. A stage receives at most one and sends no +// result. +message MiddlewareSessionEnd { + // Terminal reason. Producers never send UNSPECIFIED. + MiddlewareSessionEndReason reason = 1; + // Set only for PROTOCOL_ERROR. Missing or unknown details mean a generic + // protocol error. + MiddlewareSessionProtocolError protocol_error = 2; +} + +// Details for a protocol-error session end. +message MiddlewareSessionProtocolError { + oneof domain { + // WebSocket protocol violation. + WebSocketProtocolError web_socket = 1; + // Middleware event/result protocol violation. + MiddlewareExchangeProtocolError middleware_exchange = 2; + } +} + +// WebSocket protocol error details, reserved for future categories. +message WebSocketProtocolError {} + +// Middleware exchange error details, reserved for future categories. +message MiddlewareExchangeProtocolError {} + +// Supervisor operation selected for middleware evaluation. +enum SupervisorMiddlewareOperation { + SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; + SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; + SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE = 2; + SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE = 3; +} + +// Ordered phase within a supervisor operation. +enum SupervisorMiddlewarePhase { + SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED = 0; + SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS = 1; + SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN = 2; +} + +// WebSocketSessionEvent is one ordered event in a stage-local stream. +// Message sequence numbers identify logical messages session-wide. A stage +// receives a strictly increasing subset of those numbers; gaps are valid when +// session messages are not delivered to that stage. +message WebSocketSessionEvent { + oneof event { + WebSocketPreflight preflight = 1; + WebSocketSessionStart session_start = 2; + WebSocketMessage message = 3; + MiddlewareSessionEnd session_end = 4; + } +} + +// WebSocketPreflight lets a service decline this upgrade before OpenShell +// contacts upstream. It deliberately excludes query data, arbitrary request +// headers, and message payloads. +message WebSocketPreflight { + string session_id = 1; + SupervisorMiddlewarePhase phase = 2; + RequestContext context = 3; + // Admitted HTTP WebSocket-upgrade target. The method is GET, query is always + // empty, and path never includes a query string. + HttpRequestTarget target = 4; + repeated string requested_subprotocols = 5; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 6; + google.protobuf.Struct config = 7; +} + +// WebSocketSessionStart reports bounded metadata known only after the +// upstream 101 response validates. Empty selected_subprotocol means none. +message WebSocketSessionStart { + string selected_subprotocol = 1; +} + +// WebSocketMessage contains one complete reconstructed logical message. +message WebSocketMessage { + // Session-global sequence starting at 1. Values delivered to one stage must + // strictly increase but need not be contiguous. Reject zero, duplicates, and + // regressions; accept gaps. + uint64 sequence = 1; + // One complete logical payload. Protobuf string decoding enforces UTF-8 for + // text messages. Raw frame mechanics are never exposed. Limited to 4 MiB by + // the platform and the binding-specific cap. + oneof payload { + string text = 2; + bytes binary = 3; + } +} + +// WebSocketPreflightAction is the service's one-time scoping decision. +enum WebSocketPreflightAction { + // Invalid response value handled according to the policy failure mode. + WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED = 0; + // Inspect this session after the upstream accepts the upgrade. + WEB_SOCKET_PREFLIGHT_ACTION_INSPECT = 1; + // Voluntarily decline inspection without denying the upgrade. This is a + // successful decision and does not engage on_error. + WEB_SOCKET_PREFLIGHT_ACTION_SKIP = 2; + // Authoritatively deny the upgrade before upstream contact. This is a + // successful decision and is enforced regardless of on_error. + WEB_SOCKET_PREFLIGHT_ACTION_DENY = 3; +} + +message WebSocketPreflightDecision { + WebSocketPreflightAction action = 1; + // Free-form service diagnostic. OpenShell never exposes this to the + // workload or security logs. Limited to 4 KiB before discarding. + string reason = 2; + // Optional stable machine-readable code for a deny decision. Because + // preflight runs before the HTTP upgrade completes, OpenShell may return + // this code to the requester. Codes follow the same format and 64-byte + // maximum as HttpRequestResult.reason_code. + string reason_code = 3; + // Audit-safe findings produced during preflight. At most 32 findings of at + // most 4 KiB encoded each are accepted. + repeated Finding findings = 4; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. + map metadata = 5; +} + +// WebSocketMessageResult contains the decision and optional replacement for +// one message. A replacement must use the same variant as the input payload. +message WebSocketMessageResult { + // Must exactly match the sequence of the corresponding WebSocketMessage. + uint64 sequence = 1; + Decision decision = 2; + // Absence preserves the input unchanged. Oneof presence distinguishes an + // empty replacement from no replacement, and string decoding enforces UTF-8. + oneof replacement { + string text = 3; + bytes binary = 4; + } + // Free-form service diagnostic. OpenShell never exposes this to the + // workload or security logs. Limited to 4 KiB before discarding. + string reason = 5; + // Optional stable machine-readable code for OCSF only. Unlike the HTTP + // reason_code, this value is never put in a WebSocket close frame. + string reason_code = 6; + repeated Finding findings = 7; + map metadata = 8; +} + +// WebSocketSessionEventResult is an evaluation result for a preflight or message +// event. Session start and end events do not produce results. +message WebSocketSessionEventResult { + oneof result { + WebSocketPreflightDecision preflight_decision = 1; + WebSocketMessageResult message_result = 2; + } +} + +// RequestContext identifies the sandbox request being evaluated. +message RequestContext { + // Request id used to correlate middleware and supervisor logs. + string request_id = 1; + // Sandbox id that originated the request. + string sandbox_id = 2; + // Workload process that originated the request, when available. + Process originating_process = 3; + // Sandbox name that originated the request. For display and logging only. + // Names are workspace-scoped and may be reused for different sandbox + // instances, so consumers must use sandbox_id for authorization, persistence, + // durable correlation, and identity. + string sandbox = 4; + // Workspace the sandbox belongs to. For display and logging only; see the + // sandbox guidance above. + string workspace = 5; +} + +// HttpRequestTarget describes the admitted HTTP destination and request target. +message HttpRequestTarget { + // Request scheme, such as "http", "https", "ws", or "wss". + string scheme = 1; + // Destination hostname selected by network policy. + string host = 2; + // Destination TCP port. + uint32 port = 3; + // HTTP request method. + string method = 4; + // Request path without the query string. + string path = 5; + // Raw request query string without the leading question mark. + string query = 6; +} + +// Process identifies a workload process and its executable ancestry. +message Process { + // Executable path for the originating process. + string binary = 1; + // Process id within the sandbox. + uint32 pid = 2; + // Executable paths for ancestor processes, nearest parent first. + repeated string ancestors = 3; +} + +// Decision controls whether OpenShell continues processing the current +// evaluation unit. +enum Decision { + // Invalid response value handled according to the policy failure mode. + DECISION_UNSPECIFIED = 0; + // Continue processing the current request or message and apply any returned + // mutations. + DECISION_ALLOW = 1; + // Reject the current request or message. The operation-specific result + // defines the enclosing protocol behavior. + DECISION_DENY = 2; +} + +// Finding is an audit-safe observation produced during evaluation. +message Finding { + // Stable, service-defined finding type. + string type = 1; + // Human-readable finding label that does not contain request content. + string label = 2; + // Number of matching observations represented by this finding. + uint32 count = 3; + // Service-defined confidence level. + string confidence = 4; + // Service-defined severity level. + string severity = 5; +} + +// ExistingHeaderAction controls how a header write behaves when the +// case-insensitive header name is already present. Every action writes the +// value when the header is absent. +enum ExistingHeaderAction { + EXISTING_HEADER_ACTION_UNSPECIFIED = 0; + // Add another field value without changing existing values. + EXISTING_HEADER_ACTION_APPEND = 1; + // Remove every existing value, then add the new value. + EXISTING_HEADER_ACTION_OVERWRITE = 2; + // Leave the existing values unchanged. + EXISTING_HEADER_ACTION_SKIP = 3; +} + +// WriteHeader proposes one header value and defines collision behavior. +message WriteHeader { + string name = 1; + string value = 2; + ExistingHeaderAction on_existing = 3; +} + +// RemoveHeader removes every value for a case-insensitive header name. +message RemoveHeader { + string name = 1; +} + +// HeaderMutation is one ordered HTTP header operation. +message HeaderMutation { + oneof operation { + WriteHeader write = 1; + RemoveHeader remove = 2; + } +} + +// HttpRequestResult contains the decision and optional request mutations. +message HttpRequestResult { + // Allow or deny decision for this request. + Decision decision = 1; + // Free-form service diagnostic. OpenShell does not relay this text into + // denied responses or security logs. Limited to 4 KiB before discarding. + string reason = 2; + // Replacement request body when has_body is true. Limited to 4 MiB. + bytes body = 3; + // True when body should replace the request body, including with an empty body. + bool has_body = 4; + // Ordered request-header mutations applied before the next middleware and + // before forwarding. Writes and removals may target visible end-to-end + // request headers, but credential, routing, framing, and hop-by-hop headers + // are always protected. Written values cannot contain OpenShell credential + // placeholder syntax. A violating result is a middleware failure handled + // according to the policy failure mode. At most 64 operations, 32 KiB of + // validated name/value data, and 64 KiB encoded are accepted. + repeated HeaderMutation header_mutations = 5; + // Audit-safe findings produced during evaluation. For operator-run services, + // OpenShell logs platform-owned fields derived from the operator-owned + // registration name rather than service-provided type, label, confidence, + // or metadata text. + // At most 32 findings of at most 4 KiB encoded each are accepted per stage. + // A policy selects at most 10 stages, so one chain retains at most 320. + repeated Finding findings = 6; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. + map metadata = 7; + // Optional stable machine-readable code for a deny decision. Codes must + // start with a lowercase ASCII letter and contain only lowercase ASCII + // letters, digits, and underscores, with a maximum length of 64 bytes. + // OpenShell may return this code to the requester, unlike free-form reason. + string reason_code = 8; +} diff --git a/examples/supervisor-middleware-payment-gate/src/cli.ts b/examples/supervisor-middleware-payment-gate/src/cli.ts new file mode 100644 index 0000000000..c783929f24 --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/src/cli.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 Axiru, Inc. +// SPDX-License-Identifier: Apache-2.0 +import * as grpc from "@grpc/grpc-js"; +import { buildServer } from "./server.js"; + +const bind = process.env.AXIRU_MW_BIND ?? "0.0.0.0:50052"; +const server = buildServer({ apiKey: process.env.AXIRU_API_KEY, baseUrl: process.env.AXIRU_BASE_URL }); +server.bindAsync(bind, grpc.ServerCredentials.createInsecure(), (err, port) => { + if (err) { + console.error(err); + process.exit(1); + } + console.error(`axiru openshell middleware listening on ${bind} (port ${port})`); +}); diff --git a/examples/supervisor-middleware-payment-gate/src/gate.ts b/examples/supervisor-middleware-payment-gate/src/gate.ts new file mode 100644 index 0000000000..afebe91d37 --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/src/gate.ts @@ -0,0 +1,455 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Axiru, Inc. +// SPDX-License-Identifier: Apache-2.0 +import { createHash } from "node:crypto"; +/** A request to move money, submitted by an agent before the payment tool runs. */ +export interface PaymentIntent { + /** Stable id supplied by the caller. Used for idempotency and receipts. */ + intent_id: string; + /** Which agent is asking. */ + agent_id: string; + /** refund | credit | payout | transfer | purchase | dispute */ + action: PaymentAction; + /** Minor units (cents). Integers only. */ + amount_minor: number; + /** ISO 4217, upper case. */ + currency: string; + /** Who receives the money: a merchant, vendor id, wallet, or customer id. */ + counterparty: string; + /** stripe | x402 | usdc | card | link | other */ + rail: string; + /** Free text from the agent. Never an input to the decision. Stored on the receipt. */ + reason?: string; + /** For refunds: the original charge id and its amount, so the gate can enforce refund <= charge. */ + original_charge?: { id: string; amount_minor: number }; + /** For refunds and credits: the customer the money goes back to. */ + customer_id?: string; + /** Platform context. Not an input to the decision. */ + context?: Record; +} + +export type PaymentAction = "refund" | "credit" | "payout" | "transfer" | "purchase" | "dispute"; + +export interface Policy { + policy_id: string; + version: string; + /** Deny any single intent above this amount (minor units). */ + per_transfer_ceiling_minor?: number; + /** Hold for a human above this amount (minor units). */ + hold_above_minor?: number; + /** Deny once an agent's allowed total in a rolling 24h window would exceed this (minor units). */ + daily_cap_per_agent_minor?: number; + /** If set, counterparties not in this list are denied. Case-insensitive exact match. */ + counterparty_allowlist?: string[]; + /** Deny a second refund or credit to the same customer for the same charge inside this window. */ + duplicate_window_days?: number; + /** Hold once a customer has received this many refunds or credits inside velocity_window_days. */ + velocity_max_per_customer?: number; + velocity_window_days?: number; + /** Refunds may never exceed the original charge, cumulatively. Default true. */ + refund_cannot_exceed_charge?: boolean; + /** Actions that always hold for a person, regardless of amount. Default: dispute. */ + always_hold_actions?: PaymentAction[]; + /** Optional business hours in UTC. Outside them, hold. */ + business_hours_utc?: { start_hour: number; end_hour: number }; +} + +export type Verdict = "allow" | "hold" | "deny"; + +export type ReasonCode = + | "AMOUNT_EXCEEDS_TRANSFER_CEILING" + | "AMOUNT_EXCEEDS_DAILY_CAP" + | "COUNTERPARTY_NOT_ALLOWLISTED" + | "DUPLICATE_WITHIN_WINDOW" + | "REFUND_EXCEEDS_CHARGE" + | "VELOCITY_EXCEEDED" + | "HOLD_ABOVE_THRESHOLD" + | "ACTION_REQUIRES_HUMAN" + | "OUTSIDE_BUSINESS_HOURS" + | "INVALID_INTENT" + | "WITHIN_POLICY"; + +/** What the gate already knows: prior decisions the rules need to count. */ +export interface LedgerContext { + /** Prior allowed intents. The gate only needs amount, agent, customer, charge, and time. */ + prior: PriorDecision[]; +} + +export interface PriorDecision { + intent_id: string; + agent_id: string; + action: PaymentAction; + amount_minor: number; + currency: string; + customer_id?: string; + original_charge_id?: string; + verdict: Verdict; + /** ISO timestamp. */ + at: string; +} + +export interface Decision { + decision_id: string; + intent_id: string; + verdict: Verdict; + reason_codes: ReasonCode[]; + /** One sentence a person can read. Built from the codes, not from the agent's text. */ + rationale: string; + policy_id: string; + policy_version: string; + /** ISO timestamp handed in by the caller. The evaluator never reads the wall clock. */ + evaluated_at: string; + /** SHA-256 over the canonical decision record. */ + fingerprint: string; + /** Fingerprint of the previous decision in this session, or 64 zeros. */ + prev_fingerprint: string; +} + +const ZERO = "0".repeat(64); +const DAY_MS = 86_400_000; + +/** Canonical JSON: sorted keys, no whitespace. Same input, same bytes, same hash. */ +export function canonical(value: unknown): string { + return JSON.stringify(sortKeys(value)); +} +function sortKeys(v: unknown): unknown { + if (Array.isArray(v)) return v.map(sortKeys); + if (v && typeof v === "object") { + return Object.keys(v as Record) + .sort() + .reduce>((acc, k) => { + acc[k] = sortKeys((v as Record)[k]); + return acc; + }, {}); + } + return v; +} +export function sha256Hex(s: string): string { + return createHash("sha256").update(s).digest("hex"); +} + +function validate(intent: PaymentIntent): ReasonCode | null { + if (!intent.intent_id || !intent.agent_id || !intent.counterparty) return "INVALID_INTENT"; + if (!Number.isInteger(intent.amount_minor) || intent.amount_minor <= 0) return "INVALID_INTENT"; + if (!/^[A-Z]{3,5}$/.test(intent.currency)) return "INVALID_INTENT"; + return null; +} + +/** + * The gate. A pure function: policy, intent, prior decisions, and a timestamp in; + * a verdict, reason codes, and a fingerprint out. No I/O, no clock, no model. + * Deny beats hold beats allow. Every applicable code is returned, not just the first. + */ +export function evaluate( + policy: Policy, + intent: PaymentIntent, + ledger: LedgerContext, + nowIso: string, + prevFingerprint: string = ZERO, +): Decision { + const codes: ReasonCode[] = []; + const invalid = validate(intent); + if (invalid) codes.push(invalid); + + const now = Date.parse(nowIso); + const sameCurrency = (p: { currency: string }) => p.currency === intent.currency; + const within = (at: string, days: number) => now - Date.parse(at) <= days * DAY_MS && Date.parse(at) <= now; + + if (!invalid) { + // 1. Ceiling + if (policy.per_transfer_ceiling_minor !== undefined && intent.amount_minor > policy.per_transfer_ceiling_minor) { + codes.push("AMOUNT_EXCEEDS_TRANSFER_CEILING"); + } + + // 2. Counterparty allowlist + if (policy.counterparty_allowlist && policy.counterparty_allowlist.length > 0) { + const ok = policy.counterparty_allowlist.some((c) => c.toLowerCase() === intent.counterparty.toLowerCase()); + if (!ok) codes.push("COUNTERPARTY_NOT_ALLOWLISTED"); + } + + // 3. Daily cap per agent (allowed intents in the last 24h plus this one) + if (policy.daily_cap_per_agent_minor !== undefined) { + const spent = ledger.prior + .filter((p) => p.agent_id === intent.agent_id && p.verdict === "allow" && sameCurrency(p) && within(p.at, 1)) + .reduce((s, p) => s + p.amount_minor, 0); + if (spent + intent.amount_minor > policy.daily_cap_per_agent_minor) codes.push("AMOUNT_EXCEEDS_DAILY_CAP"); + } + + const isReturnOfMoney = intent.action === "refund" || intent.action === "credit"; + + // 4. Duplicate window: same customer, same charge, already refunded inside the window + if (isReturnOfMoney && policy.duplicate_window_days !== undefined && intent.original_charge && intent.customer_id) { + const chargeId = intent.original_charge.id; + const dup = ledger.prior.some( + (p) => + p.verdict === "allow" && + (p.action === "refund" || p.action === "credit") && + p.customer_id === intent.customer_id && + p.original_charge_id === chargeId && + within(p.at, policy.duplicate_window_days!), + ); + if (dup) codes.push("DUPLICATE_WITHIN_WINDOW"); + } + + // 5. Cumulative refunds never exceed the charge + if (isReturnOfMoney && intent.original_charge && (policy.refund_cannot_exceed_charge ?? true)) { + const already = ledger.prior + .filter((p) => p.verdict === "allow" && p.original_charge_id === intent.original_charge!.id && sameCurrency(p)) + .reduce((s, p) => s + p.amount_minor, 0); + if (already + intent.amount_minor > intent.original_charge.amount_minor) codes.push("REFUND_EXCEEDS_CHARGE"); + } + + // 6. Per-customer velocity + if (isReturnOfMoney && policy.velocity_max_per_customer !== undefined && intent.customer_id) { + const days = policy.velocity_window_days ?? 30; + const count = ledger.prior.filter( + (p) => p.verdict === "allow" && (p.action === "refund" || p.action === "credit") && p.customer_id === intent.customer_id && within(p.at, days), + ).length; + if (count + 1 > policy.velocity_max_per_customer) codes.push("VELOCITY_EXCEEDED"); + } + + // 7. Actions that always need a person + const alwaysHold = policy.always_hold_actions ?? ["dispute"]; + if (alwaysHold.includes(intent.action)) codes.push("ACTION_REQUIRES_HUMAN"); + + // 8. Hold threshold + if (policy.hold_above_minor !== undefined && intent.amount_minor > policy.hold_above_minor) { + codes.push("HOLD_ABOVE_THRESHOLD"); + } + + // 9. Business hours (UTC) + if (policy.business_hours_utc) { + const h = new Date(now).getUTCHours(); + const { start_hour, end_hour } = policy.business_hours_utc; + const inside = start_hour <= end_hour ? h >= start_hour && h < end_hour : h >= start_hour || h < end_hour; + if (!inside) codes.push("OUTSIDE_BUSINESS_HOURS"); + } + } + + const DENY: ReasonCode[] = [ + "INVALID_INTENT", + "AMOUNT_EXCEEDS_TRANSFER_CEILING", + "AMOUNT_EXCEEDS_DAILY_CAP", + "COUNTERPARTY_NOT_ALLOWLISTED", + "DUPLICATE_WITHIN_WINDOW", + "REFUND_EXCEEDS_CHARGE", + ]; + let verdict: Verdict = "allow"; + if (codes.some((c) => DENY.includes(c))) verdict = "deny"; + else if (codes.length > 0) verdict = "hold"; + if (codes.length === 0) codes.push("WITHIN_POLICY"); + + const rationale = buildRationale(verdict, codes, intent, policy); + const record = { + intent_id: intent.intent_id, + verdict, + reason_codes: codes, + policy_id: policy.policy_id, + policy_version: policy.version, + evaluated_at: nowIso, + prev_fingerprint: prevFingerprint, + intent: { agent_id: intent.agent_id, action: intent.action, amount_minor: intent.amount_minor, currency: intent.currency, counterparty: intent.counterparty, rail: intent.rail }, + }; + const fingerprint = sha256Hex(canonical(record)); + return { + decision_id: `dec_${fingerprint.slice(0, 16)}`, + intent_id: intent.intent_id, + verdict, + reason_codes: codes, + rationale, + policy_id: policy.policy_id, + policy_version: policy.version, + evaluated_at: nowIso, + fingerprint, + prev_fingerprint: prevFingerprint, + }; +} + +function money(minor: number, ccy: string): string { + return `${(minor / 100).toFixed(2)} ${ccy}`; +} + +function buildRationale(verdict: Verdict, codes: ReasonCode[], i: PaymentIntent, p: Policy): string { + const parts: string[] = []; + for (const c of codes) { + switch (c) { + case "AMOUNT_EXCEEDS_TRANSFER_CEILING": + parts.push(`amount ${money(i.amount_minor, i.currency)} exceeds per-transfer ceiling ${money(p.per_transfer_ceiling_minor!, i.currency)}`); + break; + case "AMOUNT_EXCEEDS_DAILY_CAP": + parts.push(`agent ${i.agent_id} would exceed daily cap ${money(p.daily_cap_per_agent_minor!, i.currency)}`); + break; + case "COUNTERPARTY_NOT_ALLOWLISTED": + parts.push(`counterparty ${i.counterparty} is not on the allowlist`); + break; + case "DUPLICATE_WITHIN_WINDOW": + parts.push(`a ${i.action} to customer ${i.customer_id} for charge ${i.original_charge?.id} already ran inside ${p.duplicate_window_days} days`); + break; + case "REFUND_EXCEEDS_CHARGE": + parts.push(`total refunds on charge ${i.original_charge?.id} would exceed the original ${money(i.original_charge!.amount_minor, i.currency)}`); + break; + case "VELOCITY_EXCEEDED": + parts.push(`customer ${i.customer_id} has reached ${p.velocity_max_per_customer} refunds in ${p.velocity_window_days ?? 30} days`); + break; + case "ACTION_REQUIRES_HUMAN": + parts.push(`${i.action} always requires a person`); + break; + case "HOLD_ABOVE_THRESHOLD": + parts.push(`amount ${money(i.amount_minor, i.currency)} is above the hold threshold ${money(p.hold_above_minor!, i.currency)}`); + break; + case "OUTSIDE_BUSINESS_HOURS": + parts.push(`outside business hours`); + break; + case "INVALID_INTENT": + parts.push(`intent is missing required fields or has a non-positive amount`); + break; + case "WITHIN_POLICY": + parts.push(`within policy ${p.policy_id} v${p.version}`); + break; + } + } + const head = verdict === "allow" ? "Allowed" : verdict === "hold" ? "Held for a person" : "Denied"; + return `${head}: ${parts.join("; ")}.`; +} +export interface GateOptions { + /** If set, decisions are requested from the hosted Axiru API. Otherwise the local evaluator runs. */ + apiKey?: string; + /** Defaults to https://www.axiru.com/api/v1 */ + baseUrl?: string; + /** Policy used by the local evaluator. Required when there is no API key. */ + policy?: Policy; + /** Clock injection for tests. */ + now?: () => string; + /** fetch injection for tests. */ + fetchImpl?: typeof fetch; +} + +/** Sensible defaults for a support agent with refund authority. Override per deployment. */ +export const DEFAULT_REFUND_POLICY: Policy = { + policy_id: "refund-default", + version: "1.0.0", + per_transfer_ceiling_minor: 50_000, + hold_above_minor: 10_000, + daily_cap_per_agent_minor: 100_000, + duplicate_window_days: 30, + velocity_max_per_customer: 3, + velocity_window_days: 30, + refund_cannot_exceed_charge: true, + always_hold_actions: ["dispute", "payout", "transfer"], +}; + +/** + * Gate = evaluator + session ledger + optional hosted API. + * Every platform adapter in this repo talks to this one class. + */ +export class Gate { + private readonly prior: PriorDecision[] = []; + private readonly decisions: Decision[] = []; + private lastFingerprint = "0".repeat(64); + private readonly opts: Required> & GateOptions; + + constructor(opts: GateOptions = {}) { + this.opts = { + ...opts, + baseUrl: opts.baseUrl ?? "https://www.axiru.com/api/v1", + now: opts.now ?? (() => new Date().toISOString()), + fetchImpl: opts.fetchImpl ?? fetch, + }; + if (!opts.apiKey && !opts.policy) this.opts.policy = DEFAULT_REFUND_POLICY; + } + + get policy(): Policy | undefined { + return this.opts.policy; + } + + /** Feed prior decisions from your own store so windows and caps count correctly across restarts. */ + seed(prior: PriorDecision[]): void { + this.prior.push(...prior); + } + + ledger(): readonly Decision[] { + return this.decisions; + } + + async check(intent: PaymentIntent): Promise { + const existing = this.decisions.find((d) => d.intent_id === intent.intent_id); + if (existing) return existing; // idempotent: same intent id, same decision + + const decision = this.opts.apiKey ? await this.remote(intent) : this.local(intent); + this.decisions.push(decision); + this.lastFingerprint = decision.fingerprint; + this.prior.push({ + intent_id: intent.intent_id, + agent_id: intent.agent_id, + action: intent.action, + amount_minor: intent.amount_minor, + currency: intent.currency, + customer_id: intent.customer_id, + original_charge_id: intent.original_charge?.id, + verdict: decision.verdict, + at: decision.evaluated_at, + }); + return decision; + } + + private local(intent: PaymentIntent): Decision { + const ctx: LedgerContext = { prior: this.prior }; + return evaluate(this.opts.policy!, intent, ctx, this.opts.now(), this.lastFingerprint); + } + + private async remote(intent: PaymentIntent): Promise { + const res = await this.opts.fetchImpl(`${this.opts.baseUrl}/decisions`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${this.opts.apiKey}` }, + body: JSON.stringify({ + intent_id: intent.intent_id, + agent_id: intent.agent_id, + action: intent.action, + amount_cents: intent.amount_minor, + currency: intent.currency.toLowerCase(), + counterparty: intent.counterparty, + rail: intent.rail, + customer_id: intent.customer_id, + stripe_charge_id: intent.original_charge?.id, + original_amount_cents: intent.original_charge?.amount_minor, + reason: intent.reason, + context: intent.context, + }), + }); + if (!res.ok) { + // Fail closed. A gate that cannot reach its policy does not say yes. + const now = this.opts.now(); + return { + decision_id: `dec_unavailable_${Date.parse(now)}`, + intent_id: intent.intent_id, + verdict: "hold", + reason_codes: ["ACTION_REQUIRES_HUMAN"], + rationale: `Held for a person: policy service returned HTTP ${res.status}; the gate fails closed.`, + policy_id: "remote", + policy_version: "unknown", + evaluated_at: now, + fingerprint: "", + prev_fingerprint: this.lastFingerprint, + }; + } + const body = (await res.json()) as Partial & { status?: string; decision_id?: string }; + const verdict = (body.verdict ?? mapStatus(body.status)) as Decision["verdict"]; + return { + decision_id: body.decision_id ?? `dec_${Date.parse(this.opts.now())}`, + intent_id: intent.intent_id, + verdict, + reason_codes: body.reason_codes ?? ["WITHIN_POLICY"], + rationale: body.rationale ?? "", + policy_id: body.policy_id ?? "remote", + policy_version: body.policy_version ?? "unknown", + evaluated_at: body.evaluated_at ?? this.opts.now(), + fingerprint: body.fingerprint ?? "", + prev_fingerprint: body.prev_fingerprint ?? this.lastFingerprint, + }; + } +} + +function mapStatus(s?: string): Decision["verdict"] { + if (s === "allowed" || s === "allow") return "allow"; + if (s === "blocked" || s === "deny" || s === "denied") return "deny"; + return "hold"; +} diff --git a/examples/supervisor-middleware-payment-gate/src/middleware.ts b/examples/supervisor-middleware-payment-gate/src/middleware.ts new file mode 100644 index 0000000000..c6f752392a --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/src/middleware.ts @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Axiru, Inc. +// SPDX-License-Identifier: Apache-2.0 +import { Gate } from "./gate.js"; +import type { Decision, Policy } from "./gate.js"; +import { parseStripeRequest } from "./stripe.js"; + +export interface MiddlewareDeps { + gate: Gate; + /** After this many denials from one sandbox in 24h, findings recommend quarantine. */ + quarantineAfterDenials?: number; +} + +export interface EvalInput { + method: string; + host: string; + path: string; + headers: Record; + body: string; + sandboxId: string; +} + +export interface EvalOutput { + decision: "DECISION_ALLOW" | "DECISION_DENY"; + reason: string; + reason_code: string; + header_mutations: Array<{ write: { name: string; value: string; on_existing: "EXISTING_HEADER_ACTION_OVERWRITE" } }>; + findings: Array<{ type: string; label: string; count: number; confidence: string; severity: string }>; + metadata: Record; + axiru?: Decision; +} + +const denials = new Map(); + +/** Pure-ish core of the middleware, separated from gRPC so it can be unit tested. */ +export async function evaluateHttp(deps: MiddlewareDeps, input: EvalInput): Promise { + const pass: EvalOutput = { decision: "DECISION_ALLOW", reason: "not a money-moving call", reason_code: "PASSTHROUGH", header_mutations: [], findings: [], metadata: {} }; + if (!/(^|\.)stripe\.com$/i.test(input.host)) return pass; + const parsed = parseStripeRequest(input.method, input.path, lower(input.headers), input.body, input.sandboxId); + if (!parsed) return pass; + + if (parsed.amountUnspecified) { + return deny(deps, input.sandboxId, "AMOUNT_UNSPECIFIED", `Stripe ${parsed.endpoint} without an explicit amount is a full refund. Specify amount so policy can evaluate it.`); + } + + const d = await deps.gate.check(parsed.intent); + const metadata = { + "axiru.decision_id": d.decision_id, + "axiru.verdict": d.verdict, + "axiru.policy": `${d.policy_id}@${d.policy_version}`, + "axiru.fingerprint": d.fingerprint, + "axiru.reason_codes": d.reason_codes.join(","), + }; + if (d.verdict === "allow") { + return { + decision: "DECISION_ALLOW", + reason: d.rationale, + reason_code: "AXIRU_ALLOW", + // Pin Stripe's own idempotency to the decision, so a retry cannot become a second refund. + header_mutations: [{ write: { name: "Idempotency-Key", value: d.decision_id, on_existing: "EXISTING_HEADER_ACTION_OVERWRITE" } }], + findings: [{ type: "axiru.decision", label: "allow", count: 1, confidence: "certain", severity: "info" }], + metadata, + axiru: d, + }; + } + const out = deny(deps, input.sandboxId, d.verdict === "hold" ? "AXIRU_HOLD" : "AXIRU_DENY", d.rationale); + out.metadata = { ...out.metadata, ...metadata }; + out.axiru = d; + return out; +} + +function deny(deps: MiddlewareDeps, sandboxId: string, code: string, reason: string): EvalOutput { + const cutoff = Date.now() - 86_400_000; + const list = (denials.get(sandboxId) ?? []).filter((t) => t > cutoff); + list.push(Date.now()); + denials.set(sandboxId, list); + const limit = deps.quarantineAfterDenials ?? 3; + const findings = [{ type: "axiru.decision", label: code.toLowerCase(), count: 1, confidence: "certain", severity: code === "AXIRU_HOLD" ? "medium" : "high" }]; + const metadata: Record = { "axiru.denials_24h": String(list.length) }; + if (list.length >= limit) { + // The signal Sentry or an operator can act on. OpenShell records findings and metadata in the gateway audit log. + findings.push({ type: "axiru.quarantine_recommended", label: `${list.length} denied money moves in 24h`, count: list.length, confidence: "certain", severity: "critical" }); + metadata["axiru.quarantine_recommended"] = "true"; + } + return { decision: "DECISION_DENY", reason, reason_code: code, header_mutations: [], findings, metadata }; +} + +function lower(h: Record): Record { + return Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v])); +} + +export function policyFromConfig(config: Record | undefined, base: Policy): Policy { + if (!config) return base; + const n = (k: string) => (typeof config[k] === "number" ? (config[k] as number) : undefined); + return { + ...base, + policy_id: (config.policy_id as string) ?? base.policy_id, + per_transfer_ceiling_minor: n("per_transfer_ceiling_minor") ?? base.per_transfer_ceiling_minor, + hold_above_minor: n("hold_above_minor") ?? base.hold_above_minor, + daily_cap_per_agent_minor: n("daily_cap_per_agent_minor") ?? base.daily_cap_per_agent_minor, + duplicate_window_days: n("duplicate_window_days") ?? base.duplicate_window_days, + counterparty_allowlist: Array.isArray(config.counterparty_allowlist) ? (config.counterparty_allowlist as string[]) : base.counterparty_allowlist, + }; +} diff --git a/examples/supervisor-middleware-payment-gate/src/server.ts b/examples/supervisor-middleware-payment-gate/src/server.ts new file mode 100644 index 0000000000..63f77cb489 --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/src/server.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Axiru, Inc. +// SPDX-License-Identifier: Apache-2.0 +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import { Gate, DEFAULT_REFUND_POLICY } from "./gate.js"; +import { evaluateHttp, policyFromConfig } from "./middleware.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const PROTO_DIR = join(here, "..", "proto"); + +export function buildServer(opts: { apiKey?: string; baseUrl?: string } = {}): grpc.Server { + const def = protoLoader.loadSync(join(PROTO_DIR, "supervisor_middleware.proto"), { + keepCase: true, + longs: Number, + enums: String, + defaults: true, + includeDirs: [PROTO_DIR, join(dirname(require.resolve("@grpc/proto-loader/package.json")), "..", "protobufjs")], + }); + const pkg = grpc.loadPackageDefinition(def) as any; + const svc = pkg.openshell.middleware.v1.SupervisorMiddleware.service; + + let gate = new Gate({ apiKey: opts.apiKey, baseUrl: opts.baseUrl, policy: DEFAULT_REFUND_POLICY }); + const server = new grpc.Server(); + + server.addService(svc, { + Describe: (_call: any, cb: any) => + cb(null, { + name: "axiru-payment-gate", + service_version: "0.1.0", + expected_audience: "axiru-payment-gate", + bindings: [ + { operation: "SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST", phase: "SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS", max_payload_bytes: 262144, request_timeout: { seconds: 2, nanos: 0 } }, + ], + extension: { protocol_version: { major: 1, minor: 0 }, implementation_name: "axiru-openshell-middleware", implementation_version: "0.1.0" }, + }), + ValidateConfig: (call: any, cb: any) => { + try { + const cfg = structToObject(call.request.config); + gate = new Gate({ apiKey: opts.apiKey ?? (cfg.axiru_api_key as string | undefined), baseUrl: opts.baseUrl, policy: policyFromConfig(cfg, DEFAULT_REFUND_POLICY) }); + cb(null, { valid: true, reason: "" }); + } catch (e) { + cb(null, { valid: false, reason: String(e) }); + } + }, + EvaluateHttpRequest: async (call: any, cb: any) => { + const r = call.request; + const headers: Record = {}; + for (const h of r.headers ?? []) headers[h.name] = h.value; + try { + const out = await evaluateHttp({ gate }, { + method: r.target?.method ?? "", + host: r.target?.host ?? "", + path: r.target?.path ?? "", + headers, + body: Buffer.from(r.body ?? []).toString("utf8"), + sandboxId: r.context?.sandbox_id || r.context?.sandbox || "sandbox", + }); + cb(null, { decision: out.decision, reason: out.reason, reason_code: out.reason_code, has_body: false, header_mutations: out.header_mutations, findings: out.findings, metadata: out.metadata }); + } catch (e) { + // Fail closed: the sandbox policy also sets on_error: fail_closed, this is belt and braces. + cb(null, { decision: "DECISION_DENY", reason: `axiru middleware error: ${String(e)}`, reason_code: "AXIRU_ERROR", has_body: false, header_mutations: [], findings: [], metadata: {} }); + } + }, + EvaluateWebSocketSession: (call: any) => { + call.on("data", () => call.write({})); + call.on("end", () => call.end()); + }, + }); + return server; +} + +function structToObject(s: any): Record { + const out: Record = {}; + for (const [k, v] of Object.entries((s?.fields ?? {}) as Record)) out[k] = valueOf(v); + return out; +} +function valueOf(v: any): unknown { + if (!v) return undefined; + if (v.kind === "numberValue" || v.numberValue !== undefined) return v.numberValue; + if (v.kind === "stringValue" || v.stringValue !== undefined) return v.stringValue; + if (v.kind === "boolValue" || v.boolValue !== undefined) return v.boolValue; + if (v.listValue) return (v.listValue.values ?? []).map(valueOf); + if (v.structValue) return structToObject(v.structValue); + return undefined; +} diff --git a/examples/supervisor-middleware-payment-gate/src/stripe.ts b/examples/supervisor-middleware-payment-gate/src/stripe.ts new file mode 100644 index 0000000000..10531b93df --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/src/stripe.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 Axiru, Inc. +// SPDX-License-Identifier: Apache-2.0 +import type { PaymentAction, PaymentIntent } from "./gate.js"; + +/** + * Turn a raw Stripe API request into a PaymentIntent the gate can evaluate. + * Stripe's API is form-encoded. Only money-moving endpoints are mapped; everything + * else returns null and passes through untouched. + */ +export interface ParsedStripeCall { + intent: PaymentIntent; + endpoint: string; + /** Stripe treats a refund with no amount as a full refund. The gate cannot size it, so the middleware denies and asks for an explicit amount. */ + amountUnspecified: boolean; +} + +const MONEY_ENDPOINTS: Array<{ re: RegExp; action: PaymentAction; counterpartyKey: string[] }> = [ + { re: /^\/v1\/refunds$/, action: "refund", counterpartyKey: ["charge", "payment_intent"] }, + { re: /^\/v1\/charges\/([^/]+)\/refunds$/, action: "refund", counterpartyKey: [] }, + { re: /^\/v1\/transfers$/, action: "transfer", counterpartyKey: ["destination"] }, + { re: /^\/v1\/payouts$/, action: "payout", counterpartyKey: ["destination"] }, + { re: /^\/v1\/customers\/([^/]+)\/balance_transactions$/, action: "credit", counterpartyKey: [] }, + { re: /^\/v1\/disputes\/([^/]+)$/, action: "dispute", counterpartyKey: [] }, + { re: /^\/v1\/issuing\/authorizations\/([^/]+)\/approve$/, action: "purchase", counterpartyKey: [] }, +]; + +export function parseForm(body: string): Record { + const out: Record = {}; + for (const pair of body.split("&")) { + if (!pair) continue; + const [k, v = ""] = pair.split("="); + out[decodeURIComponent(k.replace(/\+/g, " "))] = decodeURIComponent(v.replace(/\+/g, " ")); + } + return out; +} + +export function parseStripeRequest(method: string, path: string, headers: Record, bodyText: string, sandboxId: string): ParsedStripeCall | null { + if (method.toUpperCase() !== "POST") return null; + const cleanPath = path.split("?")[0]; + for (const m of MONEY_ENDPOINTS) { + const match = cleanPath.match(m.re); + if (!match) continue; + const form = headers["content-type"]?.includes("json") ? (JSON.parse(bodyText || "{}") as Record) : parseForm(bodyText); + const amountRaw = form.amount ?? form.amount_cents; + const amount = amountRaw !== undefined ? Number(amountRaw) : NaN; + const chargeId = match[1] ?? form.charge ?? form.payment_intent; + const counterparty = m.counterpartyKey.map((k) => form[k]).find(Boolean) ?? chargeId ?? form.customer ?? match[1] ?? "unknown"; + const idem = headers["idempotency-key"]; + const intent: PaymentIntent = { + intent_id: idem ? `stripe_${idem}` : `stripe_${sandboxId}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + agent_id: sandboxId, + action: m.action, + amount_minor: Number.isFinite(amount) && amount > 0 ? Math.trunc(amount) : 1, + currency: (form.currency ?? "usd").toUpperCase(), + counterparty: String(counterparty), + rail: "stripe", + reason: form["metadata[reason]"] ?? form.reason ?? form.description, + customer_id: form.customer, + original_charge: chargeId ? { id: String(chargeId), amount_minor: Number.MAX_SAFE_INTEGER } : undefined, + context: { platform: "openshell", path: cleanPath }, + }; + return { intent, endpoint: cleanPath, amountUnspecified: !(Number.isFinite(amount) && amount > 0) }; + } + return null; +} diff --git a/examples/supervisor-middleware-payment-gate/test/middleware.test.ts b/examples/supervisor-middleware-payment-gate/test/middleware.test.ts new file mode 100644 index 0000000000..038b214238 --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/test/middleware.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { Gate, DEFAULT_REFUND_POLICY } from "../src/gate.js"; +import { evaluateHttp } from "../src/middleware.js"; +import { parseStripeRequest } from "../src/stripe.js"; + +const H = { "content-type": "application/x-www-form-urlencoded" }; +const deps = () => ({ gate: new Gate({ policy: { ...DEFAULT_REFUND_POLICY, counterparty_allowlist: undefined } }) }); + +describe("stripe parser", () => { + it("maps POST /v1/refunds to a refund intent", () => { + const p = parseStripeRequest("POST", "/v1/refunds", H, "charge=ch_123&amount=8000&metadata[reason]=customer+request", "sb1"); + expect(p?.intent.action).toBe("refund"); + expect(p?.intent.amount_minor).toBe(8000); + expect(p?.intent.original_charge?.id).toBe("ch_123"); + expect(p?.intent.reason).toBe("customer request"); + }); + it("ignores reads and non-money endpoints", () => { + expect(parseStripeRequest("GET", "/v1/charges/ch_1", H, "", "sb1")).toBeNull(); + expect(parseStripeRequest("POST", "/v1/customers", H, "email=a%40b.com", "sb1")).toBeNull(); + }); +}); + +describe("middleware", () => { + it("passes non-Stripe hosts through", async () => { + const o = await evaluateHttp(deps(), { method: "POST", host: "api.github.com", path: "/repos", headers: H, body: "", sandboxId: "sb1" }); + expect(o.decision).toBe("DECISION_ALLOW"); + expect(o.reason_code).toBe("PASSTHROUGH"); + }); + it("allows an in-policy refund and pins the idempotency key to the decision", async () => { + const o = await evaluateHttp(deps(), { method: "POST", host: "api.stripe.com", path: "/v1/refunds", headers: H, body: "charge=ch_1&amount=8000&customer=cus_1", sandboxId: "sb1" }); + expect(o.decision).toBe("DECISION_ALLOW"); + expect(o.header_mutations[0].write.name).toBe("Idempotency-Key"); + expect(o.header_mutations[0].write.value).toBe(o.axiru!.decision_id); + }); + it("denies a second refund on the same charge inside the window", async () => { + const d = deps(); + await evaluateHttp(d, { method: "POST", host: "api.stripe.com", path: "/v1/refunds", headers: H, body: "charge=ch_1&amount=3000&customer=cus_1", sandboxId: "sb1" }); + const o = await evaluateHttp(d, { method: "POST", host: "api.stripe.com", path: "/v1/refunds", headers: H, body: "charge=ch_1&amount=3000&customer=cus_1", sandboxId: "sb1" }); + expect(o.decision).toBe("DECISION_DENY"); + expect(o.metadata["axiru.reason_codes"]).toContain("DUPLICATE_WITHIN_WINDOW"); + }); + it("denies a refund with no amount (full refund) and asks for one", async () => { + const o = await evaluateHttp(deps(), { method: "POST", host: "api.stripe.com", path: "/v1/refunds", headers: H, body: "charge=ch_9", sandboxId: "sb1" }); + expect(o.decision).toBe("DECISION_DENY"); + expect(o.reason_code).toBe("AMOUNT_UNSPECIFIED"); + }); + it("denies a transfer above the ceiling and recommends quarantine after repeated denials", async () => { + const d = deps(); + let o; + for (let i = 0; i < 3; i++) { + o = await evaluateHttp(d, { method: "POST", host: "api.stripe.com", path: "/v1/transfers", headers: H, body: `amount=25000000¤cy=usd&destination=acct_x${i}`, sandboxId: "sb-rogue" }); + } + expect(o!.decision).toBe("DECISION_DENY"); + expect(o!.metadata["axiru.quarantine_recommended"]).toBe("true"); + expect(o!.findings.some((f) => f.type === "axiru.quarantine_recommended")).toBe(true); + }); +}); diff --git a/examples/supervisor-middleware-payment-gate/tsconfig.json b/examples/supervisor-middleware-payment-gate/tsconfig.json new file mode 100644 index 0000000000..44851bb677 --- /dev/null +++ b/examples/supervisor-middleware-payment-gate/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"], + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +}