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
pnpmscripts; Git for Windows providesshfor Husky hooks. - Install Docker Desktop and enable the WSL2 backend if you use WSL for the repo.
- For
pnpm generate-certs, install OpenSSL and ensureopensslis onPATH(Git for Windows includes it). - For
pnpm docs:serve/pnpm docs:build, install Python 3 sopythonorpython3works in a new terminal. umaskandcpin the Setup block below are POSIX. On PowerShell:
Copy-Item .env.example .env
New-Item -ItemType Directory -Force env | Out-Null
Copy-Item env/console.env.example env/console.env
Copy-Item env/portal.env.example env/portal.env
Recommended on a shared Windows checkout (optional hardening): restrict the
client files after copy and after every pnpm run setup-authifi-oidc-clients
run. The script replaces those files and does not apply NTFS ACLs — Node
chmod(0600) is POSIX-only.
icacls env\console.env /inheritance:r /grant:r "${env:USERNAME}:(R,W)"
icacls env\portal.env /inheritance:r /grant:r "${env:USERNAME}:(R,W)"
Setup¶
git clone <repo-url> floh && cd floh
umask 077
cp .env.example .env
cp env/console.env.example env/console.env
cp env/portal.env.example env/portal.env
pnpm install
Root .env holds infra + API values. BFF client secrets live in
env/console.env and env/portal.env. The API does not need those secrets.
On POSIX, pnpm run setup-authifi-oidc-clients rewrites the client files at
mode 0600. On Windows, use the icacls step above if the checkout is shared.
Project Structure¶
The project is a pnpm monorepo with multiple 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— Authifi BFF 3.3.0 config-invariant tests for the portal and console gatewayspackages/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
# HTTPS stack with per-service log panes (requires madprocs — see dev-quickstart)
# pnpm dev:mux
# Same stack, madprocs web UI only (http://127.0.0.1:7709):
# pnpm dev:mux:web
Preferred local URLs (HTTPS, pnpm dev:https — API, both BFFs, both SPAs, form-builder):
- Backend API:
https://localhost:7070 - Admin frontend:
https://localhost:7072 - Portal BFF:
https://localhost:7071(included inpnpm dev:https); inspector127.0.0.1:9230 - Console BFF:
https://localhost:7074(included inpnpm dev:https); inspector127.0.0.1:9231 - Portal frontend:
https://localhost:7073 - Form-builder (visual editor):
https://localhost:7080—pnpm dev:form-builderdefaults 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 for the full stack, or pnpm dev:server / pnpm dev:web / pnpm dev:console:http / pnpm dev:portal:http / pnpm dev:form-builder:http for subsets): use http:// on ports 7070, 7072, 7073, 7074, and 7080 instead. Note that pnpm dev still starts the form-builder on HTTPS (port 7080) by design — see the dev-quickstart HTTP services 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_TYPE—postgresormysqlOIDC_*— OIDC provider settings (see Configuring OIDC below)SMTP_*— email server settingsREDIS_*— Redis connection settingsFLOH_CONSOLE_BFF_RUNTIME/FLOH_PORTAL_BFF_RUNTIME—host(default) ordockerfor that channel's local BFF. Unset/blank ishost.pnpm docker:*ignores these keys.
TRUST_PROXY implications¶
TRUST_PROXY changes how Fastify determines client IPs (request.ip) by trusting
X-Forwarded-* headers from an upstream proxy.
- Keep
TRUST_PROXYdisabled for direct local/server access. - Enable
TRUST_PROXY=trueonly when your ingress/reverse proxy overwrites or strips untrustedX-Forwarded-Forheaders from clients. - With
TRUST_PROXY=true, Floh disables localhost rate-limit allowlisting to avoid forwarded-header spoof bypasses. request.ipis 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/.
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 authenticates
the browser with a short-lived signed OIDC access token (the API is Bearer-only). 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— Authifi BFF 3.2.2 gateway configuration; owns portal login/logout/session under/bff/*, proxies browser/api/*calls, and keeps portal tokens out of JavaScriptpackages/portal-web— minimal Angular SPA with only user-facing routes (dashboard, tasks, invitations)
Authentication Flow¶
Admin console
- Browser redirects to the Authifi console BFF via
/bff/login - The BFF performs the console OIDC code flow and stores the session using the configured session backend
- The callback returns to
https://<console-origin>/bff/callback(local dev:https://localhost:7072/bff/callback) - Browser
GET/POST/... /api/*calls go to the BFF, which attaches the access token server-side before proxying to the Floh API - Browser JavaScript never reads the console access token or refresh token
Public portal
- Browser redirects to the Authifi BFF via
/bff/login - The BFF performs the portal OIDC code flow and stores the portal session using the configured session backend
- The callback returns to
https://<portal-origin>/bff/callback(local dev:https://localhost:7073/bff/callback) - Browser
GET/POST/... /api/*calls go to the BFF, which attaches the access token server-side before proxying to the Floh API - Browser JavaScript never reads the portal access token or refresh token
Configuring OIDC¶
OIDC is required: Floh no longer supports a dev-auth bypass. The API starts
only when OIDC_ISSUER, OIDC_CLIENT_ID, and at least one of the three channel
audiences (FLOH_CONSOLE_AUDIENCE, FLOH_PORTAL_AUDIENCE,
FLOH_MCP_AUDIENCE) are set. The API is a resource server and does not need
browser RP secrets.
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 |
FLOH_CONSOLE_AUDIENCE |
Console channel resource id — part of the verified aud allow-list |
http://console.floh.api |
FLOH_PORTAL_AUDIENCE |
Portal channel resource id — same | http://portal.floh.api |
FLOH_MCP_AUDIENCE |
MCP channel resource id — same | http://mcp.floh.api |
FLOH_RESOURCE_ID |
API resource id used by operator tooling. Not the verified aud and not required at startup |
http://floh.api |
OIDC_AUDIENCE |
Deprecated alias of FLOH_RESOURCE_ID |
(omit; use FLOH_RESOURCE_ID) |
OIDC_SCOPE |
Scopes to request (must include groups) |
openid profile email groups |
Floh does not map OIDC groups to internal roles. A request's permissions are
the intersection of the access token's scope claim with the permissions
reachable from the client (azp) the token was issued to. Grant access by
configuring which scopes the provider issues.
Provider-side configuration:
Not reachable with a non-Authifi provider today
Step 3 cannot currently be satisfied from the BFF side.
docker/bff/console.json and docker/bff/portal.json request only
openid profile email offline_access and expose no resource or
API-scope setting, so the BFF never asks the provider for a channel
audience. Configuring the audience at the provider and adding it to the
Floh allow-list is necessary but not sufficient — the token the BFF
receives still will not carry it, and the API rejects the login. Tracked
in #1189; the steps below
describe the intended shape, not a path that works end to end yet.
- Register the API audience/resource and the confidential clients used by the BFFs.
- Enable the openid, profile, email, and groups scopes.
- Ensure the provider's access token carries the configured audience and,
where role enrichment is needed, the userinfo endpoint returns
groups. - Set
OIDC_ISSUER,OIDC_CLIENT_ID, and at least one channel audience (FLOH_CONSOLE_AUDIENCE,FLOH_PORTAL_AUDIENCE,FLOH_MCP_AUDIENCE) in the API environment — startup fails without them, and they are the only values accepted as the tokenaud.FLOH_RESOURCE_ID(deprecated aliasOIDC_AUDIENCE) is optional operator-tooling metadata. Client secrets and redirect URIs belong only to the BFFs.
Portal BFF client: register a separate confidential client for the portal
gateway (PORTAL_OIDC_CLIENT_ID, typically floh-portal-client) and set its
callback to https://<portal-origin>/bff/callback (local dev:
https://localhost:7073/bff/callback). Operators set
PORTAL_OIDC_CLIENT_ID, PORTAL_OIDC_CLIENT_SECRET, and
PORTAL_BFF_COOKIE_ENCRYPTION_SECRET in env/portal.env / secret injection;
Compose maps those to the Authifi container's AUTH_CLIENT_ID,
AUTH_CLIENT_SECRET, and AUTH_COOKIE_ENCRYPTION_SECRET. Do not place the
portal client secret in Angular config, committed JSON, or the API .env.
Authifi: pnpm run setup-authifi-oidc-clients registers the console, portal,
and MCP clients (see Portal — Authifi OIDC clients).
It loads .env plus env/console.env, env/portal.env, and env/mcp.env when
those files exist, and requires a real OIDC_ISSUER plus FRONTEND_URL /
PORTAL_FRONTEND_URL; any run that applies changes also requires
AUTHIFI_ADMIN_TOKEN from gitignored .authifi-admin-token (never .env),
AUTHIFI_BASE_URL (derived from the token when unset), FLOH_RESOURCE_ID
(OIDC_AUDIENCE is a deprecated alias), and FLOH_MCP_AUDIENCE, all checked
before the first mutation. Given those, it writes public client ids and
redirect URIs to .env and RP secrets to env/console.env /
env/portal.env / env/mcp.env, then exits non-zero if any required value is
still missing. Browser clients register against /bff/callback. API
OIDC_CLIENT_ID is the console azp alias; MCP process env uses
MCP_OIDC_CLIENT_ID (or process-env OIDC_CLIENT_ID=floh-mcp-client). The
destination files must be gitignored — the run refuses to write secrets into a
tracked path.
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. For OIDC login plus inbound SCIM provisioning from Entra, see Entra setup for Floh. - Google: Issuer is
https://accounts.google.com - Okta: Issuer is
https://<org>.okta.com/oauth2/default— full OIDC + inbound SCIM walkthrough: Okta setup for Floh
After configuring the provider, restart the relevant service. Console and
portal login both run through Authifi BFFs: each gateway owns /bff/login and
/bff/callback, stores its session, and proxies browser /api/* calls with
the access token attached server-side. The API reads permissions from each
Bearer token's scope claim, so provider-side scope changes take effect as
soon as the gateway mints a fresh access token.
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:
- Swagger UI at https://localhost:7070/api/docs when the API uses TLS (preferred); http://localhost:7070/api/docs for HTTP-only dev
- OpenAPI JSON at https://localhost:7070/api/docs/json (HTTPS) or http://localhost:7070/api/docs/json (HTTP-only)
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(), 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