Skip to main content

Architecture

System Overview​

┌─────────────────────────────────────────────────────────────────────────────┐
│ Browser (external-ui) │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ /workflow-interaction Page (Split-Pane) │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌────────────────────────────────────┐ │ │
│ │ │ Action List Pane │ │ Action Detail Pane │ │ │
│ │ │ │ │ │ │ │
│ │ │ • Status filter │ │ ┌─────────────────────────────┐ │ │ │
│ │ │ • Action cards │ │ │ GetApprovalHandler │ │ │ │
│ │ │ • Load More │ │ │ ShowFormHandler │ │ │ │
│ │ │ │ │ │ WaitOnEventHandler │ │ │ │
│ │ │ │ │ │ CompletedActionView │ │ │ │
│ │ │ │ │ └─────────────────────────────┘ │ │ │
│ │ └─────────────────────┘ └────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Service Layer: services/backend/wil.ts + auth.ts + axios.ts │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────┘
│ │ │
│ GET /actions │ POST /callback │ POST /chefs-token
│ GET /messages │ │
│ GET /tenants │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────────────────────┐
│ external-hooks (Express on n8n) │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ /ui-api/wil Router (buildWilRouter) │ │
│ │ │ │
│ │ Middleware: requireUiRequestContext (OIDC session validation) │ │
│ │ │ │
│ │ Helpers: │ │
│ │ • resolveWilTenantProjectIds — tenant → project IDs │ │
│ │ • resolveActorMatchers — session + tenantId → user/role/group ids │ │
│ │ • mapActionToUiResponse — strip sensitive fields │ │
│ │ • formatListResponse — keyset pagination cursor │ │
│ │ │ │
│ │ Services: │ │
│ │ • ActionService.list() / .getById() / .updateStatus() │ │
│ │ • MessageService.list() │ │
│ │ • ChefsService.getFormToken() │ │
│ │ • TenantService.listTenants() │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────┘
│ │
│ Forward callback body │ Basic Auth token exchange
▼ ▼
┌─────────────────────┐ ┌──────────────────────────────┐
│ n8n Webhook URL │ │ CHEFS Gateway API │
│ (upstream callback)│ │ /auth/token/forms/{formId} │
└─────────────────────┘ └──────────────────────────────┘

Component Hierarchy (Frontend)​

WorkflowInteraction (page — pages/workflow-interaction.tsx)
├── TenantSelector (components/wil/tenant-selector.tsx)
├── DateFilter (components/wil/date-filter.tsx)
├── TabBar (components/wil/tab-bar.tsx)
├── StatusFilter (components/wil/status-filter.tsx)
├── Split-Pane Grid
│ ├── ActionsTab (components/wil/actions-tab.tsx)
│ │ └── ActionItem[] (components/wil/action-item.tsx)
│ └── ActionDetailPane (components/action-detail-pane.tsx)
│ ├── Placeholder (no selection)
│ ├── GetApprovalHandler (components/action-handlers/get-approval-handler.tsx)
│ ├── ShowFormHandler (components/action-handlers/show-form-handler.tsx)
│ │ └── ChefsFormViewer (components/chefs/chefs-form-viewer.tsx)
│ │ └── <chefs-form-viewer> (web component)
│ ├── WaitOnEventHandler (components/action-handlers/wait-on-event-handler.tsx)
│ ├── CompletedActionView (components/action-handlers/completed-action-view.tsx)
│ └── UnsupportedAction (fallback)
└── MessagesTab (components/wil/messages-tab.tsx)
└── MessageItem (inline)

File Organization​

The frontend is organized by domain:

external-ui/src/
├── pages/
│ └── workflow-interaction.tsx ← Page shell (state, layout, routing)
├── components/
│ ├── wil/ ← WIL page sub-components
│ │ ├── index.ts ← Barrel export
│ │ ├── tenant-selector.tsx ← Tenant dropdown (react-query)
│ │ ├── date-filter.tsx ← Time range filter + computeSinceDate()
│ │ ├── status-filter.tsx ← Multi-select status toggle
│ │ ├── tab-bar.tsx ← Actions/Messages tab switcher
│ │ ├── actions-tab.tsx ← Action list with query + pagination
│ │ ├── action-item.tsx ← Single action card (badges)
│ │ └── messages-tab.tsx ← Message list with query + pagination
│ ├── action-handlers/ ← Action type interaction handlers
│ │ ├── shared/
│ │ │ └── error-utils.ts ← Shared extractErrorMessage utility
│ │ ├── get-approval-handler.tsx
│ │ ├── show-form-handler.tsx
│ │ ├── wait-on-event-handler.tsx
│ │ └── completed-action-view.tsx
│ ├── action-detail-pane.tsx ← Handler dispatcher (routes by actionType)
│ └── chefs/ ← CHEFS form viewer integration
│ ├── chefs-form-viewer.tsx
│ ├── use-chefs-script.hook.ts
│ └── types.ts
└── services/backend/
├── wil.ts ← WIL API service functions
├── auth.ts ← Whoami / session calls
└── axios.ts ← Axios instance + token storage

