Integration Architecture
The two parts of this monorepo communicate only over HTTP. There is no shared Rust code, no IPC, no message bus. The frontend is a WASM client of the backend’s REST API.
Topology
┌─────────────────────────┐
│ User browser │
│ (Leptos WASM PWA) │
└────────────┬────────────┘
│ same-origin fetch
│ via /api/v1
▼
┌──────────────────────────────────┐
│ nginx (alpine) │
│ ├─ serves frontend/dist/ │
│ └─ proxies /api/v1 → backend │
│ Anubis sits in front in prod │
└────────────┬─────────────────────┘
│ HTTP
▼
┌──────────────────────────────────┐
│ Backend (Axum, Rust) │
│ ├─ JWT validation (JWKS-cached)│
│ ├─ TenantContext extraction │
│ ├─ RLS-bound DB transactions │
│ └─ web-push to subscribers │
└─┬───────────────┬────────────────┘
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│ Postgres16 │ │ RustFS │
│ (RLS) │ │ (S3 API) │
└────────────┘ └────────────┘
┌────────────────────────────────┐
│ Zitadel (OIDC issuer) │ ← external; backend only fetches JWKS
└────────────────────────────────┘
In dev, nginx is replaced by Trunk’s dev server (trunk serve), which proxies /api/* to 127.0.0.1:3010. Anubis is dev-skipped.
Integration point: /api/v1/*
| From | To | Protocol | Auth | Notes |
|---|---|---|---|---|
Frontend (Leptos PWA) |
Backend (Axum) |
HTTPS (HTTP in dev), JSON |
Bearer JWT in |
Same-origin via nginx/Trunk; no CORS in dev |
Backend |
Postgres |
TCP, sqlx |
DB user with |
RLS predicate |
Backend |
Zitadel |
HTTPS, JWKS fetch |
None (public JWKS) |
One refresh on unknown kid; P1: single-flight + rate-limit |
Backend |
RustFS |
HTTPS, S3 SigV4 |
AWS access key / secret in env |
|
Backend |
Subscriber browser (Web Push) |
HTTPS |
VAPID-signed payload |
Via |
Future Tauri mobile app |
Backend (Axum) |
HTTPS |
Same Bearer JWT |
Same |
The backend only consumes Zitadel; it never issues tokens. All token issuance is Zitadel’s job.
Frontend → Backend client
Centralised in frontend/src/api/:
-
client.rs— four functions (api_get,api_post,api_patch,api_delete). Each pulls the JWT fromgloo-storage(key:shiftit_token, JSON-encoded), prepends/api/v1, attachesAuthorization: Bearer <token>, deserialises the JSON response. Maps HTTP status toApiError(Unauthorized,NotFound,Network(),Server()). -
types.rs— DTOs that mirror backend response shapes. Fields use#[serde(default)]where forward-compat tolerance is required (e.g.User.created_at).
The frontend never speaks to anything except its own origin. There is no third-party JS, no analytics, no CDN.
DTO contract
The backend is the source of truth (every public DTO has #[derive(ToSchema)] and is in the OpenAPI doc). The frontend redeclares minimal subsets — this is intentional:
-
The frontend can ignore fields it doesn’t render (e.g.
tenant_idonEvent). -
New backend fields don’t break the frontend (serde ignores unknown fields by default).
-
The frontend can be more permissive (
Option<DateTime>) than the backend’sDateTimefor endpoints that historically didn’t return the field.
A future cleanup could codegen frontend/src/api/types.rs from the OpenAPI doc; for now it’s hand-mirrored. When changing a backend DTO, grep frontend/src/api/types.rs for the type and update the matching struct.
Auth flow (current vs. target)
Current (dev)
-
Browser stores any non-empty JSON-encoded string as
shiftit_tokenin localStorage. -
Frontend client attaches it as
Bearer. -
Backend
auth_middlewareseesDEV_AUTH_TENANT_IDis set in env → builds a syntheticTenantContextfrom env vars, ignoring the token entirely. -
Handler runs.
Target (production)
-
Frontend redirects to Zitadel OAuth (PKCE). Not yet implemented — P0 follow-up.
-
Zitadel redirects back with code → frontend exchanges for tokens.
-
Frontend stores access token + refresh token; PWA service worker handles renewal (TBD).
-
Backend
auth_middleware:-
Validates the JWT against Zitadel’s JWKS (
auth/jwks.rs) —audvalidation pending (P1 follow-up). -
Looks up
tenant_idbyclaims.org_id → tenants.zitadel_org_id. -
Upserts the user (currently uses placeholder email; P1 follow-up: real
userinfocall). -
Builds
TenantContext { user_id, tenant_id, role }and inserts into request extensions.
-
Customer form flow (the only RLS bypass)
/api/v1/f/{slug} is the customer-facing form endpoint. Since slug-to-tenant resolution must happen before the handler can validate the JWT’s tenant, this is the only call site that bypasses RLS:
1. Customer JWT arrives in Authorization header (still validated by auth_middleware). 2. Handler calls customer_forms_lookup_by_slug(uuid) — a SECURITY DEFINER function (migration 0006). 3. The function returns the form's tenant_id. 4. Handler verifies ctx.tenant_id == form.tenant_id; mismatch → 404. 5. Subsequent reads/writes use the RLS-bound transaction normally.
Every other endpoint relies on RLS: cross-tenant access returns no rows (which the handler maps to 404), not 403.
What does NOT cross the boundary
-
No shared types. The redeclaration pattern is by design (see DTO contract above).
-
No WebSockets. Push notifications use Web-Push, not WS. In-app inbox is polled (
GET /notifications). -
No SSR. Frontend is pure CSR; SEO / first-paint optimisation isn’t a concern given the auth-gated nature of every page.
-
No GraphQL. Plain REST + JSON.