Skip to content

Dev Quickstart

Prerequisites

  • Node.js 26 from .nvmrc for local development (nvm use; CI and release read the same file via actions/setup-node)
  • Corepack installed globally (npm install --global corepack) and enabled (corepack enable) for the pnpm version pinned in package.json
  • Docker & Docker Compose

Setup

git clone <repo-url> floh && cd floh
nvm use
npm install --global corepack
corepack enable
umask 077
cp .env.example .env
cp env/console.env.example env/console.env
cp env/portal.env.example env/portal.env
cp env/mcp.env.example env/mcp.env
pnpm install

.nvmrc is the single Node-version source of truth for local development, CI, and release automation. The workspace still declares package compatibility as node >=24.0.0, so keep scripts and tooling portable across Node 24+ even though the default developer and GitHub Actions runtime is Node 26.

Fill the env files

The copies above are templates with your-*.example.com placeholders. The API will not start until issuer, client id, and audience are real values. Edit the four gitignored files, not the committed *.example sources.

File Loaded by You edit A script writes
.env infra, API, Authifi operator scripts issuer, audiences, Authifi admin URLs, DB/Redis if you are not using the compose defaults public client ids / redirect URIs after OIDC setup
env/console.env console BFF leave secrets empty on first run; keep OIDC_REDIRECT_URI=https://localhost:7072/bff/callback OIDC_CLIENT_SECRET, CONSOLE_BFF_COOKIE_ENCRYPTION_SECRET
env/portal.env portal BFF leave secrets empty on first run; keep PORTAL_OIDC_REDIRECT_URI=https://localhost:7073/bff/callback PORTAL_OIDC_CLIENT_SECRET, PORTAL_BFF_COOKIE_ENCRYPTION_SECRET
env/mcp.env MCP process (preferred keys) leave MCP_OIDC_CLIENT_SECRET empty on first run MCP_OIDC_CLIENT_ID, MCP_OIDC_CLIENT_SECRET, FLOH_MCP_AUDIENCE

1. Hand-edit root .env. Replace every your-…example.com / empty secret that the API or Authifi scripts need:

  • OIDC_ISSUER — Authifi issuer (not the placeholder host). Setup/sync derive this from .authifi-admin-token when you confirm the .env write.
  • FLOH_RESOURCE_ID, FLOH_CONSOLE_AUDIENCE, FLOH_PORTAL_AUDIENCE, FLOH_MCP_AUDIENCE — four pairwise-distinct identifiers. Console BFF AUTH_RESOURCE is FLOH_CONSOLE_AUDIENCE from env/console.env; portal BFF uses FLOH_PORTAL_AUDIENCE from env/portal.env. The API verifies JWT aud against those channel ids (and FLOH_MCP_AUDIENCE when set). FLOH_RESOURCE_ID is an Authifi catalog id used by operator setup; OIDC_AUDIENCE is a deprecated alias. Local defaults are http://floh.api and http://{console,portal,mcp}.floh.api.
  • AUTHIFI_BASE_URL — Authifi /_api base (derived from the admin token iss).
  • AUTHIFI_TENANT_ID — numeric tenant id (from the admin token).
  • Admin token — gitignored .authifi-admin-token, never .env. SERVICES_AUTH_URL is a deprecated alias of AUTHIFI_BASE_URL.
  • FRONTEND_URL=https://localhost:7072 and PORTAL_FRONTEND_URL=https://localhost:7073 are already correct for local HTTPS.
  • Compose defaults (DB_*, REDIS_*, MailHog SMTP_*) work as copied unless another local service already owns those ports — then change DB_PORT as described under Start Infrastructure.

Mint local encryption keys with pnpm run generate-key and paste each value once (JWT_SECRET, SESSION_SECRET, SESSION_ENCRYPTION_KEY, CONNECTOR_ENCRYPTION_KEY). Do not leave production-looking blanks if the server requires the key at startup.

For HTTPS (recommended), also uncomment TLS_CERT_FILE, TLS_KEY_FILE, and NODE_EXTRA_CA_CERTS after pnpm generate-certs — see Run Services (HTTPS).

