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:
- CORS — origin whitelist with credentials
- Multipart — file uploads
- Cookie — session cookies
- Rate Limit — 200 req/min default
- Swagger — OpenAPI docs at
/api/docs - 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:
- Acquire lock
floh:run-lock:{runId} - Load run and definition, build step graph
- Walk steps via graph transitions (max 1000 steps per pass)
- Delegate to
StepExecutorby step type: action, condition, connector, approval, notification, consent, document submission, role grant/revoke, fork, join, sub-workflow - Steps that require external input (approval, consent, document submission) return a
waiting_*status and pause the run - When external input arrives, the engine resumes from the waiting step
- 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_atcolumn 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¶
- OIDC flow: Browser login is the Authifi BFF authorization-code flow (
/bff/login→/bff/callback). The API verifies Bearer user access tokens. - Sessions: Browser session cookies live on the BFF host. The API does not mint browser cookies.
- CSRF: Double-submit cookie pattern for mutating requests proxied through a BFF
- Programmatic access: OIDC access tokens only — there is no Floh-issued API token
- Authorization:
requirePermission/requireAnyPermissionroute guards read the caller's permissions from the verified access token'sscopeclaim, intersected with the channel (console / portal / MCP) the token was issued to - OIDC required: Server startup fails fast when OIDC config is missing —
OIDC_ISSUER,OIDC_CLIENT_ID, and at least one ofFLOH_CONSOLE_AUDIENCE/FLOH_PORTAL_AUDIENCE/FLOH_MCP_AUDIENCE. Those three channel audiences form the allow-list the API verifies JWTaudagainst;FLOH_RESOURCE_ID(aliasOIDC_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 |