Policy-Controlled Payment Firewall for Autonomous Agent Actions on Stellar
Fortexa is a policy-controlled payment firewall for autonomous agent actions on Stellar. It sits between agent intent and economic execution, applies governance/risk checks, and keeps an auditable decision trail.
This document reflects the current implementation in this repository.
See docs/SCF_TRANCHE_PLAN.md for the Stellar Community Fund (SCF) funding tranches and roadmap alignment.
Agentic systems can now trigger real payments. That creates a new risk layer: high-speed model decisions can become high-impact economic actions.
Fortexa adds a control plane between intent and money movement:
- Policy checks before execution
- Risk scoring on suspicious behavior
- Human-approval gate for sensitive cases
- Wallet-native signed XDR flow
- Auditable evidence trail for every decision
In short: Fortexa is the safety layer for agentic payments.
If you only read one section, read this:
- Login with wallet on
/login. - Evaluate action in
/console. - Receive decision:
BLOCK/REQUIRE_APPROVAL/WARN/APPROVE. - For allowed flows, build unsigned XDR → sign in wallet → submit signed XDR.
- Verify outcome with Explorer link and inspect evidence in
/activityand/ops.
The core security premise of Fortexa is that it does not hold private keys or perform server-side signing. This end-to-end flow validates that design:
| Step | UI / Route | Source / Logic | Expected Signal |
|---|---|---|---|
| 1. Login | /login |
POST /api/auth/login src/components/login-form.tsx |
Success: Freighter challenge signed, session issued. Failure: Signature mismatch, unauthorized wallet. |
| 2. Decision | /console |
POST /api/decision src/components/decision-console.tsx |
Success: Returns APPROVE or WARN with a fixed payment quote.Failure: Returns BLOCK (no quote). |
| 3. Quote Lock | /console |
POST /api/stellar/build-payment |
Success: Build request perfectly matches the approved audit entry quote. Failure: Server rejects tampered destination, amount, or memo with 403. |
| 4. Unsigned XDR Build | /console |
POST /api/stellar/build-payment |
Success: Server returns valid unsigned XDR envelope. Failure: Network timeout, missing parameters. |
| 5. Wallet Signing | /console |
signTransaction inside src/components/decision-console.tsx |
Success: Freighter popup appears, user signs, UI holds signed XDR. Failure: User rejects in wallet. |
| 6. Signed Submit | /console |
POST /api/stellar/submit-signed |
Success: Broadcasts successfully to Stellar Testnet (200 OK). Failure: Horizon error ( tx_bad_seq, op_underfunded). |
| 7. Explorer Link | /console |
src/components/decision-console.tsx |
Success: Clickable link to Stellar Expert confirming hash matches. |
| 8. Audit Evidence | /activity/ops |
GET /api/audit src/app/activity/page.tsx |
Success: Immutable record of the original decision and execution hash. |
(Note: Fortexa is currently built for testnet validation. Mainnet readiness requires further risk intel integrations.)
Fortexa currently runs with a strict wallet-bound model:
- User logs in with wallet (
/login). - Session is created with role (
operator/viewer). - Session wallet is bound as execution source.
- Actions are evaluated by policy + security engine.
- Approved/warned decisions can proceed to signed-XDR payment flow.
- Decision/audit evidence is stored and visible in
/activityand/ops.
Fortexa uses a challenge-signature login flow:
- Client requests a one-time login challenge via
POST /api/auth/challengewith the wallet public key (G...). - The server returns a short-lived challenge message bound to that wallet.
- The wallet signs the challenge message (SEP-53 / Freighter
signMessage). - Client posts
publicKey,challengeId, andsignaturetoPOST /api/auth/login. - The server verifies the signature, enforces one-time challenge use + expiry, then issues
fortexa_session.
Role is still resolved via allowlists:
FORTEXA_OPERATOR_WALLETSFORTEXA_VIEWER_WALLETS
If both allowlists are empty, current behavior falls back to operator role for any valid wallet (recommended only for local/dev).
Session cookie: fortexa_session (HMAC-signed).
Challenge TTL: FORTEXA_AUTH_CHALLENGE_TTL_SECONDS (default 300).
operator: full decision/policy/payment flowviewer: read-only experience on sensitive execution paths
- Rate limiting
- Brute-force lockout (
FORTEXA_AUTH_MAX_ATTEMPTS,FORTEXA_AUTH_LOCK_MINUTES)
Note: MFA is removed from current implementation.
Fortexa currently does not perform server-side signing or private-key custody.
- Session is wallet-bound at login.
- Execution source wallet is derived from session identity.
- Session wallet mappings expire automatically after 24 hours. Expired sessions will receive a
401 Unauthorizedresponse on protected endpoints. - Operators can forcefully revoke a compromised or stale session mapping via
DELETE /api/auth/wallet/revoke. This deterministically removes the mapping from storage, requiring the user to reconnect their wallet. - Manual arbitrary wallet assignment in UI is removed.
/api/stellar/balanceauto-syncs missing wallet mapping from session when possible.
- Policy engine:
src/lib/policy/engine.ts - Security analyzer:
src/lib/security/analyzer.ts - Decision engine:
src/lib/decision/engine.ts
Decision outcomes:
BLOCKREQUIRE_APPROVALWARNAPPROVE
Human Approve & Re-run applies only when prior result is REQUIRE_APPROVAL.
Before committing a policy change, operators can dry-run the unsaved draft from the Policy editor (Run simulation). The draft is evaluated against the seeded demo scenarios — and, optionally, a small recent-audit sample — and the result shows each action's current → proposed decision so risky edits surface before they go live.
Simulation is strictly read-only: it never saves the policy and never consumes usage. Saving still happens only through POST /api/policy. See src/lib/decision/simulate.ts and POST /api/policy/simulate.
Reporting API failures: Include the
x-request-idheader value from the response (or therequestIdfield from server-side logs) when filing a bug report. See docs/observability.md for details.
- Evaluate action in
/consolewith a payment quote (paymentQuoteInput: destination, optional memo, network). OnAPPROVE/WARN, Fortexa stores an immutablepaymentQuoteon the audit entry. - Build unsigned tx:
POST /api/stellar/build-paymentwithauditEntryIdplus the same destination, amount, asset, memo, and network. The server verifies every field against the authorized quote before constructing XDR. Submit Signed XDRorchestrates signing/submission path:- if signed input is already present → submit directly
- if unsigned input is present → wallet signing is triggered first, then submit
- Submit signed tx:
POST /api/stellar/submit-signed. - Explorer URL is returned and shown as clickable link.
The policy decision authorizes a fixed payment quote (destination, amount, asset, memo, network). POST /api/stellar/build-payment is the enforcement gate: it loads the audit entry by auditEntryId, confirms the decision is APPROVE/WARN, and rejects any request whose fields diverge from the stored quote.
| Condition | HTTP | Response |
|---|---|---|
Missing/invalid body (auditEntryId, schema) |
400 |
Invalid payment build request. + zod details |
| Unknown audit entry or non-executable decision | 403 |
No authorized payment decision found… / Decision 'BLOCK' does not authorize… |
Quote older than FORTEXA_PAYMENT_QUOTE_TTL_SECONDS (default 300 s) |
403 |
Payment quote has expired. Please re-evaluate the action. |
| Tampered destination, amount, asset, or memo | 403 |
{ error, field } naming the mismatched field |
| Valid approved request | 200 |
{ ok: true, xdr, networkPassphrase, … } |
Client-side UI must pass the same paymentQuoteInput at decision time and reuse the returned auditEntry.id when building XDR. Mutating any authorized field after approval cannot produce a valid unsigned transaction.
Idempotent retries: POST /api/stellar/submit-signed accepts an optional idempotency key, supplied either as an Idempotency-Key request header or an idempotencyKey body field (the header wins if both are present). Results are stored per authenticated user + key + signed-XDR hash. Replaying the same key with the same signed XDR returns the original result (200, with header Idempotency-Replayed: true) without resubmitting to Horizon. Reusing the same key with a different signed XDR returns 409 Conflict. Omitting the key preserves the original submit-on-every-request behavior. Keys must be 8–255 characters.
Additional behavior:
- XDR build timeout configured to 180 seconds.
- Submit errors include Horizon result codes when available.
- Decisions are appended to audit store at evaluation time.
/activityreads entries by authenticated session user id.- Export endpoint supports
mineandallscopes in JSON/CSV.
All audit timestamps are recorded and exported in UTC (ISO 8601 format with a Z suffix, e.g. 2025-06-01T12:00:00.000Z). This applies to both JSON and CSV exports — the timestamp column in CSV output carries the raw UTC string with no local-time conversion. The from/to query parameters on the export endpoint are also compared against these UTC timestamps, so any filter dates should be expressed in UTC.
Every new audit entry is linked into a tamper-evident SHA-256 hash chain:
| Field | Description |
|---|---|
previousHash |
entryHash of the immediately preceding entry, or 0000…0000 (64 zeroes) for the first hashed entry. |
entryHash |
SHA-256 of the entry's canonical fields (id, timestamp, action, decision, explanation, triggeredPolicies, riskFindings, stellarTxHash, previousHash). Object keys are sorted before hashing so DB-stored and file-stored entries produce identical digests. |
Both fields are included in JSON exports. CSV exports add entryHash and previousHash columns.
Verification helper: verifyHashChain(entries) in src/lib/audit/hash-chain.ts — returns { valid: true } for an untouched log and { valid: false, reason } when it detects a modified, deleted, or reordered entry.
Entries written before this feature was introduced carry no hash fields and are treated as legacy entries; they do not break verification of newer hashed entries.
An exported JSON audit file can be verified outside the running application:
npm run verify:audit -- path/to/export.jsonThe script reads the JSON export, extracts the entries (handles scope=mine and scope=all formats), and runs the same verifyHashChain logic that the library uses. Exit code:
| Exit code | Meaning |
|---|---|
0 |
All entries verified successfully |
1 |
Chain integrity check failed (see stdout for details) |
2 |
Usage error or file not readable |
Usage: tsx scripts/verify-audit-export.ts <file>
- Node.js 20+
- npm 10+
npm install
cp .env.example .env.local
npm run devOpen: http://localhost:3000
To clean up local developer state safely, use the built-in reset utility (scripts/reset-local-demo-state.ts). The script is strictly for local environments and enforces guardrails to prevent accidental destruction of non-local data.
- Local Database Check: Inspects
DATABASE_URLand blocks execution if the hostname is notlocalhost,127.0.0.1,::1, or a local UNIX socket. - Local Redis Check: Skips Redis cleanup entirely when
REDIS_URLpoints to a non-local host. - Explicit Confirmation: Runs in dry-run mode unless both
FORTEXA_ALLOW_LOCAL_RESET=true(env var) and--yes(CLI flag) are provided together.
-
Dry-Run (Default) — preview what would be cleared without touching any data:
npm run demo:reset
(or
npx tsx scripts/reset-local-demo-state.ts) -
Apply Reset — execute once both guardrails are satisfied:
FORTEXA_ALLOW_LOCAL_RESET=true npm run demo:reset -- --yes
(or
FORTEXA_ALLOW_LOCAL_RESET=true npx tsx scripts/reset-local-demo-state.ts --yes)
JSON store files (inside FORTEXA_STORE_DIR, default .fortexa/):
| File | Contents |
|---|---|
audit.json |
Audit log entries |
policy.json |
Active policy configuration |
policy-history.json |
Policy change history |
submit-idempotency.json |
Payment idempotency cache |
wallets.json |
Wallet registrations |
FORTEXA_SHARED_STATE_PATH |
Shared-state file (if configured) |
Database tables (only when DATABASE_URL is set to a local host):
| Table | Contents |
|---|---|
fortexa_wallets |
Wallet registrations |
fortexa_audit_entries |
Audit log entries |
fortexa_usage |
Usage tracking records |
fortexa_policy_state |
Active policy state |
fortexa_policy_history |
Policy change history |
fortexa_submit_idempotency |
Payment idempotency records |
Tables are truncated with RESTART IDENTITY CASCADE.
Redis keys (only when REDIS_URL points to a local host):
- All keys matching
fortexa:*are deleted.
After clearing, the script re-inserts the default policy configuration at version 1 into both fortexa_policy_state and fortexa_policy_history. The app starts in a known, valid policy state immediately — no manual re-seed required.
.env.localand all environment variables- Database schema and migrations
node_modulesand build artifacts- Any files outside
FORTEXA_STORE_DIR
# 1. Preview what will be affected (safe — no changes made)
npm run demo:reset
# 2. Apply the reset
FORTEXA_ALLOW_LOCAL_RESET=true npm run demo:reset -- --yes
# 3. Optionally reload fresh demo scenarios
npm run demo:scenarios
# 4. Restart the dev server — default state is regenerated on startup
npm run devAll configuration is documented in .env.example. Copy it to .env.local and fill in the values you need:
cp .env.example .env.localThe file covers every variable used by the app, organized into:
| Category | Variables |
|---|---|
| Stellar Network | STELLAR_HORIZON_URL, STELLAR_NETWORK_PASSPHRASE, NEXT_PUBLIC_STELLAR_DESTINATION, FORTEXA_PAYMENT_QUOTE_TTL_SECONDS |
| Auth | FORTEXA_AUTH_SECRET, FORTEXA_OPERATOR_WALLETS, FORTEXA_VIEWER_WALLETS, FORTEXA_AUTH_CHALLENGE_TTL_SECONDS, FORTEXA_AUTH_MAX_ATTEMPTS, FORTEXA_AUTH_LOCK_MINUTES |
| Storage | DATABASE_URL, DATABASE_SSL, FORTEXA_STORE_DIR |
| Shared State | FORTEXA_SHARED_STATE_PATH, REDIS_URL |
| Idempotency | FORTEXA_IDEMPOTENCY_RETENTION_DAYS |
| Optional Integrations | GROQ_API_KEY, GROQ_MODEL, FORTEXA_BLOCKLIST_URL |
| Request Handling | FORTEXA_JSON_BODY_MAX_BYTES |
| Dev Utilities | FORTEXA_ALLOW_LOCAL_RESET |
Important
Stellar Network Configuration Pairing:
STELLAR_HORIZON_URL and STELLAR_NETWORK_PASSPHRASE are paired settings and must always be configured together. When switching between local development and Stellar testnet, ensure both values are updated in tandem. Mismatched values can cause confusing failures (e.g., submitting transactions to one Horizon instance while signing for another network). In production, ensure both are set to the correct production values.
npm run dev
npm run build
npm run start
npm run lint
npm test
npm run test:watch
npm run demo:scenarios
npm run db:migrateThe investor-facing scenario pack lives in src/lib/scenarios/seed.ts and its regression suite in src/lib/scenarios/scenario-pack.test.ts.
Run the full scenario pack:
npm test -- src/lib/scenarios/scenario-pack.test.tsRun the standalone demo runner (prints expected vs actual for every seeded scenario):
npm run demo:scenariosJSON POST routes that accept request bodies enforce a shared size limit before parsing (default 64 KiB, override with FORTEXA_JSON_BODY_MAX_BYTES). Oversized payloads receive HTTP 413 with a clear error message; malformed but small JSON still returns the route's normal validation error.
POST /api/auth/challengePOST /api/auth/loginPOST /api/auth/logoutGET /api/auth/sessionPOST /api/auth/refreshDELETE /api/auth/wallet/revoke(operator) — revokes session wallet mapping
GET /api/policyPOST /api/policy(operator)POST /api/policy/simulate(operator) — read-only pre-save simulationGET /api/policy/history(operator)POST /api/policy/rollback(operator)
POST /api/decision(operator)POST /api/agent/plan(operator, Groq-backed)
GET /api/auditGET /api/audit/export?format=json|csv&scope=mine|all&from=<ISO8601>&to=<ISO8601>&decision=APPROVE|WARN|REQUIRE_APPROVAL|BLOCK&domain=<string>&actionId=<string>- Filters:
from/to(ISO 8601 date),decision,domain,actionId— all optional - Scope:
mine(own entries) orall(operator only) - Examples:
GET /api/audit/export?format=csv&scope=mine&from=2025-06-01T00:00:00Z&to=2025-06-30T23:59:59ZGET /api/audit/export?format=json&scope=all&decision=BLOCK&domain=malicious.example.comGET /api/audit/export?format=json&scope=mine&actionId=evt_abc123
- Filters:
GET /api/healthGET /api/metrics(?format=prometheus)
GET /api/stellar/balancePOST /api/stellar/setup(session-wallet bootstrap/sync helper; not manual wallet linking)POST /api/stellar/build-paymentPOST /api/stellar/submit-signed(supportsIdempotency-Keyheader/body for safe UI retries)POST /api/stellar/pay(legacy disabled)POST /api/stellar/fund(removed behavior, returns410)
Note: Newly created Stellar testnet wallets start with zero balance. Unfunded wallets cannot perform balance queries, payment flows, or other wallet operations. Before testing, fund the wallet using the Stellar testnet friendbot (
GET https://friendbot.stellar.org?addr=YOUR_PUBLIC_KEY) or the in‑app/api/stellar/setupendpoint. This applies only to the testnet; production wallets are funded via standard Stellar distribution channels.
/→ Overview dashboard/login→ Wallet-only authentication (Connect Wallet)/wallet→ Session wallet status and balance/console→ Decisioning + payment execution console/policies→ Policy editor, history, rollback/scenarios→ Scenario gallery/activity→ Audit trail timeline/ops→ Operations/telemetry dashboard
- Health endpoint:
GET /api/health— returnsblocklistobject withconfigured,lastRefreshAt,domainCount,lastError - Metrics endpoint:
GET /api/metrics+ Prometheus format /opsdashboard shows:- service health
- total requests
- error rate
- signed tx count
- blocklist feed health (configured, domain count, last refresh, errors)
- top routes + rolling trend
Ops dashboard initial load is optimized so core telemetry renders first; slow TX-count fetch no longer blocks first paint.
See docs/observability.md for the Prometheus scrape config, sample PromQL (request rate, error rate, p95 latency), and an example alert rule.
Stores include:
audit-storepolicy-storeuser-wallet-storesubmit-idempotency-store
If DATABASE_URL is available and healthy, Postgres is used.
Otherwise Fortexa falls back to local JSON files:
- local/dev default:
.fortexa/*.json - Vercel default:
/tmp/fortexa/*.json
Optional overrides:
FORTEXA_STORE_DIRto set file-store directory explicitlyFORTEXA_SHARED_STATE_PATHfor shared lockout/rate-limit state file path- use an absolute path on Vercel (example:
/tmp/fortexa/shared-security-state.json)
- use an absolute path on Vercel (example:
REDIS_URLfor multi-instance deployments (e.g. Vercel)- uses a Redis-backed adapter with automatic, transparent fallback to the file store if Redis is unreachable or unconfigured.
- Migrations:
src/lib/storage/migrations.ts - Runner:
src/lib/storage/db.ts - Tracking table:
fortexa_schema_migrations - Manual run:
npm run db:migrate
- Framework: Next.js App Router (
next@16) - Language: TypeScript
- UI: Tailwind CSS + custom UI primitives
- Validation:
zod - Charts:
recharts - Stellar:
@stellar/stellar-sdk, optional@stellar/freighter-api - Database:
pg(optional Postgres, file fallback enabled) - Tests: Vitest
Fortexa applies baseline security headers to every response (pages and API routes) via src/middleware.ts.
| Header | Value | Rationale |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevents MIME-type sniffing — browsers trust the declared Content-Type without guessing. |
X-Frame-Options |
DENY |
Blocks the app from being embedded in <frame>, <iframe>, or <object> — prevents clickjacking. |
Content-Security-Policy |
default-src 'self'; + per-resource directives |
Restricts resource loading to the origin. Inlines styles are allowed (required by next/font and Tailwind). In development script-src also allows 'unsafe-inline' and 'unsafe-eval' for Next.js hot-reload; in production only 'self' scripts are permitted. frame-ancestors 'none' and form-action 'self' provide additional clickjacking and form-redirect protection. base-uri 'self' prevents injected <base> tag attacks. |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Disables unused browser capabilities to reduce the attack surface. |
- In
NODE_ENV=developmentthe CSPscript-srcincludes'unsafe-inline'and'unsafe-eval'because the Next.js dev server injects inline scripts for hot-module reload. The production build strips these, relying on Next.js's hashed/external script strategy. - All other directives are identical across environments.
These headers are applied by Next.js Middleware (src/middleware.ts), which runs on every request matching /((?!_next/static|_next/image|favicon.ico|icon.jpg).*). The helper function buildSecurityHeaders() in src/lib/security/headers.ts constructs the header map and is unit-tested independently.
- Shared security state supports Redis distributed locking, but defaults to file-based for local development.
- Risk scoring remains heuristic-heavy (no external threat-intel integration).
- Stellar workflow is testnet-oriented.
- Server-side signing remains intentionally disabled.
- Full end-to-end automated coverage for the complete decision-to-payment lifecycle is still limited.
Fortexa is intentionally optimized for hackathon clarity and wallet-native control, not full production deployment.
Reviewer-facing explanation text is guarded by snapshot tests to ensure transparency and prevent accidental explanation drift across changes.
How to update snapshots:
npm run test -- src/lib/decision/engine.scenarios.test.ts --updateSnapshotFiles:
src/lib/decision/engine.scenarios.test.ts- Snapshot tests for decision explanationssrc/lib/decision/engine.test.ts- Updated summary file referencing the snapshots
Covered decision types:
- APPROVE - Safe research payment (human-readable approval message)
- BLOCK - Malicious endpoint blocked by domain policy
- WARN - Typosquat domain risk detected (caution warning)
- REQUIRE_APPROVAL - Over-budget transfer requiring manual approval
These snapshots make policy decision transparency reproducible for reviewers and protect against accidental explanation drift.
Common Stellar Horizon failures during the signed payment flow:
tx_bad_seq: The transaction sequence number is incorrect. Wait for pending transactions to clear or refresh your wallet state.tx_insufficient_fee: The provided fee is below the current network minimum. Increase the base fee.op_no_destination: The destination account does not exist on the network. Verify the destination address.op_underfunded: Your source wallet lacks the XLM necessary to complete the payment and satisfy the network base reserve.
- Add stronger risk intelligence + anomaly detection.
- Expand end-to-end payment verification and automated lifecycle tests.
MIT (see package.json).
