Skip to main content

Architecture overview

WalletsApp is split into independent repositories, each with a single responsibility. None of them accesses another one's database directly — communication between them is always signed HTTP.

Client (wallet-admin, wallet-cashier, participant app, PDV)


wallet-edge ──── single public entry point

├── global route ─────────────► wallet-control-api
│ (identity, directory, management)

└── regional route (by eventId) ► wallet-api
(that event's wallets, balances, ledger)

The repositories

RepositoryResponsibilityNever does
wallet-edgeThe single public entry point. Does a preliminary JWT check, applies CORS/CSRF/Turnstile/rate limiting, decides whether a route is global or regional, and signs the origin call.Holds no data at all; is not the final authority on authentication.
wallet-control-apiGlobal identity (app.user_profiles), organizations, workspaces (= events), PDV/cashier accounts, global wallet directory.Never writes balance, ledger, or any financial data.
wallet-apiOne instance per region. Owns app.wallets, app.wallet_balances, ledger.* for that event.Never decides global identity or organization/permission.
wallet-adminManagement frontend.
wallet-cashierCashier frontend.

Why "global" and "regional"

An event (workspace) is provisioned in a physical region (home_region + cluster_key). Everything financial about that event — wallets, balances, ledger — lives in the regional database for that region, not the global one. This exists so financial data stays physically close to where the event happens, and so one region can go down without taking global identity/login with it.

wallet-edge decides routing from the shape of the URL:

  • /v1/auth/*, /v1/wallet/*, /v1/management/*, /v1/directory/* → always wallet-control-api (global). /v1/wallet/* groups the global resources exclusive to the participant (wallet discovery, event lookup by code), the same way /v1/management/* groups the ones exclusive to management — the prefix alone tells you whose resource it is.
  • /v1/events/{eventId}/* → resolved to that specific eventId's region, via the regional wallet-api.

This has a practical consequence: never nest a global discovery endpoint under /v1/events/{eventId}/..., because the Edge will try to treat any segment there as a real eventId and attempt to resolve its region. That's why GET /v1/wallet/events/by-code/{code} (find an event by code) lives under /v1/wallet, not under /v1/events.

The global wallet directory

global.user_wallet_directory (in wallet-control-api) is a discovery mirror, never the source of balance. When the regional wallet-api activates a wallet, it enqueues a wallet.activated event on its own outbox (ops.outbox_events); a background dispatcher (same process, setInterval, no external cron) sends this to wallet-control-api via a call signed with CONTROL_PLANE_SECRET. See Authentication and profiles to understand that signature, and the Participant section for GET /v1/wallet/wallets using this directory.

Internal signatures — two different schemes

SchemeWho signsWho verifiesSignature header
Origin signaturewallet-edgethe origin API (InternalOriginGuard)X-Origin-Signature
Control-plane signaturewallet-control-apiwallet-api (both directions)ControlPlaneGuard on the receiving sideX-Control-Signature

The control-plane signature is symmetric: the same function signs on both sides, with the same secret (CONTROL_PLANE_SECRET). wallet-control-api uses it to provision an event in the region; wallet-api uses the same scheme, in the opposite direction, to sync the wallet directory.