Herald

Architecture

Rust backend (hexagonal architecture) + React frontend (TanStack stack), deployed as a single process.

Rust backend (hexagonal architecture) + React frontend (TanStack stack), deployed as a single process.

Directory Structure

backend/
├── entity/              # SeaORM entity definitions (database table mappings)
├── domain/              # Domain layer — pure business logic, zero external dependencies
├── infra/               # Infrastructure layer — concrete implementations for DB, Redis, third-party APIs
├── infra-creem/         # Creem payment integration
├── infra-iap/           # In-app purchase integration (Apple App Store / Google Play)
├── infra-stripe/        # Stripe payment integration
├── infra-wechatpay/     # WeChat Pay integration
├── core/                # Assembly layer — dependency injection, ApplicationService Builder
├── api/                 # Main API crate (Axum route registration, middleware, AppState)
├── api-base/            # Shared API utilities (AppState definition, common HTTP helpers)
├── api-billing/         # Billing handlers (credit accounts, entitlement mappings, subscriptions, payments, invoices, webhooks)
├── api-admin/           # Admin panel handlers (user management, roles, permission definitions)
├── api-auth/            # Auth handlers (registration, login, password reset)
├── api-ext/             # External API handlers (API Key auth, for third-party consumption)
├── api-mcp/             # MCP server endpoints (agent-facing read-only tools, OAuth user credentials)
├── api-oauth/           # OAuth/OIDC handlers (authorize/token, social login, device code, discovery, MCP metadata)
├── api-points/          # Points handlers (balance queries, consumption, top-up)
├── worker/              # Background jobs (points expiration, points pre-grant, invoice overdue marking)
├── app/                 # Entry point (main.rs, database migrations)
├── test-db/             # Test database utilities (testcontainers)
├── test-support/        # Test helpers
└── integration-tests/   # Integration tests

frontend/                # React admin frontend
sdk/                     # SDKs for third-party integrators (rust/, node/, web/)
docker/                  # Dockerfile

Tech Stack

Backend: Rust 2024 edition + Axum 0.8 + SeaORM 1.1 + sqlx 0.8 + PostgreSQL 16+ + Redis + Tokio. Frontend: React 19 + TypeScript + TanStack Router/Query/Form + Tailwind CSS v4 + Vite 7.

These aren't picked to follow trends. Each choice solves a concrete problem:

  • Axum over actix-web: native tokio runtime, clean integration with the tower middleware ecosystem, and trait-based handlers that read better than macro-based routing.
  • SeaORM alongside sqlx: SeaORM handles routine CRUD (the entity crate is auto-generated), while sqlx deals with complex queries and migrations. Both point at the same PostgreSQL — SeaORM sits on top of sqlx under the hood.
  • Redis: token storage (access/refresh token families), permission caching, rate limiting (Redis Functions), idempotency keys. Wrapped in a custom RedisConnectionManager that automatically switches to DB 1 during tests for data isolation.
  • TanStack Router: file-based routing with type safety. Path parameters like $realmId get their types inferred at compile time. More reliable than react-router's runtime matching.
  • API type generation: the backend exports OpenAPI JSON via utoipa, and the frontend generates TypeScript clients and types with @hey-api/openapi-ts. When the backend API changes, one command — npm run generate-api — brings the frontend types back in sync.

Core Request Flow

A typical request travels through these layers:

HTTP Request
  → Axum Router (route matching)
  → inject_identity middleware (resolves user identity from the Authorization: Bearer token)
  → Handler (extracts AppState, parses request body)
  → Domain Service (business logic, calls Repository through a trait)
  → Repository implementation (infra layer, talks to PostgreSQL or Redis)
  → SeaORM Entity / sqlx query
  → PostgreSQL

Handlers never write SQL. They call methods on Domain Services, which access data through trait-based ports. The infra layer provides concrete implementations. This is the central constraint of hexagonal architecture: domain/ pulls in sea-orm, sqlx, etc. in its Cargo.toml, but only for error type conversions (From impls). The domain layer does not directly query databases or make HTTP requests.

Permission checks follow a separate path: Handler → RedisPermissionChecker → Redis cache → PostgreSQL (on cache miss). The permission model uses resource:action pairs (e.g., product:read), scoped by realm_id and client_id. Actions have a hierarchy: manage covers view, create, and manage itself; create and view only cover themselves.

Module Breakdown

entity — Database Table Mappings