Backend Route Structure​

Express app (n8n)
└── /ui-api (buildUiApiRouter)
├── /session, /auth/*, /whoami, /workflows (existing)
└── /wil (buildWilRouter — sub-router)
├── GET /tenants → TenantService.listTenants()
├── GET /messages → MessageService.list()
├── GET /actions → ActionService.list() + mapActionToUiResponse
├── POST /chefs-token → ActionService.getById() + ChefsService.getFormToken()
└── POST /callback → ActionService.getById() + fetch(callbackUrl) + updateStatus()

Request Lifecycle​

Standard Action List Request​

  1. Browser sends GET /ui-api/wil/actions?status=pending&limit=20 with headers:

    • Authorization: Bearer <app-token>
    • X-TENANT-ID: <uuid>
  2. requireUiRequestContext middleware validates the Bearer JWT, attaches session.

  3. resolveWilTenantProjectIds extracts and validates X-TENANT-ID, queries tenant_project_relation table for project IDs. For current tenant-source limits and the distinction between tenant listing and route-level scoping, see ../wil-tenant-source-and-limitations.md.

  4. resolveActorMatchers builds the actor filter from the OIDC session and the validated tenantId:

    • userId — user's email
    • userFallback — OIDC subject (legacy identifier)
    • roleNames — CSTAR role names the user holds in this tenant
    • groupNames — CSTAR group names the user belongs to in this tenant
  5. Zod schema parses query params (limit, since, status).

  6. ActionService.list() queries action_requests table with a single OR clause:

    WHERE project_id IN (resolved project IDs)
    AND (
    (actor_type = 'user' AND actor_id IN [email, subject])
    OR (actor_type = 'role' AND actor_id IN [role names])
    OR (actor_type = 'group' AND actor_id IN [group names])
    )
    AND status IN (filter values)
  7. mapActionToUiResponse strips sensitive fields from each action.

  8. formatListResponse adds nextCursor if results fill the limit.

  9. Response returned to browser.

Callback Proxy Request​

  1. Browser sends POST /ui-api/wil/callback with body { actionId, body: {...} }.

  2. Backend fetches the full action record (including callbackUrl, callbackMethod).

  3. If callbackMethod === 'NONE' or URL is empty → mark completed, return.

  4. Otherwise, forward body to callbackUrl with 30-second timeout.

  5. On upstream 2xx → update action status to completed → return success.

  6. On upstream error → return error without updating status (user can retry).

Key Architectural Decisions​

Why a Callback Proxy?​

The n8n webhook URLs contain internal routing information. Exposing them to the browser would:

  • Allow users to bypass the UI and call webhooks directly
  • Reveal internal infrastructure details
  • Prevent server-side audit logging of interactions

The proxy fetches the URL from the DB at call time, ensuring the frontend only needs the actionId.

Why Separate CHEFS Token Exchange?​

The CHEFS FormAPIKey is a long-lived secret that grants full API access to a form. The exchange endpoint:

  • Keeps the key server-side (never sent to browser)
  • Returns a short-lived JWT scoped to one form
  • Allows the backend to validate the action exists and belongs to the actor before issuing a token

Why Keyset Pagination?​

Offset-based pagination (OFFSET N) suffers from drift when new records are inserted. Keyset pagination using createdAt|id cursors provides:

  • Stable results as new actions arrive
  • O(1) performance regardless of offset depth
  • Simple "Load More" UX without page numbers

Why OR-Based Actor Matching?​

Actions and messages can be assigned to a user (by email or OIDC subject), a CSTAR role, or a CSTAR group. A single OR-based query matches all three in one database round-trip:

  • actor_type = 'user' — direct user assignment by email or legacy OIDC subject
  • actor_type = 'role' — any user holding that CSTAR role in the current tenant sees the item
  • actor_type = 'group' — any member of that CSTAR group in the current tenant sees the item

This allows workflow designers to target a position (role) or a team (group) rather than a specific individual, which is the common case in government workflows where the responsible party may vary.