Skip to content

Architecture

Floh is a multi-step workflow orchestration platform. Internal administrators design and manage workflows through an admin UI, while external users (invitees, approvers, task assignees) interact through a separate public portal. The platform integrates with OIDC identity providers, SMTP servers, and arbitrary external services via a pluggable connector system.

System Context

graph LR
    Admin["Admin User"]
    External["External User"]
    IdP["OIDC Provider"]
    SMTP["SMTP Server"]
    ExtSvc["External Services"]
    AI["AI Tools (MCP)"]

    Admin --> AdminUI["Admin UI"]
    External --> PortalUI["Portal UI"]
    AI --> MCP["MCP Server"]

    AdminUI --> ConsoleBFF["Console BFF"]
    ConsoleBFF --> Server["API Server"]
    PortalUI --> PortalBFF["Portal BFF"]
    PortalBFF --> Server
    MCP --> Server

    Server --> DB["PostgreSQL / MySQL"]
    Server --> Redis["Redis"]
    Server --> IdP
    Server --> SMTP
    Server --> ExtSvc

Packages

Package Name Role
packages/server @floh/server Fastify REST API, workflow engine, BullMQ worker
packages/web @floh/web Angular admin UI with workflow designer, reports, and full management
packages/portal-bff @floh/portal-bff Authifi BFF config tests for portal and console OIDC gateways
packages/portal-web @floh/portal-web Minimal Angular UI for external users (tasks, approvals, invitations)
packages/shared @floh/shared TypeScript types and constants shared across packages
packages/mcp @floh/mcp Model Context Protocol server exposing Floh to AI tools

Dependency Graph

graph TD
    shared["@floh/shared"]
    server["@floh/server"]
    web["@floh/web"]
    portalBff["@floh/portal-bff"]
    portalWeb["@floh/portal-web"]
    mcp["@floh/mcp"]

    server --> shared
    web --> shared
    portalWeb --> shared
    portalBff -.->|"HTTP proxy"| server
    mcp -.->|"HTTP client"| server

Solid arrows are compile-time workspace:* dependencies. Dashed arrows are runtime HTTP connections.

Request Flows

Admin Path

