This document describes the architecture as currently implemented in this repository, not an idealized target state. This file is intended for technical reviewers; see
README.mdfor product framing and demo-first flow.
Fortexa is a policy-controlled payment firewall for agent-triggered actions on Stellar.
It inserts a decision layer between agent intent and transaction submission.
Core intent:
- authenticate users via wallet-bound session
- evaluate requested actions against policy + risk heuristics
- allow signed XDR submission only through authenticated operator flows
- preserve audit evidence for traceability
- Wallet challenge-signature login (
/api/auth/challenge+/api/auth/login) - Role-based route protection (
operator,viewer) - Policy + security-based decisioning
- Signed XDR submission to Stellar Testnet
- Audit/history persistence with DB-first fallback
- Basic health/metrics endpoints for ops visibility
- Server-side signing
- Private-key custody
- Mainnet-first transaction flow
- Distributed, strongly consistent security state by default
flowchart LR
UI[Next.js App UI\n/pages + client components] --> API[Next.js API Routes]
API --> AUTH[Auth + Session]
API --> DEC[Decision Engine]
API --> POL[Policy Store]
API --> AUD[Audit Store]
API --> STELLAR[Stellar Client]
API --> OBS[Metrics + Logs]
POL --> DB[(Postgres if available)]
AUD --> DB
AUTH --> DB
POL --> FILE[JSON fallback\nlocal .fortexa / Vercel /tmp/fortexa]
AUD --> FILE
AUTH --> FILE
STELLAR --> HZN[Horizon Testnet]
API --> GROQ[Groq API /agent/plan]
flowchart TB
subgraph Untrusted[Untrusted Zone]
Browser[Browser + User Input]
WalletExt[Wallet Extension\n(Freighter etc.)]
end
subgraph AppBoundary[Fortexa Application Boundary]
WebUI[Next.js UI]
ApiRoutes[API Routes + Validation]
Authz[Session + Role Authorization]
Decisioning[Policy + Risk Decisioning]
Storage[DB/File Persistence]
Observability[Metrics + Logs]
end
subgraph External[External Systems]
Horizon[Stellar Horizon Testnet]
Groq[Groq API]
end
Browser --> WebUI
WebUI --> ApiRoutes
ApiRoutes --> Authz
ApiRoutes --> Decisioning
ApiRoutes --> Storage
ApiRoutes --> Observability
WebUI <-- signed XDR --> WalletExt
ApiRoutes --> Horizon
ApiRoutes --> Groq
src/app/api/auth/*: wallet login, session issue/refresh/logout/session lookup.src/lib/auth/session.ts: signed cookie session token (fortexa_session).src/lib/auth/require-auth.ts: role-based auth guard for protected APIs.src/app/api/decision/route.ts: evaluates action, applies optional human-approval override, appends audit record.src/app/api/policy/simulate/route.ts: read-only pre-save check; compares current vs proposed policy decisions without persisting.src/lib/decision/engine.ts: combines policy checks + risk findings into decision outcome.src/lib/decision/simulate.ts: pure helper that evaluates demo scenarios + a recent-audit sample against current and proposed policy.src/lib/policy/engine.ts: deterministic policy rules (caps, tools, domains, hours, thresholds).src/lib/security/analyzer.ts: heuristic risk findings + risk score.src/app/api/stellar/build-payment/route.ts: builds unsigned TESTNET payment XDR.src/app/api/stellar/submit-signed/route.ts: submits signed XDR, returns tx hash + explorer link.src/lib/stellar/client.ts: Horizon calls, XDR construction, XDR submission.src/lib/storage/*-store.ts: policy/audit/user-wallet persistence with DB fallback.src/lib/storage/db.ts: optional Postgres connector + migration bootstrap + graceful fallback.src/app/api/metrics/route.ts+src/lib/observability/metrics.ts: JSON and Prometheus metrics.
- Client posts wallet
publicKeyto/api/auth/challenge. - Server returns
challengeId,message, andexpiresAt(one-time, short-lived). - Client signs the challenge message with the wallet (SEP-53 via Freighter
signMessage). - Client posts
publicKey,challengeId, andsignatureto/api/auth/login. - Server verifies signature, enforces challenge expiry + one-time use, then resolves role via allowlists.
- Lockout + rate-limit checks apply on login verification failures.
- Session token is issued in
fortexa_sessioncookie. - Wallet mapping is upserted for that
userId.
Honest note: if both allowlists are empty, current behavior allows any valid wallet as operator (developer-friendly, not production-safe).
sequenceDiagram
autonumber
participant U as User/Browser
participant CH as /api/auth/challenge
participant API as /api/auth/login
participant RL as Rate Limit + Lockout
participant AUTH as Session Signer
participant UW as user-wallet-store
U->>CH: POST { publicKey }
CH-->>U: challengeId + message + expiresAt
U->>U: signMessage(challenge) via Freighter
U->>API: POST { publicKey, challengeId, signature }
API->>RL: consumeRateLimit + isLoginLocked
alt Limited or locked
RL-->>API: deny
API-->>U: 429/423 + headers
else Allowed
API->>API: verify challenge + SEP-53 signature
alt Invalid/expired/replayed challenge or bad signature
API->>RL: register failure (bad signature)
API-->>U: 400/401
else Signature verified
API->>API: resolve role via allowlists
alt Unauthorized wallet
API->>RL: register failure
API-->>U: 401
else Authorized wallet
API->>UW: upsertUserWallet(userId, publicKey)
API->>AUTH: createSessionToken(userId, role)
API-->>U: 200 + fortexa_session cookie
end
end
end
- Authenticated operator calls
/api/decision. - Request is schema-validated.
- Policy is loaded (
policy-store) and current usage is loaded (audit-store). - Decision engine computes
BLOCK | REQUIRE_APPROVAL | WARN | APPROVE. - Optional
approvedByHumancan elevateREQUIRE_APPROVALtoAPPROVE. - Usage is consumed only for
APPROVE/WARN. - Audit entry is persisted.
Operators can dry-run an unsaved policy draft before committing it, so the impact of a change is visible up front.
- Operator edits the policy in the UI and clicks Run simulation (optionally including a recent-audit sample).
- The draft is posted to
/api/policy/simulateand schema-validated. - The route loads read-only snapshots of the current policy (
policy-store) and usage (audit-store); it builds cases from the seeded demo scenarios and, optionally, the newest few audit actions for that operator. simulatePolicyChangeevaluates each action against both the current and proposed policy and reportscurrent → proposeddecisions plus a changed-count summary.- Nothing is written: the policy is not saved and usage is not consumed. Saving still happens only via
/api/policy.
The audit sample is intentionally small and deterministic (newest-first, capped) to keep simulations cheap and reviewable.
- Build unsigned transaction via
/api/stellar/build-payment. - User signs in external wallet (e.g., Freighter).
- Signed XDR is sent to
/api/stellar/submit-signed. - API validates input and auth (
operator), submits to Horizon Testnet. - Response includes tx hash, ledger, and testnet explorer URL.
Honest note: no server-held secret signs transactions.
sequenceDiagram
autonumber
participant U as Operator UI
participant BP as /api/stellar/build-payment
participant W as Wallet Extension
participant SS as /api/stellar/submit-signed
participant H as Horizon Testnet
U->>BP: POST payment request
BP-->>U: unsigned XDR
U->>W: request signature(unsigned XDR)
W-->>U: signed XDR
U->>SS: POST signedXdr
SS->>H: submitTransaction
alt Horizon success
H-->>SS: hash, ledger, result_xdr
SS-->>U: 200 + explorerUrl
else Horizon failure
H-->>SS: error + result_codes
SS-->>U: 500 + parsed tx/op codes
end
/api/stellar/setup currently acts as a session-wallet bootstrap/sync helper.
It does not enable arbitrary manual wallet linking; it syncs the wallet already present in session context.
policy-store: policy config + version historyaudit-store: per-user audit records + daily usage countersuser-wallet-store: user-to-wallet mapping
erDiagram
FORTEXA_POLICY_STATE ||--o{ FORTEXA_POLICY_HISTORY : versions
FORTEXA_USAGE ||--o{ FORTEXA_AUDIT_ENTRIES : per_user
FORTEXA_POLICY_STATE {
int id PK
int version
timestamptz updated_at
jsonb policy
}
FORTEXA_POLICY_HISTORY {
int version PK
timestamptz updated_at
text updated_by
jsonb policy
}
FORTEXA_USAGE {
text user_id PK
numeric spent_xlm
int tool_calls
timestamptz last_updated
}
FORTEXA_AUDIT_ENTRIES {
uuid id PK
text user_id
timestamptz timestamp
jsonb payload
text entry_hash
}
FORTEXA_USER_WALLETS {
text user_id PK
text public_key
text source
text provider
timestamptz updated_at
}
Each audit entry appended via appendAuditEntry is enriched with two fields before persistence:
previousHash— theentryHashof the most recent already-stored entry for the same user, or the 64-zero genesis sentinel (0000…0000) if no prior hashed entry exists.entryHash— a SHA-256 hex digest computed over a canonicalized JSON object that includesid,timestamp,action,decision,explanation,triggeredPolicies,riskFindings,stellarTxHash, andpreviousHash. All object keys are sorted before hashing to guarantee DB-stored and file-stored entries produce identical digests.
The entry_hash column on FORTEXA_AUDIT_ENTRIES (migration 002_audit_hash_chain) holds this value for fast lookup of the previous hash during inserts.
Verification — verifyHashChain(entries, boundaries?) (src/lib/audit/hash-chain.ts):
- Sorts entries chronologically by
timestamp. - Skips entries without
entryHash/previousHash(legacy, pre-chain entries). - For each hashed entry, checks that
previousHashequals the preceding hashed entry'sentryHash(orGENESIS_HASHfor the first). - Recomputes
entryHashfrom current field values and compares to the stored value. - When an export includes
chainBoundary(orchainBoundariesByUserfor an all-user export), checks the expected first and last hashes. A declared first boundary also allows verification of filtered segments whose firstpreviousHashis not the genesis sentinel. - Returns
{ valid: false, reason, entryId }on the first violation found.
This detects:
- Modified entries — any field change invalidates
entryHash. - Deleted entries — the next entry's
previousHashno longer matches; explicit export boundaries also detect deletion of the first or last exported record. - Reordered entries — the chain order breaks even if timestamps are adjusted.
Limitations: the chain is append-only tamper evidence, not a cryptographically signed ledger. An adversary with full write access to the store can rewrite the entire chain. The design goal is detection of unintended or casual tampering, not adversarial forgery.
- If
DATABASE_URLis set and DB is reachable: uses Postgres tables. - On DB unavailability or operation failure: falls back to local JSON files.
- local/dev default:
.fortexa/ - Vercel default:
/tmp/fortexa/ - optional override:
FORTEXA_STORE_DIR
- local/dev default:
This fallback is intentional for local resilience, but introduces consistency tradeoffs in multi-instance deployments.
- Migration definitions:
src/lib/storage/migrations.ts - Runtime bootstrap:
src/lib/storage/db.ts - Script:
npm run db:migrate
Stored policy JSON (current state + history) can outlive the active schema. To prevent silent corruption when policyConfigSchema evolves:
- Valid current payloads parse unchanged.
- Older payloads missing only documented optional fields are migrated by
parseStoredPolicy(src/lib/policy/migrations.ts), which fills the safe defaults listed inOPTIONAL_DEFAULTSand reports the exact set of migrations applied. - Malformed payloads (wrong types, unsafe values, mixes of missing-optional and non-rectifiable issues) fail closed with a structured
{ ok: false, error, issues }result. Every Zod issue is surfaced with its path and message — no defaults are silently substituted for non-optional fields. - The strict schema is never weakened for backwards compatibility. Migrating a new optional field is an explicit one-line change to
OPTIONAL_DEFAULTSplus a corresponding fixture undersrc/lib/policy/__fixtures__/. parseStoredPolicyis a pure helper: it does not read or rewrite.fortexa/policy files. Callers decide whether to persist a migrated policy, reject one, or surface the error.- Smoke coverage lives at
src/lib/policy/migrations.test.ts.
- Browser/UI is untrusted input surface.
- API routes enforce schema validation + auth.
- Session cookie is signed; authz is checked per protected route.
- Stellar signature material remains client-side in external wallet.
- Horizon/Groq are external dependencies.
- Per-route rate limiting (
src/lib/security/rate-limit.ts) - Login lockout on repeated failures (
src/lib/auth/login-lockout.ts) - Optional shared file-backed state (
FORTEXA_SHARED_STATE_PATH) for cross-process limiter/lockout state - Role gating (
requireAuth) for sensitive APIs
- No Redis-backed distributed rate limiter by default
- No HSM/KMS signing flow (intentionally)
- No advanced fraud intelligence feed integration
stateDiagram-v2
[*] --> Evaluate
Evaluate --> BLOCK: policy/risk hard fail
Evaluate --> REQUIRE_APPROVAL: threshold crossed
Evaluate --> WARN: soft concerns
Evaluate --> APPROVE: clean pass
REQUIRE_APPROVAL --> APPROVE: approvedByHuman=true
REQUIRE_APPROVAL --> REQUIRE_APPROVAL: no manual approval
WARN --> Audited
APPROVE --> Audited
BLOCK --> Audited
REQUIRE_APPROVAL --> Audited
Audited --> [*]
- Structured request-aware responses/log context via
jsonWithRequestContextand logger utilities /api/healthfor health checks/api/metricsfor JSON snapshot/api/metrics?format=prometheusfor scrape-compatible format- Ops UI consumes these APIs for dashboarding
- DB down or flaky: storage layer logs warning and falls back to file store.
- Horizon rejection: submit endpoint returns enriched error with result codes when available.
- Auth failures: role/auth errors short-circuit protected routes.
- Rate limit exceeded: API returns
429with rate-limit headers. - Invalid session wallet state: setup/balance flows return validation errors or require resync.
- Deployment model is still effectively single-node oriented unless external shared state is configured.
- Shared security state is file-backed, not distributed by default.
- Decision risk scoring is heuristic and policy-driven, not ML threat-intel driven.
- Transaction flow is testnet-centric (
Networks.TESTNET, testnet explorer links). - End-to-end automated coverage for full decision-to-payment lifecycle is still limited.
Near-term, high-impact improvements:
- move limiter/lockout shared state to Redis for true multi-instance consistency
- add deeper post-submit reconciliation and payment state tracking
- expand full-path integration/e2e coverage across decision + signing + submission + audit
If this file and README.md disagree, treat this file as implementation-level detail and update both together in the same PR.