Developer Guide¶
Prerequisites¶
- Node.js 24 LTS
- pnpm (enabled via corepack:
corepack enable) - Docker and Docker Compose
- A code editor with TypeScript support
Setup¶
Project Structure¶
The project is a pnpm monorepo with five packages:
packages/shared— shared TypeScript types and constants used by both frontend and backendpackages/server— Fastify 5 backend with Kysely, BullMQ, and OIDCpackages/web— Angular 21 frontend with PrimeNG (admin interface)packages/portal-bff— Fastify stateless proxy for the public portalpackages/portal-web— Angular 21 portal frontend for external users
Development¶
Starting the Dev Environment¶
# Install all dependencies (required before first run)
pnpm install
# Start PostgreSQL, Redis, and MailHog
docker compose -f docker/docker-compose.yml up -d postgres redis mailhog
# Run database migrations
pnpm migrate:latest
# Start all dev servers (backend + frontend)
pnpm dev
- Backend API: http://localhost:3000
- Frontend: http://localhost:4200
- Portal BFF: http://localhost:3001 (run
pnpm dev:portal) - Portal Frontend: http://localhost:4201 (run
pnpm dev:portal) - MailHog: http://localhost:8025
- API Documentation (Swagger UI): http://localhost:3000/api/docs
- OpenAPI JSON spec: http://localhost:3000/api/docs/json
When using HTTPS mode (
pnpm dev:https), the URLs becomehttps://localhost:3000/api/docsandhttps://localhost:3000/api/docs/json.
Environment Variables¶
Copy .env.example to .env and configure:
DB_TYPE—postgresormysqlOIDC_*— OIDC provider settings (see Configuring OIDC below)SMTP_*— email server settingsREDIS_*— Redis connection settings
Database Migrations¶
Migrations use Kysely's Migrator and live in packages/server/src/db/migrations/.
To create a new migration, add a numbered .ts file (e.g., 002_add_feature.ts) exporting up and down functions.
Running Tests¶
pnpm test:unit # Backend unit tests (vitest)
pnpm test:integration # Backend integration tests (testcontainers)
pnpm test:web # Frontend tests (jest)
pnpm test:portal-bff # Portal BFF tests (vitest)
pnpm test:portal-web # Portal frontend tests (jest)
pnpm test:e2e # E2E tests (playwright)
pnpm test # All tests
Architecture¶
Backend Modules¶
Each module follows a consistent pattern: - repository.ts — Kysely database queries - service.ts — business logic (where needed) - routes.ts — Fastify route handlers
Modules: auth, users, workflows, tasks, approvals, notifications, connectors, scheduler, audit, reports, health, config-transfer.
Portal Architecture¶
The public portal allows external users to interact with Floh through a firewall. See the Portal Guide for full details.
packages/portal-bff— stateless HTTP proxy; whitelists routes, stripsscope, injectsX-Portal-Originheaderpackages/portal-web— minimal Angular SPA with only user-facing routes (dashboard, tasks, invitations)
Authentication Flow¶
- Frontend redirects to OIDC provider via
/api/auth/login - User authenticates at the provider and is redirected back with an authorization code
- Backend exchanges the code for tokens, fetches the userinfo endpoint for
groups - Backend maps OIDC groups to Floh roles, upserts the user, and syncs role assignments
- Backend signs a local JWT containing user info and roles, then redirects the browser to the frontend
- Frontend stores the JWT and sends it as a Bearer header on API requests
- Backend verifies the local JWT; roles and permissions are resolved in-memory from
DEFAULT_ROLE_PERMISSIONS
Configuring OIDC¶
Development without OIDC: When OIDC_ISSUER is not set (the default), the app runs in dev mode — authentication is bypassed and all requests use a built-in dev user with admin privileges. No provider setup is needed to get started.
Setting up a provider: Floh works with any OIDC-compliant provider. Set these variables in .env:
| Variable | Description | Example |
|---|---|---|
OIDC_ISSUER |
Provider's issuer URL | https://login.example.com/realms/floh |
OIDC_CLIENT_ID |
Client ID registered with the provider | floh-client |
OIDC_CLIENT_SECRET |
Client secret | your-secret |
OIDC_REDIRECT_URI |
Callback URL (must match provider config) | http://localhost:3000/api/auth/callback |
OIDC_SCOPE |
Scopes to request (must include groups) |
openid profile email groups |
OIDC_ROLE_ADMIN |
OIDC group(s) that map to admin role |
floh-admins |
OIDC_ROLE_APPROVER |
OIDC group(s) that map to approver role |
floh-approvers |
OIDC_ROLE_RESOURCE_MANAGER |
OIDC group(s) that map to resource_manager role |
floh-resource-managers |
OIDC_ROLE_REQUESTOR |
OIDC group(s) that map to requestor role |
floh-requestors |
Multiple OIDC groups can map to the same role using comma-separated values (e.g. OIDC_ROLE_ADMIN=floh-admins,super-admins). If a user's groups don't match any mapping, they default to the requestor role.
Provider-side configuration:
- Register a new client in your provider (confidential or public)
- Set the Token Endpoint Authentication to
client_secret_post - Set the Redirect URI to
http://localhost:3000/api/auth/callback(or your production URL) - Enable the openid, profile, email, and groups scopes
- Ensure the provider's userinfo endpoint returns a
groupsclaim - Copy the Client ID and Client Secret into your
.env(leaveOIDC_CLIENT_SECRETempty for public clients) - Update
OIDC_ISSUERwith the provider's issuer URL (often found at/.well-known/openid-configuration)
Provider examples:
- Keycloak: Issuer is
https://<host>/realms/<realm> - Auth0: Issuer is
https://<tenant>.auth0.com - Microsoft Entra ID: Issuer is
https://login.microsoftonline.com/<tenant-id>/v2.0 - Google: Issuer is
https://accounts.google.com - Okta: Issuer is
https://<org>.okta.com/oauth2/default
After configuring the provider, restart the server. The OIDC flow works as follows: the backend redirects to the provider for login (requesting the configured scopes including groups), receives the authorization code at its callback, exchanges it for tokens, fetches the userinfo endpoint for the groups claim, maps groups to Floh roles using the OIDC_ROLE_* environment variables, upserts the user and syncs their role assignments in the database, signs a local JWT containing the user's roles, and redirects the browser to the frontend with the token. Roles are synced on every login, so changes in the OIDC provider take effect immediately.
API Documentation¶
The server auto-generates an interactive OpenAPI 3.0 specification from route schemas using @fastify/swagger. When the dev server is running:
- Swagger UI at http://localhost:3000/api/docs -- browse, search, and try out all 70+ endpoints grouped by tag (Auth, Workflows, Runs, Tasks, Approvals, etc.)
- OpenAPI JSON at http://localhost:3000/api/docs/json -- machine-readable spec for code generators, Postman import, or CI validation
Route schemas are defined with TypeBox in packages/server/src/shared/schemas/ and referenced in each module's routes.ts. Adding a schema object to a new route automatically documents it in the spec.
Workflow Step Types¶
The step executor (packages/server/src/modules/workflows/step-executor.ts) handles each step type. All step configs support variable interpolation — {{variableName}} references are resolved from workflow variables at execution time.
notification¶
Sends email and/or in-app notifications. The primary recipient is configured with a recipient type toggle:
| Config Field | Type | Description |
|---|---|---|
recipientType |
'internal' | 'external' |
How to resolve the primary recipient |
recipientUserId |
string | User UUID or {{variable}} — used when recipientType is internal. Server looks up the user by ID to get their email. Ensures in-app notifications are linked correctly. |
recipientEmail |
string | Email or {{variable}} — used when recipientType is external. For generic mailboxes or external partners who aren't system users. |
templateId |
string (optional) | Email template to use |
customSubject |
string (optional) | Subject line override (supports {{variable}}) |
customBody |
string (optional) | HTML body override |
cc |
string[] (optional) | CC email addresses |
bcc |
string[] (optional) | BCC email addresses |
recipients |
string[] (optional) | Additional recipients — user IDs or group references (group:groupName) |
requiresAcceptance |
boolean (optional) | Pause workflow until recipient accepts/rejects |
acceptanceExpiresInHours |
number (optional) | Acceptance link expiry (default: 72) |
Internal vs External recipients:
- Internal User — the recipient is a system user. The workflow designer provides an autocomplete to search users by name or email, displaying the issuer to disambiguate accounts with the same email (e.g., Google vs NIH). The user's UUID is stored; the server resolves their email at execution time. In-app notifications and invitation tokens are linked to the user.
- External Address — the recipient is not a system user (e.g., a partner, a shared mailbox). A plain email input is shown. The email is used directly for delivery with no in-app notification linkage.
Backward compatibility: Workflow definitions saved before the recipient type toggle (with only recipientEmail) continue to work — the server defaults to external mode when recipientType is absent.
Other step types¶
- action — executes immediately, stores config as output data
- approval — creates approval records, pauses workflow until approved/rejected
- connector — invokes a registered connector command with timeout and output variable capture
- transform — runs user-provided JavaScript in the QuickJS sandbox to compute new workflow variables. The script accesses
floh.variables,floh.uuid(),floh.now(), andfloh.log.*. Declaredoutputspopulate downstream autocomplete. See thetransform-testAPI endpoint for stateless script testing - condition — evaluates a boolean expression, determines branch path
- document_submission — creates a task for a user to upload a document, with optional expiry (
expiresAfterDays). Supports submitter comments, document withdrawal, and rejection-with-resubmission (see Document Submission Workflow) - role_grant — grants a business role to a user, provisioning all associated entitlements via connectors (see Roles & Entitlements)
- role_revoke — revokes a role assignment, deprovisioning all entitlements
- fork / join — parallel execution branches
- sub_workflow — executes another workflow definition as a nested run
Shared Frontend Components¶
Reusable components live in packages/web/src/app/shared/components/:
ConnectorConfigFormComponent— renders dynamic form fields from a connector'sconfigSchema.commands. Accepts acommandsinput (the commands record from a connector's config schema), an optionalinitialConfigfor edit mode, and emits structured config objects viaconfigChange. Used by the entitlement list for provision/deprovision/reconciliation config forms. Can be reused anywhere connector command configuration is needed. Falls back to raw JSON mode for connectors without a command schema or for unrecognized commands.StepNavigatorComponent— collapsible step list sidebar for the workflow designer.EntityLookupDialogComponent— modal dialog for looking up users, groups, etc.AdvancedSearchComponent— filter builder for AND/OR query groups.
Key Design Decisions¶
- Kysely over ORMs — type-safe SQL without the abstraction overhead
- Repository pattern — each module owns its queries, keeping route handlers thin
- Append-only audit log — no UPDATE/DELETE on audit_log table for compliance
- BullMQ for scheduling — reliable job processing with Redis-backed persistence
- Connector framework — standardized interface for integrating external systems