Auto-generated SeaORM entity definitions. Covering users, roles, permissions, subscriptions, points, payments, invoices, and more. Each .rs file maps to one table, containing column definitions, relations, and defaults.

No business logic lives here. To change a table schema, write a migration SQL file first (backend/app/migrations/), then regenerate the entities.

domain — Domain Layer

Pure business logic. Cargo.toml primarily depends on foundational crates (serde, uuid, chrono, bcrypt, etc.), but also pulls in sea-orm, sqlx, redis, reqwest, and axum — these are used solely for From<ExternalError> error conversions. The domain layer does not directly query databases or make HTTP requests.

Key submodules:

ModuleResponsibility
authenticationLogin, registration, session management
authorizationRBAC permission model (roles, policies, permission definitions)
auditAudit event model and collection (user management, RBAC changes, auth events)
billingCredit account directory, entitlement mappings, subscription projection, payment webhook handling
pointsPoints accounts (pooled per credit account), top-up, consumption, expiration, idempotency
payment_attemptUnified payment attempts (abstracting over payment channels)
purchasePurchase fulfillment (one-time top-up or subscription activation)
realmTenant management (manual creation by Admin Realm admins, plus a public self-service signup entry hosted on the admin realm — /api/auth/admin/signup — that lets an unauthenticated visitor provision a new realm and become its admin)
realm_configTenant configuration (payment provider secrets, etc.)
clientThird-party application management
client_appClient App entity and logic
client_api_keysAPI Key management
oauthOAuth provider configuration
userUser entity and queries
user_totpTOTP two-factor authentication
totp_key_managementTOTP key management
rbac_initDefault roles and permissions for new realms
dashboardDashboard data aggregation
commonShared domain utility types
security_constantsSecurity-related constants

Each submodule contains ports/ (trait definitions), entities/ (domain entities), and service.rs (business logic). Ports define Repository traits with methods like find_by_id, save, and update — without caring whether the backing store is PostgreSQL or in-memory.

infra — Infrastructure Implementations

Concrete implementations of domain layer traits, named PostgresXxxRepository — one struct per trait.

Beyond database repositories:

  • redis/ — RedisConnectionManager, connection pool with test isolation
  • authorization/ — RedisPermissionChecker, permission caching
  • billing/ — invoice PDF generation (IronPress), encryption key management

Payment channel clients live in separate crates (infra-creem, infra-iap, infra-stripe, infra-wechatpay) rather than inside the main infra crate. The reason is to avoid pulling in provider SDKs you don't need — if you only use Stripe, the WeChat Pay SDK won't get compiled.

core — Assembly Layer

Two responsibilities:

  1. ApplicationService Builder: wires domain services together with infra repositories. Builder pattern — inject database, redis, permission_checker in sequence, then call .build() to produce an ApplicationService.
  2. Re-exports: herald_domain becomes domain, herald_entity becomes entity, herald_infra becomes infrastructure. Other crates can write use herald_core::domain::xxx directly.

api crates — HTTP Interface

Nine crates form the API layer:

CrateResponsibilityAuth Method
apiMain entry: route registration, middleware orchestration, Swagger UI, OpenAPI spec merging, audit log queries—
api-baseAppState definition (shared across all api sub-crates)—
api-authRegistration, login, password reset, email verificationBearer token (public endpoints + token issuance)
api-adminUser CRUD, role management, permission definition managementBearer token (FirstParty) + inject_identity
api-billingCredit account directory, entitlement mappings, subscription projection, payment webhooks (Stripe/Creem), invoices, one-time purchasesmixed
api-oauthOAuth authorization server: authorize/token endpoints (authorization code + PKCE, refresh), OIDC discovery / id_token / JWKS / userinfo, Device Code Grant (RFC 8628), social login (GitHub/Google/WeChat), MCP resource metadata and pass-through client registrationmixed
api-extThird-party API: permission checks, subscription queries, points balance and consumption, isolated by the API key's bound client appAPI Key
api-mcpMCP server for AI agent clients: nine read-only tools, five admin-plane queries (users, points balance/transactions, audit logs, realm config status) gated by the authorizing user's RBAC and four self-plane queries (my profile, points balance, transactions, subscriptions) gated by MCP scopesOAuth user credentials (MCP audience)
api-pointsPoints balance, transaction history, consumption, top-upBearer token or API Key

