Skip to content

Developer Guide

Prerequisites

  • Node.js 24 LTS
  • pnpm (enabled via corepack: corepack enable)
  • Docker and Docker Compose
  • A code editor with TypeScript support

Windows

  • Use PowerShell or cmd for pnpm scripts; Git for Windows provides sh for Husky hooks.
  • Install Docker Desktop and enable the WSL2 backend if you use WSL for the repo.
  • For pnpm generate-certs, install OpenSSL and ensure openssl is on PATH (Git for Windows includes it).
  • For pnpm docs:serve / pnpm docs:build, install Python 3 so python or python3 works in a new terminal.

Setup

git clone <repo-url> floh && cd floh
pnpm install

Project Structure

The project is a pnpm monorepo with five packages:

  • packages/shared — shared TypeScript types and constants used by both frontend and backend
  • packages/server — Fastify 5 backend with Kysely, BullMQ, and OIDC
  • packages/web — Angular 21 frontend with PrimeNG (admin interface)
  • packages/portal-bff — Fastify stateless proxy for the public portal
  • packages/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

# Preferred: HTTPS — generate certs once, set TLS_CERT_FILE / TLS_KEY_FILE / NODE_EXTRA_CA_CERTS in .env (see dev-quickstart.md)
pnpm dev:https

# HTTP-only alternative for the main stack:
# pnpm dev

Preferred local URLs (HTTPS, pnpm dev:https + pnpm dev:portal:https + pnpm dev:form-builder):

  • Backend API: https://localhost:7070
  • Admin frontend: https://localhost:7072
  • Portal BFF: https://localhost:7071 when using pnpm dev:portal:https with PORTAL_LISTEN_TLS=true and TLS_* in .env; http://localhost:7071 with pnpm dev:portal (HTTP-only)
  • Portal frontend: https://localhost:7073
  • Form-builder (visual editor): https://localhost:7080pnpm dev:form-builder defaults to HTTPS, so the iframe embeds cleanly inside both HTTP and HTTPS host SPAs
  • MailHog: http://localhost:8025 (web UI is HTTP only)
  • API Documentation (Swagger UI): https://localhost:7070/api/docs
  • OpenAPI JSON spec: https://localhost:7070/api/docs/json

HTTP-only (pnpm dev:server / pnpm dev:web / pnpm dev:portal / pnpm dev:form-builder:http): use http:// on ports 7070, 7072, 7073, and 7080 instead. Note that pnpm dev (the all-stack shortcut) starts the form-builder on HTTPS (port 7080) by design — see the dev-quickstart's "Or start everything at once" note for why a strict all-HTTP stack requires the per-package shortcuts plus a formBuilderEmbedUrl flip.

Environment Variables

Copy .env.example to .env and configure:

  • DB_TYPEpostgres or mysql
  • OIDC_* — OIDC provider settings (see Configuring OIDC below)
  • SMTP_* — email server settings
  • REDIS_* — Redis connection settings
  • TRUST_PROXY — set to true only when running behind a trusted reverse proxy/load balancer

TRUST_PROXY implications

TRUST_PROXY changes how Fastify determines client IPs (request.ip) by trusting X-Forwarded-* headers from an upstream proxy.

  • Keep TRUST_PROXY disabled for direct local/server access.
  • Enable TRUST_PROXY=true only when your ingress/reverse proxy overwrites or strips untrusted X-Forwarded-For headers from clients.
  • With TRUST_PROXY=true, Floh disables localhost rate-limit allowlisting to avoid forwarded-header spoof bypasses.
  • request.ip is used in access and audit metadata, so a misconfigured proxy can also poison IP attribution.

For deployment-focused guidance, see Scaling and performance.

Database Migrations

Migrations use Kysely's Migrator and live in packages/server/src/db/migrations/.

pnpm migrate:latest    # Apply all pending migrations
pnpm migrate:down      # Revert last migration

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 smoke tests against real OIDC credentials
pnpm test:e2e:local     # Deterministic local E2E stack (testcontainers + Playwright)
pnpm test               # All tests

pnpm test:e2e:local starts isolated Postgres and Redis containers, an API server on 17074, and the Angular dev server on 17073. It enables test-only support routes with a local shared secret, resets the database, seeds deterministic data, and creates a browser session without using the external OIDC UI. Run pnpm --filter @floh/web exec playwright install chromium once if Playwright reports a missing browser binary.

pnpm test:e2e keeps the real-OIDC smoke profile. Configure it with packages/web/.env.e2e / .env.e2e.local when you need to verify the external login flow.

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 reverse proxy; whitelists routes, strips scope, injects X-Portal-Origin header (optional HTTPS in dev via PORTAL_LISTEN_TLS + TLS_*)
  • packages/portal-web — minimal Angular SPA with only user-facing routes (dashboard, tasks, invitations)

Authentication Flow

  1. Frontend redirects to OIDC provider via /api/auth/login
  2. User authenticates at the provider and is redirected back with an authorization code
  3. Backend exchanges the code for tokens, fetches the userinfo endpoint for groups
  4. Backend maps OIDC groups to Floh roles, upserts the user, and syncs role assignments
  5. Backend signs a local JWT containing user info and roles, then redirects the browser to the frontend
  6. Frontend stores the JWT and sends it as a Bearer header on API requests
  7. Backend verifies the local JWT; roles and permissions are resolved in-memory from DEFAULT_ROLE_PERMISSIONS

Configuring OIDC

OIDC is required: Floh no longer supports a dev-auth bypass. Startup fails fast unless OIDC config is present (OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URI).

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) https://localhost:7070/api/auth/callback (preferred; use http:// only for HTTP-only dev)
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:

  1. Register a new client in your provider (confidential or public)
  2. Set the Token Endpoint Authentication to client_secret_post
  3. Set the Redirect URI to https://localhost:7070/api/auth/callback for HTTPS dev (or http://localhost:7070/api/auth/callback if you run the API without TLS), or your production URL
  4. Enable the openid, profile, email, and groups scopes
  5. Ensure the provider's userinfo endpoint returns a groups claim
  6. Copy the Client ID and Client Secret into your .env (leave OIDC_CLIENT_SECRET empty for public clients)
  7. Update OIDC_ISSUER with 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. In development and test, docs are enabled by default. In production, set ENABLE_API_DOCS=true to expose them.

When the dev server is running:

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' | 'group' 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.
recipientGroupRef string Group reference (e.g. group:engineering) — used when recipientType is group. Expands group membership and notifies all members.
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(), and floh.log.*. Declared outputs populate downstream autocomplete. See the transform-test API 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's configSchema.commands. Accepts a commands input (the commands record from a connector's config schema), an optional initialConfig for edit mode, and emits structured config objects via configChange. 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