2. Reconcile Authifi resource servers, then register OIDC clients. Default local ids (http://console.floh.api, http://portal.floh.api, http://mcp.floh.api, http://floh.api) must exist in Authifi before setup can bind floh-client / floh-portal-client to them:

pnpm run sync-authifi-rbac -- --dry-run
pnpm run sync-authifi-rbac -- --apply

Then let the operator script fill BFF secrets and rewrite public ids. Preconditions and flags are under Auth / MCP Server.

pnpm run setup-authifi-oidc-clients -- --local-dev --identity-provider nih --dry-run
pnpm run setup-authifi-oidc-clients -- --local-dev --identity-provider nih

--identity-provider is required only when creating floh-client from scratch. If that client already exists, omit the flag — setup copies its providers onto floh-portal-client.

On POSIX the script chmods env/console.env and env/portal.env to 0600 even if a world-readable copy already existed. Windows does not get owner-only ACLs from that chmod; see the developer-guide icacls step if the checkout is shared.

Do not put OIDC_CLIENT_SECRET or PORTAL_OIDC_CLIENT_SECRET in root .env, Angular config, or committed JSON. The API is a resource server and does not need those RP secrets.

pnpm dev:help                # list local-dev scripts (dev, infra, migrate, aliases)
pnpm dev:all                 # prompts for an HTTPS stack, then infra + migrations + that mode
pnpm dev:all:http            # same launcher; HTTP stack picker
pnpm dev:all https           # skip the prompt: runs dev:https after infra + migrations

pnpm dev:all runs three steps sequentially and fails fast on the first non-zero exit:

  1. node scripts/docker-infra.mjs --waitdocker compose up -d --wait for Postgres + Redis (and MailHog, unless port 1025 is already taken), blocking until each container's healthcheck passes.
  2. pnpm migrate:latest — apply pending database migrations.
  3. pnpm dev:<mode> — start the chosen dev mode. The interactive picker is protocol-split: pnpm dev:all lists HTTPS stacks (dev:https, dev:mux, dev:mux:web, dev:https:clean, dev:server); pnpm dev:all:http lists HTTP stacks (dev, dev:mux:http, dev:mux:http:web, dev:web, dev:server). Pass any registered dev:* suffix as an argument to skip the picker (e.g. https, mux, mux:web, server, console, web, portal, portal:bff, portal:http, mcp, form-builder:https).

pnpm dev:all without an argument requires an interactive terminal. In a non-TTY context (CI, piped invocation), pass an explicit mode.

Split-pane logs (pnpm dev:mux)

pnpm dev and pnpm dev:https dump every process into one stdout. pnpm dev:mux starts the HTTPS stack as separate madprocs panes (TUI search with /, restart one process with r, web UI at http://127.0.0.1:7709, press w in the TUI).

Default streams (HTTPS):

Pane Process Autostart
api-server @floh/server yes
console-bff dev-console.mjs --bff-only yes
console-web @floh/web dev:https yes
portal-bff pnpm dev:portal-bff yes
portal-web @floh/portal-web dev:https yes
form-builder @floh/form-builder-app yes
mcp @floh/mcp no (s)

To add a stream later, append one object to STREAMS in scripts/dev-mux-streams.mjs.

pnpm dev:all mux             # infra + migrations, then HTTPS mux TUI
pnpm dev:all mux:web         # infra + migrations, then HTTPS mux web UI
pnpm dev:mux                 # HTTPS panes (TUI, default)
pnpm dev:mux:web             # HTTPS dashboard at http://127.0.0.1:7709 (no TTY)
pnpm dev:mux:http            # HTTP profile, TUI
pnpm dev:mux:http:web        # HTTP profile, web UI

madprocs is a local binary (not a pnpm dep):

go install github.com/speakeasy-api/madprocs@latest
# or: mise use -g github:speakeasy-api/madprocs@latest
# or: download from https://github.com/speakeasy-api/madprocs/releases
# optional: copy the binary to gitignored local/bin/madprocs

Extra flags after -- still go to madprocs. pnpm dev:mux:web is the first-class equivalent of pnpm dev:mux -- -web-only.

Terminal tab titles

pnpm dev:all names its controlling terminal as floh dev:<mode> (e.g. floh dev:https, floh dev:portal, floh dev) so multiple Cursor / VS Code tabs running different stacks stay distinguishable at a glance. It does this two ways:

  • OSC 0 escape sequence — written to stderr when stderr is a TTY. This is the source of truth for the tab title. Every modern terminal emulator (iTerm2, Terminal.app, kitty, Ghostty, alacritty, gnome-terminal, tmux with allow-rename on) honours it as the window/tab title; Cursor / VS Code expose it via the ${sequence} template variable, but the default tabs template is ${process} and does not read ${sequence}, so you have to opt in (see below).
  • process.title — set on the dev-all.mjs node process so it shows in ps, htop, and Activity Monitor as floh dev:<mode>. Cursor / VS Code's default ${process} template walks toward the leaf of the foreground process tree (so it usually reports node, pnpm, or the shell name, not the outer wrapper), which means this alone is not enough to fix the tab label in Cursor — the OSC opt-in below is.

Cursor / VS Code opt-in (required for the tab label)

Add this to your user settings.json (Cursor: Cmd+Shift+PPreferences: Open User Settings (JSON)):

"terminal.integrated.tabs.title": "${sequence}",
"terminal.integrated.tabs.description": "${process}${separator}${cwdFolder}"

After saving, new terminals pick up the template immediately; an already-open terminal updates its label the next time any OSC title is emitted into it (the simplest way to refresh an existing pnpm dev:all tab is to Ctrl-C and re-run).

If you'd rather not drop ${process} from the title slot — e.g. on terminals where nothing has emitted an OSC and you want a fallback — use this template instead:

"terminal.integrated.tabs.title": "${sequence}${separator}${process}"

The script writes nothing when stderr isn't a TTY, so pnpm dev:all https | tee log.txt and CI invocations don't leak escape bytes into log files.

Start Infrastructure (manual control)

pnpm dev:docker:infra        # Postgres :$DB_PORT (default 5432), Redis :6379, MailHog :8025
pnpm dev:migrate:latest      # apply database migrations
pnpm dev:prep                # equivalent to: dev:docker:infra --wait && dev:migrate:latest

pnpm docker:infra and pnpm migrate:latest remain as aliases of those dev:* names.

pnpm dev:docker:infra probes the host ports for Postgres (DB_PORT, default 5432), Redis (6379), and MailHog (1025) in turn. Any service whose port is already bound (a stale container from a sibling worktree, a local Postgres install, a system Redis, MailCatcher/Maildev, etc.) is dropped from the docker compose up invocation and the existing listener is reused. If every probed service is already running on the host, no docker compose call is made.

To run Floh Postgres next to another local Postgres on 5432, set DB_PORT=5433 (or any free host port) in .env only. Compose publishes that host port onto container 5432; pnpm dev:migrate:latest and the local (host) server use the same value. pnpm dev:docker:up server/worker stay on in-network postgres:5432 — compose environment pins DB_PORT so the host remap cannot leak through env_file. Recreate the container after changing it (node scripts/compose-with-env.mjs up -d --force-recreate postgres).

Trust assumption: the script assumes any existing listener is a compatible replacement. If your local Postgres is the wrong version or Redis has the wrong auth, migrations and runtime traffic will surface the mismatch at the next step — not silently.

Default Ports

Port Service Env var
7070 API server PORT
7071 Portal BFF PORTAL_PORT
7072 Admin frontend
7073 Portal frontend
7074 Console BFF
7080 Form-builder (visual editor)

HTTPS dev uses the same ports; enable TLS with TLS_CERT_FILE / TLS_KEY_FILE. The portal stack is HTTPS-first: pnpm dev:portal defaults to HTTPS, pnpm dev:portal:https is an explicit alias, and pnpm dev:portal:http is the opt-out. The form-builder dev server (pnpm dev:form-builder) also defaults to HTTPS; use pnpm dev:form-builder:http for the explicit HTTP opt-out.

Run Services (HTTPS — preferred)

Generate local certs first (one-time):

pnpm generate-certs

The HTTPS dev scripts (pnpm dev:https, pnpm dev:portal, pnpm dev:portal:https, pnpm dev:form-builder) auto-invoke generate-certs.mjs --quiet as a preflight, so on a fresh clone the certs appear automatically on first start. pnpm generate-certs is only required up-front when you want the verbose trust-store and .env guidance the bare command prints. Run it once now (or skip and read it on first dev start) so you have the trust-store / NODE_EXTRA_CA_CERTS hints in your terminal.

Then set TLS_CERT_FILE, TLS_KEY_FILE, and NODE_EXTRA_CA_CERTS in .env:

TLS_CERT_FILE=certs/localhost.crt
TLS_KEY_FILE=certs/localhost.key
NODE_EXTRA_CA_CERTS=certs/localhost.crt

Set FLOH_INTERNAL_URL=https://localhost:7070 when the API serves HTTPS so both host-Node BFFs and other Node clients target the same scheme. pnpm dev, pnpm dev:https, and mux start both Authifi BFFs as host Node by default:

  • Portal installs @authifi/auth-bff-gateway@3.3.0 into gitignored local/bff-gateway/ and binds the inspector on 127.0.0.1:9230.
  • Console installs @authifi/auth-bff-gateway@3.4.0 into gitignored local/bff-gateway-console/ and binds the inspector on 127.0.0.1:9231.

Each launcher injects PEM contents into SERVER_CERT / SERVER_KEY. Those settings take PEM contents, not file paths. First run needs a //npm.pkg.github.com/:_authToken in ~/.npmrc that can read both Authifi/idbroker-tools and AxleResearch packages (@authifi and @axleresearch scopes). The launcher writes those scope mappings into the install directory .npmrc and installs with --ignore-workspace so Floh's lockfile is untouched. Install failure is fatal (no Docker fallback). Pass --no-inspect or set FLOH_BFF_NO_INSPECT=1 to disable the inspector.

Set FLOH_CONSOLE_BFF_RUNTIME=docker or FLOH_PORTAL_BFF_RUNTIME=docker to run that channel from the published image instead (docker-compose.console.yml or docker/docker-compose.portal-dev.yml). Unset or blank defaults to host; any other value fails closed. pnpm docker:up / pnpm docker:portal:up ignore the toggle. Host runtime ignores leftover BFF_PROXY_TARGET=host.docker.internal and proxies to {FLOH_INTERNAL_URL}/api.

NODE_EXTRA_CA_CERTS tells Node.js to trust the self-signed certificate, which is required when Node clients (portal BFF, scripts, tests) call https://localhost:7070.

TLS by tier

Tier TLS enabled by Required settings
API server (packages/server) .env TLS_CERT_FILE, TLS_KEY_FILE
Node clients to API (portal-bff, scripts) .env NODE_EXTRA_CA_CERTS when API uses self-signed HTTPS
Admin frontend (packages/web) script pnpm dev:https enables HTTPS UI
Portal frontend (packages/portal-web) script pnpm dev:portal / pnpm dev:portal:https enable HTTPS UI
Form-builder app (packages/form-builder-app) script (HTTPS by default) pnpm dev:form-builder already serves HTTPS on port 7080; use pnpm dev:form-builder:http for the explicit HTTP opt-out
Portal BFF listener (packages/portal-bff) launcher pnpm dev:portal / pnpm dev:portal:https inject SERVER_CERT / SERVER_KEY PEM contents so the BFF serves HTTPS on port 7071
Portal BFF upstream protocol FLOH_INTERNAL_URL Must match API scheme (https:// preferred)
Console BFF listener launcher pnpm dev:console / full stack inject SERVER_CERT / SERVER_KEY PEM contents so the BFF serves HTTPS on port 7074
Console BFF upstream protocol FLOH_INTERNAL_URL Host default: {FLOH_INTERNAL_URL}/api. BFF_PROXY_TARGET is docker-runtime only (host.docker.internal)
Command Service URL
pnpm dev:https Full HTTPS stack (API + both BFFs + both SPAs + form-builder) :7070 / :7074 / :7072 / :7071 / :7073 / :7080 (all https://localhost)
pnpm dev:mux Same HTTPS stack, one log pane per process same ports; madprocs TUI + UI at http://127.0.0.1:7709
pnpm dev:mux:web Same HTTPS stack, madprocs web UI only (no TTY panes) same ports; open http://127.0.0.1:7709
pnpm dev:server API server (start first when using subset scripts) https://localhost:7070 (with TLS in .env)
pnpm dev:console Console BFF + admin SPA (API already up) https://localhost:7074 / https://localhost:7072
pnpm dev:console:bff Console BFF only (host Node) https://localhost:7074
pnpm dev:console:web:https Admin SPA only https://localhost:7072
pnpm dev:portal Portal (BFF + SPA together; API already up) https://localhost:7071 (BFF) / https://localhost:7073 (SPA)
pnpm dev:portal:https Portal (explicit HTTPS) https://localhost:7071 (BFF) / https://localhost:7073 (SPA)
pnpm dev:portal:bff Portal BFF only (host Node) https://localhost:7071
pnpm dev:portal:web:https Portal SPA only https://localhost:7073
pnpm dev:form-builder Form-builder (visual editor) https://localhost:7080 (HTTPS — default)

The form-builder defaults to HTTPS so its iframe embeds cleanly inside both http:// and https:// parent pages. Browsers block mixed-content iframes (http:// inside https://), but never the inverse. The default formBuilderEmbedUrl in packages/web/src/environments/environment.ts is https://localhost:7080/ to match. If you opt out via pnpm dev:form-builder:http, also flip formBuilderEmbedUrl back to http://localhost:7080/ for the session.

Subset scripts (pnpm dev:console, pnpm dev:portal, pnpm dev:console:bff, pnpm dev:portal:bff) do not start the API server. Run pnpm dev:server first, or use the full-stack commands (pnpm dev:https / pnpm dev) which already include it.

To split a BFF from its SPA (a BFF crash then no longer SIGTERMs the SPA), use two terminals after the API is up:

# Console split
pnpm dev:console:bff
pnpm dev:console:web:https

# Portal split
pnpm dev:portal:bff
pnpm dev:portal:web:https

Combined pnpm dev:console / pnpm dev:portal still start BFF + SPA in one terminal. pnpm dev:mux is the same full stack as pnpm dev:https with one pane per process.

pnpm dev:https

pnpm dev:https starts the host API, both Authifi BFFs (console 7074, portal 7071), both SPAs (admin 7072, portal 7073), and the form-builder (7080). MCP stays off by default. Both BFFs run as host Node by default and proxy to {FLOH_INTERNAL_URL}/api (typically https://localhost:7070/api). Set FLOH_CONSOLE_BFF_RUNTIME=docker if you need the published console image; that path still uses https://host.docker.internal:7070/api and certs include DNS:host.docker.internal. Do not also run pnpm dev:portal in a second terminal — ports 7071 and 7073 are already taken.

To run MCP in a separate terminal, first configure auth (FLOH_REFRESH_TOKEN + OIDC_ISSUER + MCP_OIDC_CLIENT_ID or process-env OIDC_CLIENT_ID=floh-mcp-client). The refresh-token path needs the MCP confidential-client secret (MCP_OIDC_CLIENT_SECRET). That value lives in env/mcp.env after setup, not in root .env and not in env/console.env:

set -a && . ./env/mcp.env && set +a
pnpm run dev:mcp

Run Services (HTTP — alternative)

Command Service URL
pnpm dev Full HTTP stack (form-builder still HTTPS on :7080) :7070 / :7074 / :7072 / :7071 / :7073 / :7080
pnpm dev:mux:http Same HTTP stack, one log pane per process same ports; madprocs TUI + UI at http://127.0.0.1:7709
pnpm dev:mux:http:web Same HTTP stack, madprocs web UI only same ports; open http://127.0.0.1:7709
pnpm dev:server API server http://localhost:7070
pnpm dev:web Console-only HTTP: host API + console BFF + admin SPA http://localhost:7070 / :7074 / :7072
pnpm dev:console:http Console BFF + admin SPA (API already up) http://localhost:7074 / :7072
pnpm dev:console:bff:http Console BFF only http://localhost:7074
pnpm dev:portal:http Portal (BFF + SPA together) http://localhost:7071 / http://localhost:7073
pnpm dev:portal:bff:http Portal BFF only http://localhost:7071
pnpm dev:portal:web Portal SPA only http://localhost:7073
pnpm dev:form-builder:http Form-builder (visual editor) http://localhost:7080

(pnpm dev:form-builder itself runs HTTPS; the :http suffix is the explicit opt-out, mirroring how pnpm dev:portal:http flips the portal stack back to plain HTTP.)

pnpm dev is almost all-HTTP — it starts API, both BFFs, and both SPAs over HTTP, but the form-builder still starts on HTTPS (port 7080). This is by design so the iframe URL hardcoded in packages/web/src/environments/environment.ts (formBuilderEmbedUrl: "https://localhost:7080/") loads cleanly regardless of the host SPA's scheme — an HTTP iframe inside an HTTPS host is mixed-content blocked, but never the other way around. The auto-cert preflight in form-builder's dev script (PR #381) means operators don't need to run pnpm generate-certs first; the cert pair is created on first start. To run a strict all-HTTP stack with no TLS at all, start the per-package shortcuts manually (pnpm dev:server, pnpm dev:web, pnpm dev:portal:http, pnpm dev:form-builder:http) and flip formBuilderEmbedUrl to http://localhost:7080/ for the duration of the session.

Useful URLs

URL What
https://localhost:7070/api/docs Swagger UI (preferred)
http://localhost:7070/api/docs Swagger UI (HTTP-only dev)
http://localhost:8025 MailHog inbox
http://127.0.0.1:7709 madprocs log UI (dev:mux)

Auth

OIDC configuration is required in all environments. The API is a Bearer-only resource server and fails fast when issuer, client id, and a channel catalog audience are missing:

  • OIDC_ISSUER
  • OIDC_CLIENT_ID
  • FLOH_CONSOLE_AUDIENCE, FLOH_PORTAL_AUDIENCE, or FLOH_MCP_AUDIENCE

The API verifies JWT aud against those channel identifiers. Console and portal BFFs request FLOH_CONSOLE_AUDIENCE and FLOH_PORTAL_AUDIENCE so Authifi attaches those catalog scopes. FLOH_RESOURCE_ID (OIDC_AUDIENCE alias) remains an Authifi catalog id for operator setup. All four identifiers must be pairwise distinct.

Confidential client secrets and redirect URIs belong to the BFFs (env/console.env, env/portal.env), not the API process.

New Environment Variables

The following env vars were added as part of the architecture hardening work:

Env var Default Description
OIDC_TOKEN_ISSUER OIDC_ISSUER Expected iss in tokens (set when discovery uses a CNAME alias)
ALLOWED_ORIGINS FRONTEND_URL Comma-separated CORS allowed origins
DB_POOL_MAX 10 Max database pool connections
DB_POOL_MIN 2 Min idle database pool connections
DB_POOL_IDLE_TIMEOUT_MS 30000 Idle connection timeout
DB_POOL_CONNECTION_TIMEOUT_MS 5000 Connection acquisition timeout
STUCK_RUN_TIMEOUT_MINUTES 30 Timeout for stuck workflow runs

These can also be managed via Admin > Security Settings in the web UI (requires settings:manage permission).

CSRF Tokens

When OIDC is enabled, cookie-session requests still use double-submit CSRF:

  • the admin app uses floh_console_csrf, minted by the console Authifi BFF
  • the portal uses floh_portal_csrf, minted by the portal Authifi BFF
  • both SPAs send the x-csrf-token header on every proxied HTTP method

Bearer-only proxied portal mutations do not carry a Floh session cookie to the API, so they are not blocked by Floh's cookie-session CSRF gate.

Webhook Configuration

Connector webhooks now require HMAC-SHA256 signature verification. Set a webhook secret on the connector and send X-Webhook-Signature: <hmac-sha256-hex> with each webhook request.

MCP Server (AI Integration)

To set up the MCP server for Claude Desktop or Cursor, see MCP Setup.

pnpm run sync-authifi-rbac -- --dry-run
pnpm run sync-authifi-rbac -- --apply

The sync rebuilds @floh/shared before planning so leftover dist cannot apply an older catalog. It reconciles console, portal, and MCP resource servers, permissions, access roles, and groups. Dry-run is the default; apply requires confirmation. It loads repo .env when present and lets direct shell values override file values. Paste the admin JWT into gitignored .authifi-admin-token (not .env). That must be the Authifi admin API access token (it has org.labshare.tenant.id), not a login ID token. If the JWT is expired (or Authifi returns 401 with no challenge), copy a fresh admin access token into the file and retry — both CLIs check exp locally before calling Authifi. The CLI derives AUTHIFI_BASE_URL, numeric AUTHIFI_TENANT_ID, and AUTHIFI_ADMIN_RESOURCE from that token when they are unset. Set all three FLOH_*_AUDIENCE values in .env or the shell; a complete shell environment also works when .env is absent. Never auto-apply it to production. Use --apply --prune only after reviewing the exact dry-run: prune removes Floh-owned namespaced children and managed permissions, but never resource servers or unrelated tenant objects.

--apply needs an MFA / AAL2 admin token: Authifi requires step-up authentication for group writes and rejects a password-only token with 401 MFA_OR_AAL_2_REQUIRED. The command checks the token up front and refuses to start an apply that would fail partway through, so re-authenticate with MFA and run the apply promptly. Dry-run works with any unexpired admin token; an inspectable JWT past exp fails locally with a refresh-file hint instead of a bare HTTP 401.

STEP_UP_ACR_ALIASES controls which token acr values satisfy Authifi's acr_values=mod-mf challenge. If you override it, the value must be valid JSON mapping non-empty challenge keys to non-empty arrays of safe ACR strings; malformed JSON, empty arrays, unsafe characters, and prototype-pollution keys fail closed before the script constructs a client or contacts Authifi.

Local preflight only inspects readable JWTs. If the admin token file holds an opaque (non-JWT) token, the script cannot read acr or auth_time locally and Authifi's server-side response remains authoritative for step-up enforcement.

After the RBAC apply, register or reconcile the console (floh-client) and portal (floh-portal-client) confidential clients:

pnpm run setup-authifi-oidc-clients -- --help
pnpm run setup-authifi-oidc-clients -- --local-dev --identity-provider nih --dry-run
pnpm run setup-authifi-oidc-clients -- --local-dev --identity-provider nih

--identity-provider (repeatable, or AUTHIFI_IDENTITY_PROVIDERS=nih,google) is required whenever the plan creates the console client — that run aborts without it, before the dry run prints anything. It is optional once the client exists, since reconciling copies its providers. Substitute your tenant's provider name for nih.

Preconditions. Every run that contacts Authifi — --dry-run included, since it lists the existing clients — needs gitignored .authifi-admin-token plus FLOH_RESOURCE_ID (Authifi catalog resource id, not the verified JWT audience; OIDC_AUDIENCE is a deprecated alias), FLOH_CONSOLE_AUDIENCE, and FLOH_PORTAL_AUDIENCE in .env or the shell. Setup derives AUTHIFI_BASE_URL from the token when unset. The two audience flags can override their matching variables. --print-commands needs no credentials or audiences. The command collects all missing values before contacting Authifi. When the initial all-clients listing is empty, setup also requires the numeric AUTHIFI_TENANT_ID; otherwise it validates and uses the unique listed tenant. If both sources exist they must match, and shell values override .env. --vault additionally requires DEFAULT_TENANT.

OIDC dry-run truthfully previews authoritative URI additions/removals, but it cannot preview numeric client-to-resource-server bindings before clients exist. It does validate that all three target resource servers exist uniquely with positive ids before reporting success. Always run the three-channel RBAC sync first. Apply removes the two known managed cross-channel assignments, then reasserts each client's matching resource server. FLOH_RESOURCE_ID remains an Authifi catalog id for operator setup; the API verifies JWT aud against the channel identifiers. All four identifiers must be pairwise distinct.

Exit codes. 0 means everything the run needed is in place. 1 means either the run failed, or it applied successfully but .env is still missing a required value (for example a secret that could not be captured). Nothing partial is ever reported as success.

Rollback. Restore the previous catalog revision, preview with pnpm run sync-authifi-rbac -- --dry-run --prune, then apply with --apply --prune. Without prune, removed catalog entries stay in Authifi as reported extras. Restore the previous complete origin list and rerun OIDC setup. The commands do not rotate existing secrets unless --rotate-secrets is passed. Runtime Authifi grant rechecks on delayed steps remain deferred to LSA-9940.

For the SECRETS_BACKEND=authifi machine client, add --vault. That mints an RSA key pair through Authifi and writes the private key outside the repository at mode 0600; see Secrets management.

Reporting

The admin UI includes a full reporting system at /reports/* with predefined templates, a visual query builder, multi-format export (PDF, Excel, CSV, Markdown), saved reports with sharing and scheduling. See Reporting for details.

Predefined templates are automatically seeded on server startup (migration 035_reporting). PDF export requires Puppeteer; Excel export requires ExcelJS — both are included in dependencies.

Tests

pnpm test:unit        # server unit (vitest)
pnpm test:integration # server integration (testcontainers)
pnpm test:web         # frontend (jest)
pnpm test:e2e:local   # local browser E2E (testcontainers + Playwright)
pnpm test             # all

The local E2E command owns its own ports (17073 for web, 17074 for API) so it can run beside the normal dev stack. Install the Playwright browser once with pnpm --filter @floh/web exec playwright install chromium if prompted.

Troubleshooting

Port already in use — if a dev server fails with ELIFECYCLE / exit status 2, a previous process is still holding the port. Find and kill it:

lsof -ti :7070 | xargs kill   # server
lsof -ti :7074 | xargs kill   # console BFF
lsof -ti :7072 | xargs kill   # web
lsof -ti :7071 | xargs kill   # portal BFF (host Node)
lsof -ti :7073 | xargs kill   # portal web
lsof -ti :17073 | xargs kill  # local E2E web
lsof -ti :17074 | xargs kill  # local E2E API

If lsof shows com.docker on 7071 or 7074, a leftover BFF container is publishing the port (pnpm dev / pnpm dev:portal / pnpm dev:console now run BFFs as host Node by default). List it with docker ps --filter publish=7071 or publish=7074 and stop it. Host Node and a Docker BFF cannot share the same listen port.

If lsof shows node with --inspect=127.0.0.1:9230 (portal) or :9231 (console), a leftover host-Node BFF already owns that channel.

  • Combined pnpm dev:portal: leave that BFF running and start only pnpm dev:portal:web:https, or stop that pid. Do not start combined pnpm dev:portal on top of it.
  • Mux portal-bff pane: restart the pane (r). --bff-only replaces an orphaned BFF (parent pid 1, typical after a previous pane restart). If another live pnpm dev / pnpm dev:portal still owns the process, stop that stack first.

Endless "App Update Issue" toast / "Failed to fetch dynamically imported module" / 504 Gateway Timeout for /.angular/cache/.../vite/deps/... — Angular's Vite-based dev-server (@angular/build:dev-server) pre-bundles CommonJS deps into packages/<pkg>/.angular/cache/.../vite/deps/. That cache occasionally wedges (most often after switching builders, upgrading Angular, or interrupting a build mid-optimization), and surfaces as a 504 on a single dep file like primeng_chart.js.

Most cases are now caught automatically by scripts/maybe-clean-vite-cache.mjs which runs as part of dev / dev:https and wipes .angular/cache/ whenever pnpm-lock.yaml, package.json, or angular.json has changed since the last successful start. A separate browser-side guard in GlobalErrorHandler (packages/web/src/app/core/error-handler.ts) prevents the toast/refresh storm even if a wedge slips through.

If you still hit a wedge (e.g. interrupted a build mid-optimization), use the manual escape hatch — stop the dev server (Ctrl+C), nuke the cache, restart:

pnpm --filter @floh/web run dev:https:clean       # admin
pnpm --filter @floh/portal-web run dev:https:clean # portal
# or for the full stack:
pnpm dev:https:clean

After restart, in the browser do DevTools → Application → Storage → Clear site data → reload. The first build after a clean takes ~10–20s longer because Vite re-pre-bundles every CJS dep from scratch.

Stop Infrastructure

pnpm dev:docker:down