sequenceDiagram
    participant Browser
    participant Web as Admin UI
    participant ConsoleBFF as Console BFF
    participant Server as API Server
    participant DB as PostgreSQL
    participant Redis

    Browser->>Web: GET /
    Web-->>Browser: SPA assets
    Browser->>ConsoleBFF: /bff/login or /bff/session
    Browser->>ConsoleBFF: /api/* (console session)
    Note over ConsoleBFF: Attach Bearer token server-side
    Note over ConsoleBFF: Tokens never exposed to JavaScript
    ConsoleBFF->>Server: Forward proxied /api/* request
    Server->>DB: Query
    Server->>Redis: Session / queue
    Server-->>ConsoleBFF: JSON response
    ConsoleBFF-->>Browser: JSON response

Portal Path

sequenceDiagram
    participant Browser
    participant PortalWeb as Portal UI (nginx)
    participant BFF as Portal BFF
    participant Server as API Server

    Browser->>PortalWeb: GET /
    PortalWeb-->>Browser: SPA assets
    Browser->>BFF: /bff/login or /bff/session
    Browser->>BFF: /api/* (portal session)
    Note over BFF: Authifi BFF gateway
    Note over BFF: Attach Bearer token server-side
    Note over BFF: Tokens never exposed to JavaScript
    BFF->>Server: Forward proxied /api/* request
    Server-->>BFF: JSON response
    BFF-->>Browser: Passthrough

The portal BFF is the portal OIDC relying party. It runs as a prebuilt Authifi bff-gateway image; the pinned tag lives in the compose files under docker/ rather than here, since it is upgraded per environment. Its committed config keeps bff.exposeTokens=false, bffProxy.ws=false, and bffProxy.allowlist.enabled=false, so Floh does not currently claim unknown /api/* paths return 404 at the gateway. Browser-facing portal traffic is always HTTPS. Floh-owned defaults are HTTPS-first, the Palantir deployment also keeps Caddy -> BFF and BFF -> API on HTTPS, and other deployments may explicitly choose trusted-ingress or private-network HTTP for internal hops. See Portal.

Server Internals

Plugin Chain

Fastify plugins are registered in packages/server/src/app.ts:

  1. CORS — origin whitelist with credentials
  2. Multipart — file uploads
  3. Cookie — session cookies
  4. Rate Limit — 200 req/min default
  5. Swagger — OpenAPI docs at /api/docs
  6. CSRF — double-submit cookie (when OIDC enabled)

Decorators attach shared instances (db, redis, config, logService, schedulerService, escalationService) to the Fastify app instance.

Module Organization

Each domain lives under packages/server/src/modules/:

Module Responsibility
auth OIDC login/callback, sessions, JWT, guards, API tokens
workflows Definition CRUD, engine, step executor, graph walker, lifecycle
tasks Step/task management for running workflows
approvals Approval routing, decisions, escalation
connectors Registry, execution dispatch, OAS parser, script sandbox
scheduler BullMQ queue, cron triggers, delayed jobs
notifications Email (Handlebars templates) and in-app notifications
roles Role definitions, entitlements, assignments
audit Immutable audit log, checkpoints
reports Report templates, saved reports, scheduled delivery
documents Document templates and submissions
organizations Multi-org support and memberships
escalation Reminder and reassignment logic
health Health check endpoint

Workflow Engine

The engine (modules/workflows/engine.ts) executes runs synchronously with a Redis distributed lock per run:

  1. Acquire lock floh:run-lock:{runId}
  2. Load run and definition, build step graph
  3. Walk steps via graph transitions (max 1000 steps per pass)
  4. Delegate to StepExecutor by step type: action, condition, connector, approval, notification, consent, document submission, role grant/revoke, fork, join, sub-workflow
  5. Steps that require external input (approval, consent, document submission) return a waiting_* status and pause the run
  6. When external input arrives, the engine resumes from the waiting step
  7. Fork/join steps enable parallel branches with barrier synchronization

See System Architecture and Fork/Join Parallel Branches.

Background Jobs

Background work is distributed across four domain-specific BullMQ queues, each with independent concurrency and retry settings:

Queue Concurrency Jobs
workflow-execution 5 trigger-workflow
escalation 10 escalation-reminder, escalation-reassignment, ticket-sla-warning, ticket-sla-breach
lifecycle 3 role-expiry-check, document-expiry-check, entitlement-reconciliation, audit-checkpoint, run-orphan-cleanup, stuck-run-recovery, pam-session-expiry, consent-expiry-check, tls-cert-check
integrations 3 deliver-scheduled-report, connector-resource-sync, sync-workflow-trigger

The canonical job-to-queue mapping is JOB_QUEUE_MAP in packages/server/src/modules/scheduler/queue-config.ts; resolveQueue throws for any job name absent from it, so a new job cannot be enqueued without being assigned a queue.

Scheduled jobs:

Job Schedule Purpose
trigger-workflow Per-schedule cron Start workflow runs on schedule
escalation-reminder Delayed Send approval reminder notifications
escalation-reassignment Delayed Reassign overdue approvals
role-expiry-check Hourly Revoke expired role assignments
document-expiry-check Hourly Flag expired documents
entitlement-reconciliation Daily 2:00 UTC Reconcile all entitlements
stuck-run-recovery Every 15 min Recover runs stuck beyond timeout
run-orphan-cleanup Daily 2:30 UTC Clean up orphaned run artifacts
audit-checkpoint Every 6h (configurable) Create audit integrity checkpoint
pam-session-expiry Every minute Expire PAM sessions past their TTL
consent-expiry-check Every 5 min Expire consents past their TTL
tls-cert-check Every 15 min Reload Caddy to retry ACME after a failed public TLS probe
deliver-scheduled-report Per-report cron Generate and deliver reports
connector-resource-sync Per-connector cron Sync external resources

tls-cert-check is recovery, not monitoring: it probes the public console and portal hostnames and reloads Caddy only once a TLS request has already failed. It does not warn ahead of notAfter, and it never probes the internally issued BFF certificates — so an impending BFF certificate expiry produces no signal here.

Each job type is implemented as a standalone handler file under modules/scheduler/handlers/, registered via a HandlerRegistry. The registry maps job names to handler instances and groups them by queue. SchedulerService creates a BullMQ Worker per queue and dispatches jobs to the matching handler.

The worker can run in-process (default, for development) or as a separate process (WORKER_MODE=separate, recommended for production). See Service Architecture.

Data Layer

  • ORM: Kysely (type-safe query builder, no code generation)
  • Databases: PostgreSQL 16 (primary) or MySQL 8
  • Migrations: SQL files wrapped in TypeScript up/down functions (packages/server/src/db/migrations/)
  • Repositories: Each module has a repository that wraps Kysely, handles snake_case/camelCase conversion, and serializes/deserializes JSON columns
  • Soft deletes: deleted_at column with a far-future sentinel value for index efficiency (see Decision Records)
  • Flexible data: Workflow definitions, variables, and connector configs are stored as serialized JSON in TEXT columns
  • Encryption at rest: Connector secrets and session data encrypted with AES-256-GCM using rotatable keys

See Security and Encryption Keys.

Authentication and Authorization

  1. OIDC flow: Browser login is the Authifi BFF authorization-code flow (/bff/login/bff/callback). The API verifies Bearer user access tokens.
  2. Sessions: Browser session cookies live on the BFF host. The API does not mint browser cookies.
  3. CSRF: Double-submit cookie pattern for mutating requests proxied through a BFF
  4. Programmatic access: OIDC access tokens only — there is no Floh-issued API token
  5. Authorization: requirePermission / requireAnyPermission route guards read the caller's permissions from the verified access token's scope claim, intersected with the channel (console / portal / MCP) the token was issued to
  6. OIDC required: Server startup fails fast when OIDC config is missing — OIDC_ISSUER, OIDC_CLIENT_ID, and at least one of FLOH_CONSOLE_AUDIENCE / FLOH_PORTAL_AUDIENCE / FLOH_MCP_AUDIENCE. Those three channel audiences form the allow-list the API verifies JWT aud against; FLOH_RESOURCE_ID (alias OIDC_AUDIENCE) is operator-tooling metadata and is not a startup requirement

See Security and Roles & Entitlements.

Deployment

graph TD
    Internet["Internet"]
    Caddy["Caddy (TLS)"]
    AdminUI["web (nginx:8080)"]
    PortalUI["portal-web (nginx:8080)"]
    BFF["portal-bff (:7071)"]
    Server["server (:7070)"]
    Worker["worker"]
    PG["PostgreSQL"]
    Redis["Redis"]

    Internet --> Caddy
    Caddy -->|"domain/api/*"| Server
    Caddy -->|"domain/*"| AdminUI
    Caddy -->|"portal-domain/api/*"| BFF
    Caddy -->|"portal-domain/*"| PortalUI
    BFF --> Server
    Server --> PG
    Server --> Redis
    Worker --> PG
    Worker --> Redis
  • TLS termination: Caddy with automatic Let's Encrypt certificates
  • Container images: Multi-stage Docker builds, pushed to GHCR with semver + SHA + latest tags
  • Deployment target: Single host via Docker Compose (see Deployment)
  • CI/CD: GitHub Actions — changeset check on PR, automated version PRs on merge, deploy on release

See Deployment and Worker Deployment.

Further Reading

Topic Document
Detailed system architecture System Architecture
Service boundaries and scaling Service Architecture
Connector execution models Connector Architecture
Portal architecture Portal
Security model Security
Deployment guide Deployment
Developer quickstart Quick Start
Architecture decisions Decision Records