api crate's create_api_routes() is the single entry point for route registration. It nests sub-crate routes under unified prefixes and attaches the inject_identity middleware. Each sub-crate defines its own ApiDoc (utoipa OpenApi spec), which get merged into one complete OpenAPI document in build_openapi_spec().

Splitting into multiple crates is a compile-time optimization. api-billing is the heaviest crate (webhook handling, type definitions). Changing one payment channel's handler should not force api-auth to recompile.

worker — Background Jobs

Recurring tasks that run in the same process as the API server:

  • Points expiration: hourly scan for expired points, batch-marked as expired
  • Points pre-grant: every 5 minutes, scans grant schedules due soon (subscription renewals, free periodic grants) and pre-grants them ahead of their effective time, absorbing webhook and scheduling latency. This is a performance optimization, not a correctness guarantee — even when this job is not running, balance queries and consumption sync-grant due credits on the request path
  • Invoice overdue marking: hourly scan for unpaid invoices, marked as overdue

WorkerConfig is generic over R: InvoiceRepository, making it straightforward to inject mocks during testing. Production uses PostgresInvoiceRepository.

app — Entry Point

main.rs follows a fixed startup sequence:

  1. Parse CLI arguments. --export-openapi <path> exports OpenAPI JSON and exits (used in CI before frontend builds)
  2. Load configuration (HERALD_CONFIG environment variable or config.toml)
  3. Initialize tracing
  4. Connect to PostgreSQL (SeaORM connection pool, parameters from config)
  5. Run database migrations (sqlx migrate)
  6. Connect to Redis and run a health check
  7. Initialize the points expiration service (points repository + expiration service)
  8. Start the API server (herald_api::run_with_config, which initializes all services and binds Axum routes internally)
  9. Start the Worker (recurring job loop)

Steps 8 and 9 run concurrently. tokio::select! waits for either one to finish or a shutdown signal (Ctrl+C or SIGTERM).

sdk — third-party SDKs

Three packages under sdk/, published separately (.github/workflows/publish-sdk.yml, publish-node-sdk.yml, publish-web-sdk.yml):

  • sdk/rust (crate herald-sdk) — server-side Rust wrapper over /api/ext/: check_permission (moka local cache with a 5-minute TTL; cache entries are cleared in batch when a token expires), get_subscription by entitlement_key, get_balance / consume_points. The constructor takes base_url and api_key; all requests include the X-API-Key header automatically.
  • sdk/node (npm package herald-sdk) — the TypeScript counterpart with the same API surface and caching behaviour, plus realm / user / client-app administration over the external API. Zero runtime dependencies (native fetch, Node 18+).
  • sdk/web (npm package herald-auth-web) — browser SDK for end-user authentication on third-party pages: register, email verification, password reset, login (TOTP / passkey / email-OTP second factors), silent access-token refresh, logout. Framework-agnostic, ESM-only, with an IIFE bundle for <script> / CDN integration.

Each API key is explicitly bound to a client app at creation (the binding is required — there is no default). A key bound to the built-in admin-api-client (which seeds disabled on new realms and must be enabled by a realm admin) is realm-wide and can access external API resources for any client app in the same realm. A key bound to an ordinary client app can only access permission checks, subscriptions, and points for that client app. api-ext enforces this scope for subscription, points, and permission-check endpoints. API key authentication rechecks the bound client app's enabled state even on cache hits, so disabling a client app immediately blocks its API keys.

frontend — React Admin Panel

File-based routing via TanStack Router. The route structure mirrors the frontend/src/routes/ directory:

$realmId/
├── auth/          # Login, registration, email verification
├── user/          # User profile (settings, security, points, subscriptions, invoices)
├── manage/        # Admin panel (users, roles, permissions, entitlement mappings, billing, points, audit logs, Client Apps, settings)
├── points/        # Points balance and transaction history
├── subscription/  # User subscription status
└── device/        # Device Code authorization page

All API calls go through auto-generated clients in frontend/src/lib/api-generated/. When backend endpoints change, run npm run generate-api (which exports OpenAPI JSON, then runs openapi-ts to generate TypeScript types) to regenerate.

TanStack Query manages server state. TanStack Form + Zod handles form validation. Radix UI provides accessible low-level components (Dialog, Select, Tabs, etc.). Tailwind CSS v4 for styling.

Database Migrations

Timestamped SQL files in backend/app/migrations/. Migrations run automatically at startup via sqlx::migrate!. No manual scripts needed.

On this page