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 Authorization header

Same-origin via nginx/Trunk; no CORS in dev

Backend

Postgres

TCP, sqlx

DB user with SET ROLE shiftit_app per connection

RLS predicate app.tenant_id = ... set per transaction

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

force_path_style(true)

Backend

Subscriber browser (Web Push)

HTTPS

VAPID-signed payload

Via web-push crate; subscriber endpoint stored in push_subscriptions

Future Tauri mobile app

Backend (Axum)

HTTPS

Same Bearer JWT

Same /api/v1 surface — same OpenAPI doc, same DTOs

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 from gloo-storage (key: shiftit_token, JSON-encoded), prepends /api/v1, attaches Authorization: Bearer <token>, deserialises the JSON response. Maps HTTP status to ApiError (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_id on Event).

  • 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’s DateTime for 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)

  1. Browser stores any non-empty JSON-encoded string as shiftit_token in localStorage.

  2. Frontend client attaches it as Bearer.

  3. Backend auth_middleware sees DEV_AUTH_TENANT_ID is set in env → builds a synthetic TenantContext from env vars, ignoring the token entirely.

  4. Handler runs.

Target (production)

  1. Frontend redirects to Zitadel OAuth (PKCE). Not yet implemented — P0 follow-up.

  2. Zitadel redirects back with code → frontend exchanges for tokens.

  3. Frontend stores access token + refresh token; PWA service worker handles renewal (TBD).

  4. Backend auth_middleware:

    • Validates the JWT against Zitadel’s JWKS (auth/jwks.rs) — aud validation pending (P1 follow-up).

    • Looks up tenant_id by claims.org_id → tenants.zitadel_org_id.

    • Upserts the user (currently uses placeholder email; P1 follow-up: real userinfo call).

    • 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.


See also

  • architecture-backend.md — backend-internal layering

  • architecture-frontend.md — frontend-internal layering

  • api-contracts-backend.md — endpoint catalog