Skip to content

feat(examples): OPA policy engine behind the pre-sign hook (#142) - #242

Open
fernando-mendoza wants to merge 1 commit into
nirium-protocol:mainfrom
fernando-mendoza:feat/142-policy-opa
Open

fernando-mendoza wants to merge 1 commit into
nirium-protocol:mainfrom
fernando-mendoza:feat/142-policy-opa

Conversation

@fernando-mendoza

Copy link
Copy Markdown
Collaborator

What

Adds examples/x402-policy-opa/: a pre-sign policy hook whose evaluate() sends the decoded payment (amount, destination, asset, network, agent) to an Open Policy Agent server and maps its answer to ALLOW / DENY. Same layout as x402-policy-daily-budget (tsx + node:test, same tsconfig). No changes to the hook or the SDK.

How to run

cd examples/x402-policy-opa
# OPA (Docker):
docker run --rm -p 8181:8181 -v "$PWD/policy:/policy:ro" -w / \
  openpolicyagent/opa run --server --addr :8181 --watch --bundle policy
# or the binary: opa run --server --addr 127.0.0.1:8181 --watch --bundle policy

(cd ../../packages/sdk && npm install --legacy-peer-deps && npm run build)
npm ci && npm test && npm run typecheck
OPA_URL=http://127.0.0.1:8181 npm start
opa test policy -v

Tests

  • test/opa-policy.test.ts (14): unit tests of OpaPolicy against a mocked fetch: allow, deny, OPA down, HTTP 500, 8 malformed shapes, stale revision, aborted request.
  • test/hook-integration.test.ts (11): OpaPolicy.hook() wired into the SDK's real gate, createPolicyGatedSigner (the one initX402({ policy }) installs), with a mocked OPA fetch. Each test builds a real Soroban transfer auth entry and signs it through stellar-sdk's authorizeEntry, and asserts whether the base signer was reached. initX402 itself needs a real 402 server and RPC, so the gate is imported from the built SDK's dist/x402-policy.js; this adds @stellar/stellar-sdk@^16.3.0 as a devDependency (same range as the SDK).
allow                         -> SIGNED  (ALLOWED, SIGNED records; OPA got the input decoded from the preimage)
deny (over cap)               -> DENY, no signature
OPA down / HTTP 500 / malformed -> ENGINE_ERROR, no signature
slow OPA (honours abort)      -> TIMEOUT via hook timeoutMs, no signature
slow OPA (ignores abort)      -> TIMEOUT, no signature
revision r1 -> r2 before sign -> STALE, no signature
ALLOW with wrong contextHash  -> UNBOUND (rejected by the gate)
ALLOW without expiresAt       -> EXPIRED (requireExpiry)
ALLOW past its ttl            -> EXPIRED

npm test → # tests 25 # pass 25 # fail 0 (Node 22.23, after rm -rf node_modules && npm ci).

Done items

  • Rego policy ships and runs on OPA. policy/x402.rego: per-payment cap + destination allowlist. opa test policy -v → PASS: 7/7; opa fmt --diff policy and opa check --strict policy are clean (OPA 1.22.0). Docker isn't available on my box, so I ran the same server with the opa binary; the docker command above uses the same arguments.
  • Unreachable / slow / malformed never ALLOW. evaluate() throws on fetch failure, non-200, non-JSON, undefined rule, a result that isn't {allow: boolean, reason?: string}, or a missing bundle revision. Nothing catches these. Through the real gate: down/500/malformed → ENGINE_ERROR, slow → TIMEOUT (even when the fetch ignores the abort signal), base signer never called.
  • ALLOW is bound and short; version = bundle revision. ALLOW echoes ctx.contextHash, expiresAt = now + ttlMs (2 s default), requireExpiry: true. policyVersion and currentVersion() come from OPA's ?provenance field provenance.bundles.<name>.revision (the bundle .manifest). Live end to end, real OPA (--watch --bundle policy) + the real gate + a real auth entry, bumping .manifest from r1 to r2 between decision and signing:
    5 000 000 to allowed merchant      => SIGNED        base signer calls=1
    20 000 000 to allowed merchant     => DENY          base signer calls=0 x402 policy DENY: amount over per-payment cap
    5 000 000 to other address         => DENY          base signer calls=0 x402 policy DENY: destination not in allowlist
    revision bumped after decision     => STALE         base signer calls=0 x402 policy STALE: ALLOW was issued under policy opa-example-r1, current is opa-example-r2
    after bump, fresh decision         => SIGNED        base signer calls=1
    
  • Tests against a mocked OPA: allow, deny, OPA down, malformed, stale revision, plus timeout (see Tests).
  • README repeats L01 and L02 from the SDK README and the non-atomic version check, and says it's a reference, not a guarantee.

Also checked: .github/scripts/typecheck-example.sh against a packed SDK tarball (nirium 0.16.1) passes for this example and for every other example with a tsconfig.json.

Open questions for review

  1. Bundle name. Config bundle defaults to policy (OPA names a --bundle after its path). Alternative: read the sole bundle OPA reports in provenance and fail if there are several. I chose explicit config.
  2. currentVersion() re-queries the decision path with an empty input to read the revision (harmless; the rule is just undefined for that call). A different endpoint (e.g. /v1/status or a dedicated rule) would work too if you prefer.
  3. Amounts in Rego are compared with to_number after a digits-only regex. Fine for a reference cap, but very large amounts lose precision as floats; a production policy should compare exact integers.

Closes #142

…otocol#142)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fernando-mendoza

Copy link
Copy Markdown
Collaborator Author

@Eras256 este PR resuelve #142 y está listo para revisión cuando tengas chance. Dejé en la descripción tres preguntas cortas de diseño por si prefieres otro enfoque. ¡Gracias!

@Eras256
Eras256 requested a review from M0nsxx November 3, 2026 19:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Example: an external policy engine (Open Policy Agent) behind the pre-sign policy hook

1 participant