Dev Quickstart¶
Prerequisites¶
- Node.js 26 from
.nvmrcfor local development (nvm use; CI and release read the same file viaactions/setup-node) - Corepack installed globally (
npm install --global corepack) and enabled (corepack enable) for thepnpmversion pinned inpackage.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-tokenwhen you confirm the.envwrite.FLOH_RESOURCE_ID,FLOH_CONSOLE_AUDIENCE,FLOH_PORTAL_AUDIENCE,FLOH_MCP_AUDIENCE— four pairwise-distinct identifiers. Console BFFAUTH_RESOURCEisFLOH_CONSOLE_AUDIENCEfromenv/console.env; portal BFF usesFLOH_PORTAL_AUDIENCEfromenv/portal.env. The API verifies JWTaudagainst those channel ids (andFLOH_MCP_AUDIENCEwhen set).FLOH_RESOURCE_IDis an Authifi catalog id used by operator setup;OIDC_AUDIENCEis a deprecated alias. Local defaults arehttp://floh.apiandhttp://{console,portal,mcp}.floh.api.AUTHIFI_BASE_URL— Authifi/_apibase (derived from the admin tokeniss).AUTHIFI_TENANT_ID— numeric tenant id (from the admin token).- Admin token — gitignored
.authifi-admin-token, never.env.SERVICES_AUTH_URLis a deprecated alias ofAUTHIFI_BASE_URL. FRONTEND_URL=https://localhost:7072andPORTAL_FRONTEND_URL=https://localhost:7073are already correct for local HTTPS.- Compose defaults (
DB_*,REDIS_*, MailHogSMTP_*) work as copied unless another local service already owns those ports — then changeDB_PORTas 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:
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.
Start Everything (recommended)¶
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:
node scripts/docker-infra.mjs --wait—docker compose up -d --waitfor Postgres + Redis (and MailHog, unless port 1025 is already taken), blocking until each container's healthcheck passes.pnpm migrate:latest— apply pending database migrations.pnpm dev:<mode>— start the chosen dev mode. The interactive picker is protocol-split:pnpm dev:alllists HTTPS stacks (dev:https,dev:mux,dev:mux:web,dev:https:clean,dev:server);pnpm dev:all:httplists HTTP stacks (dev,dev:mux:http,dev:mux:http:web,dev:web,dev:server). Pass any registereddev:*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 thedev-all.mjsnode process so it shows inps, htop, and Activity Monitor asfloh dev:<mode>. Cursor / VS Code's default${process}template walks toward the leaf of the foreground process tree (so it usually reportsnode,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+P → Preferences:
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:
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:infraprobes 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 thedocker compose upinvocation 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.envonly. Compose publishes that host port onto container 5432;pnpm dev:migrate:latestand the local (host) server use the same value.pnpm dev:docker:upserver/worker stay on in-networkpostgres:5432— composeenvironmentpinsDB_PORTso the host remap cannot leak throughenv_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):
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.0into gitignoredlocal/bff-gateway/and binds the inspector on127.0.0.1:9230. - Console installs
@authifi/auth-bff-gateway@3.4.0into gitignoredlocal/bff-gateway-console/and binds the inspector on127.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 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:
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_ISSUEROIDC_CLIENT_IDFLOH_CONSOLE_AUDIENCE,FLOH_PORTAL_AUDIENCE, orFLOH_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-tokenheader 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.
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 onlypnpm dev:portal:web:https, or stop that pid. Do not start combinedpnpm dev:portalon top of it. - Mux
portal-bffpane: restart the pane (r).--bff-onlyreplaces an orphaned BFF (parent pid 1, typical after a previous pane restart). If another livepnpm dev/pnpm dev:portalstill 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.