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
| Repository | Responsibility | Never does |
|---|---|---|
wallet-edge | The 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-api | Global identity (app.user_profiles), organizations, workspaces (= events), PDV/cashier accounts, global wallet directory. | Never writes balance, ledger, or any financial data. |
wallet-api | One instance per region. Owns app.wallets, app.wallet_balances, ledger.* for that event. | Never decides global identity or organization/permission. |
wallet-admin | Management frontend. | — |
wallet-cashier | Cashier 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/*→ alwayswallet-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 specificeventId's region, via the regionalwallet-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
| Scheme | Who signs | Who verifies | Signature header |
|---|---|---|---|
| Origin signature | wallet-edge | the origin API (InternalOriginGuard) | X-Origin-Signature |
| Control-plane signature | wallet-control-api ↔ wallet-api (both directions) | ControlPlaneGuard on the receiving side | X-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.