Public Portal¶
The public portal enables external users to interact with Floh without direct access to the firewalled admin interface. It provides a minimal, user-facing experience for accepting invitations, completing tasks, uploading documents, and managing approvals.
Architecture¶
┌────── Firewall ──────┐
│ │
┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ ┌──────────┐
│ Portal SPA │──▶│ Portal BFF │──▶│ Floh Internal │──▶│ DB │
│ (Angular) │ │ (Fastify) │ │ Server (Fastify) │ │ Redis │
│ port 7073 │ │ port 7071 │ │ port 7070 │ │ SMTP │
└──────────────┘ └──────────────┘ └──────────────────────┘ └──────────┘
Public Public Private
The portal consists of two public-facing services:
- Portal BFF (
packages/portal-bff) — a stateless reverse proxy (HTTPS in local dev whenPORTAL_LISTEN_TLS=true, HTTP otherwise; TLS at the edge in production) - Portal SPA (
packages/portal-web) — a minimal Angular frontend
Both sit outside the organization's firewall. The internal Floh server, database, Redis, and OIDC provider remain inside the firewall, accessible only to the BFF.
Portal BFF¶
The Backend-for-Frontend is a lightweight Fastify application that acts as a pure HTTP proxy. It has no direct connections to the database, Redis, or OIDC provider, minimizing the attack surface of the publicly exposed service.
Route Whitelisting¶
Only a predefined set of user-facing API routes are forwarded to the internal server. All other requests receive a 404 Not Found response.
| Category | Routes |
|---|---|
| Auth | GET /api/auth/config, GET /api/auth/login, GET /api/auth/callback, POST /api/auth/logout, GET /api/auth/me, GET /api/auth/session, POST /api/auth/session/extend |
| Invitations | GET /api/invitations/verify/:token, POST /api/invitations/respond/:token, GET /api/invitations/pending |
| Tasks | GET /api/tasks, GET /api/tasks/:id, POST /api/tasks/:id/complete, POST /api/tasks/:id/respond |
| Task Lifecycle | POST /api/tasks/:id/claim, POST /api/tasks/:id/hold, POST /api/tasks/:id/unhold (LSA-8721 / LSA-8722). assign / unassign remain admin-only on the upstream. |
| Task Comments | GET /api/tasks/:id/comments, POST /api/tasks/:id/comments, PATCH /api/tasks/:id/comments/:commentId, DELETE /api/tasks/:id/comments/:commentId (LSA-8723). |
| Approvals | GET /api/approvals, GET /api/approvals/:id, POST /api/approvals/:id/decide |
| Documents | GET /api/documents, GET /api/documents/:id, GET /api/documents/:id/download, POST /api/documents/upload |
| Document Templates | GET /api/document-templates/:id, GET /api/document-templates/:id/download |
| Runs (read-only) | GET /api/runs/:id |
| Request Catalog | GET /api/request-catalog, POST /api/request-catalog/:id/submit |
| Health | GET /api/health |
Administrative routes (workflows, users, roles, connectors, audit, reports, config-transfer, etc.) are blocked.
Scope Enforcement¶
The BFF strips the scope query parameter from /api/tasks and /api/approvals requests. This ensures portal users always see only their own assignments, even if a client attempts to request scope=all.
Header Injection¶
On every proxied request, the BFF injects:
X-Portal-Origin— set toPORTAL_FRONTEND_URL, so the internal server knows to redirect back to the portal after authenticationX-Forwarded-For— the original client IP address
Security Features¶
| Feature | Implementation |
|---|---|
| Rate limiting | @fastify/rate-limit — configurable max requests per window |
| Security headers | @fastify/helmet with strict Content Security Policy |
| CORS | Restricted to PORTAL_FRONTEND_URL only |
| Body size limits | Configurable via MAX_UPLOAD_SIZE (default 10 MB) |
Configuration¶
| Variable | Default | Description |
|---|---|---|
PORTAL_PORT |
7071 |
BFF listen port |
PORTAL_HOST |
0.0.0.0 |
BFF listen host |
FLOH_INTERNAL_URL |
https://localhost:7070 |
Internal Floh server URL (use https:// when the API serves TLS; http:// only for HTTP-only dev) |
PORTAL_FRONTEND_URL |
https://localhost:7073 |
Portal SPA URL (for CORS, header injection); use http:// only for HTTP-only dev |
MAX_UPLOAD_SIZE |
10485760 |
Maximum request body size in bytes |
RATE_LIMIT_MAX |
100 |
Max requests per rate limit window |
RATE_LIMIT_WINDOW_MS |
60000 |
Rate limit window in milliseconds |
LOG_LEVEL |
info |
Log level |
PORTAL_LISTEN_TLS |
(unset) | When true or 1, BFF serves HTTPS on PORTAL_PORT using TLS_CERT_FILE / TLS_KEY_FILE (same paths as the API). pnpm dev:portal:https sets this in the shell. |
Portal SPA¶
The portal SPA is a stripped-down Angular application derived from the main Floh frontend. It includes only the components and routes external users need.
Routes¶
| Path | Component | Auth Required | Description |
|---|---|---|---|
/welcome |
WelcomeComponent | No | Landing page with login button |
/dashboard |
PortalDashboardComponent | Yes | Pending invitations, tasks, approvals |
/tasks |
TaskInboxComponent | Yes | Task inbox (tasks and approvals) |
/requests/catalog |
RequestCatalogComponent | Yes | Browse and submit workflow requests |
/invitations/respond |
InvitationRespondComponent | No* | Accept or decline invitations |
/auth/callback |
AuthCallbackComponent | No | OIDC callback handler |
*Invitations require authentication to respond, but the page itself loads without auth to verify the token and prompt login.
Removed Features¶
Compared to the main Floh frontend, the portal SPA does not include:
- Sidebar navigation
- Workflow designer / definition management
- User / role / organization management
- Connector management
- Audit log viewer
- Reports / analytics
- Admin panel
- Permission override controls
- Project and workflow set filters
Request Catalog¶
The Request Catalog allows portal users to browse published workflows and submit requests. Administrators configure which workflows appear in the catalog from the admin UI (catalog publishing toggle, icon, description, and tags on the workflow detail page).
- Users browse published workflows displayed as cards with icons, descriptions, category tags, and searchable content
- Filtering is available by category, free-text search, and tag selection
- Administrators can restrict submission to members of specific groups via the "Submission Restrictions" setting on the catalog publishing card; restricted entries are hidden from non-members
- Submitting a request opens a dynamic form built from the workflow's variable definitions (excluding secret variables)
- On submission, a workflow run is started via
POST /api/request-catalog/:id/submit(requires only authentication, no specific permission) and a success confirmation is shown - When no real catalog entries exist, example entries are shown to give users a sense of the UI
The Request Catalog link appears in the portal topbar alongside Dashboard and Tasks.
Draft Preview (workflow author testing)¶
To shorten the develop-and-test loop for workflow authors, draft workflows that have catalogPublished turned on are also visible in the request catalog — but only to users who hold the workflow:publish permission (admin and resource_manager by default). Other portal users continue to see only active catalog entries; the draft is filtered out of GET /api/request-catalog and POST /api/request-catalog/:id/submit returns 404 rather than disclosing that the draft exists.
This lets authors validate the real portal submission UX (the rendered form, group restrictions, step-up auth) without flipping the workflow to active for the entire org. Submissions of a draft from the portal create real workflow runs with all configured side effects (notifications, approvals, connector calls), so authors should treat them like a regular run when their workflow makes external changes.
In the admin workflow editor, the Catalog Publishing card is now also available while a workflow is in draft status. Toggle Published to Catalog on a draft to expose it in the portal preview; the card shows a Draft preview tag so authors can see that the entry is not yet visible to the wider org. The toggle remains disabled for deprecated workflows.
Dashboard¶
The portal dashboard displays three cards:
- Pending Invitations — invitations awaiting the user's response, with a "Respond" link
- Active Tasks — the user's assigned tasks (up to 5), with a link to the full task inbox
- Pending Approvals — approvals awaiting the user's decision (up to 5)
Internal Server Changes¶
The portal requires two small changes to the internal Floh server:
Portal-Aware Auth Redirects¶
The resolveRedirectBase function in packages/server/src/modules/auth/routes.ts checks for the X-Portal-Origin header on incoming requests. If the header value matches one of the configured ALLOWED_PORTAL_ORIGINS, the server redirects to the portal frontend instead of the admin frontend.
This affects:
- OIDC callback — redirects to
{portalUrl}/auth/callbackafter login - Account deleted — redirects to
{portalUrl}/auth/callback?error=account_deleted - Logout — uses the portal URL as the
post_logout_redirect_uri - Cookie security — sets the
Secureflag based on the portal URL scheme
Notification Invitation Links¶
When PORTAL_FRONTEND_URL is configured, invitation emails link to the portal instead of the admin frontend. This is controlled by:
in packages/server/src/modules/notifications/service.ts.
Internal Server Configuration¶
| Variable | Default | Description |
|---|---|---|
ALLOWED_PORTAL_ORIGINS |
(empty) | Comma-separated list of allowed portal frontend URLs |
PORTAL_FRONTEND_URL |
(empty) | Portal frontend URL for invitation email links |
Development¶
Starting the Portal¶
Preferred (HTTPS): configure TLS on the API (TLS_CERT_FILE, TLS_KEY_FILE, NODE_EXTRA_CA_CERTS) and use the HTTPS dev scripts.
# Start infrastructure (if not already running)
docker compose -f docker/docker-compose.yml up -d postgres redis mailhog
# Run migrations
pnpm migrate:latest
# Terminal 1 — API + admin UI (or `pnpm dev:server` if you only need the API)
pnpm dev:https
# Terminal 2 — portal BFF + portal SPA (HTTPS on :7071 and :7073; script sets PORTAL_LISTEN_TLS)
pnpm dev:portal:https
HTTP-only alternative:
Or start the main stack with pnpm dev, then pnpm dev:portal in another terminal.
Environment Setup¶
Add these variables to your .env file (HTTPS preferred; match schemes to your dev scripts):
# Portal BFF
PORTAL_PORT=7071
# Optional in .env: pnpm dev:portal:https already exports PORTAL_LISTEN_TLS=true
# PORTAL_LISTEN_TLS=true
FLOH_INTERNAL_URL=https://localhost:7070
PORTAL_FRONTEND_URL=https://localhost:7073
# Internal server (portal support)
ALLOWED_PORTAL_ORIGINS=https://localhost:7073
For HTTP-only local dev, use http:// in all three URLs above, omit PORTAL_LISTEN_TLS, and run pnpm dev:portal.
Running Tests¶
pnpm test:portal-bff # Portal BFF tests (vitest)
pnpm test:portal-web # Portal frontend tests (jest)
Docker Deployment¶
Building¶
Running¶
# Start everything (main + portal)
docker compose -f docker/docker-compose.yml -f docker/docker-compose.portal.yml up -d
Or run only the portal stack (assumes the internal server is already running):
Docker Services¶
| Service | Image | Port | Description |
|---|---|---|---|
portal-bff |
Dockerfile.portal-bff |
7071 | Stateless proxy |
portal-web |
Dockerfile.portal-web |
7073 | Angular SPA via nginx |
Network Topology¶
┌─────────── Public Network ───────────┐
│ portal-web ──▶ portal-bff │
└──────────────────┬───────────────────┘
│
┌──────────────────▼───────────────────┐
│ portal-bff ──▶ server │
│ Internal Network │
│ server ──▶ postgres, redis │
└──────────────────────────────────────┘
The portal-bff and portal-web services are on the public network. The BFF also joins the internal network to reach the server. The server, database, and Redis are on the internal network only, never exposed publicly.
Security Considerations¶
- Minimal attack surface — the BFF has no database, Redis, or OIDC connections; it only forwards whitelisted HTTP requests
- Route whitelisting — only user-facing endpoints are accessible; admin APIs return 404
- Scope enforcement —
scope=allis stripped, preventing privilege escalation - Origin validation — the internal server validates
X-Portal-Originagainst an allowlist before using it for redirects - Rate limiting — the BFF limits request rates to prevent abuse
- CORS — strict origin policy locked to the portal frontend URL
- CSP — Content Security Policy via helmet restricts script/style/image sources
- Cookie security —
Secureflag is set based on the target URL scheme - No secrets in the BFF — the BFF only needs the internal server URL and portal frontend URL; no database credentials, session secrets, or OIDC secrets
- Step-up authentication — sensitive workflow steps (consent steps with
requireStepUpAuth, catalog entries withcatalogRequireStepUpAuth) trigger an OIDC re-auth withacr_values=mod-mf. The portal SPA'sstepUpInterceptorwatches for401 { code: "MFA_OR_AAL_2_REQUIRED" }responses and redirects through/api/auth/login?acr_values=mod-mf&return_to=<currentPath>so the user resumes the original action after MFA. See Security › Step-Up Authentication.