A proof of concept: a CIB seven 2.2 process engine runs a BPMN process with two human tasks, a DMN auto-approval decision, a non-interrupting timer boundary event, and five connector-backed service tasks (one fetches a product price, one renders an approval PDF via Gotenberg, three POST email notifications — one with the PDF attached — to Mailpit). A React app opens each human task with its own hand-written form; the Cockpit / Tasklist / Admin webapps are also bundled with full Keycloak SSO.
The React SPA and the Keycloak login are branded eRegistrations; "CIB seven" throughout this repo always refers to the underlying process engine (cibseven.org), not the app.
It is a slice of the larger design in
docs/human-role-react-forms-spec.md —
see Deviations from the spec below.
A hosted instance runs at https://companylab.ai — try it without cloning anything.
| User | Password | Role | Sees |
|---|---|---|---|
bart |
bart |
applicant | PartA — start & fill applications |
homer |
homer |
civil servant | PartB — review / accept / send back |
admin |
admin |
Camunda admin | Cockpit / Admin / Tasklist consoles |
Camunda webapps: https://companylab.ai/camunda/app/cockpit/ · Keycloak admin:
https://keycloak.companylab.ai/admin/ · demo email inbox (registration &
notification mail): https://companylab.ai/mailpit. The Traefik dashboard is
local-dev only. The table below is the local docker compose equivalent —
same logins, just swap localhost:3000 for companylab.ai.
Shared inbox — don't enter real data. The demo has Keycloak email verification on, and all mail (verification links + process notifications) lands in one public Mailpit inbox that any visitor can read. New registrants must open https://companylab.ai/mailpit to click their verification link. The SPA shows a demo banner (warning + a "Demo email inbox" link) above its header, and the Keycloak registration / verify-email pages carry the same warning inline.
| Console | URL | Login | Password |
|---|---|---|---|
| React SPA (PartA & PartB) | http://localhost:3000 | bart / homer |
same as username |
| ↳ PartA — applicant | bart |
bart |
|
| ↳ PartB — civil servant | homer |
homer |
|
| CIB seven Admin (users, groups, authorizations) | http://localhost:3000/camunda/app/admin/ | admin |
admin |
| CIB seven Cockpit (process instances, incidents) | http://localhost:3000/camunda/app/cockpit/ | admin |
admin |
| CIB seven Tasklist (legacy task UI) | http://localhost:3000/camunda/app/tasklist/ | admin |
admin |
| CIB seven REST API | http://localhost:3000/engine-rest | Bearer JWT from Keycloak | — |
| Mailpit inbox (process-sent emails) | http://localhost:8025 (needs docker compose --profile dev up -d mailpit-ui) |
— | — |
| Keycloak admin console (realm / users / clients) | http://localhost:8180/admin/ | admin |
admin |
| Traefik dashboard (inspect ingress routes) | http://localhost:8081/dashboard/ | — | — |
| MCP endpoint (Claude Desktop, Cursor, Codex, …) | http://localhost:3000/mcp | OAuth2 PKCE via Keycloak | (browser pops, log in as bart / homer) |
| ↳ OAuth resource metadata | http://localhost:3000/.well-known/oauth-protected-resource | — | — |
Everything except Keycloak comes in through a single Traefik ingress on port
3000. The engine container's 8080, the MCP container's 8090, the frontend's
80, and Mailpit's 8025/1025 are no longer published to the host — they are
reachable only via the path-routed front door (or, for Mailpit's web UI,
the opt-in dev profile). Keycloak stays on its own port to avoid moving
the issuer URL stamped into existing JWTs.
Role notes:
bart(Bart Simpson) —/applicantgroup, sees PartA on the SPA.homer(Homer Simpson) —/civil-servantgroup, sees PartB on the SPA. Cannot access/camundawebapps (those need/cib7-admin).admin—/cib7-admingroup only, dedicated Camunda administrator. Admin everything on the/camundawebapps; not used in the SPA UI flows.- Engine authorizations for
/applicantand/civil-servantare bootstrapped on startup bycib7/src/main/java/com/poc/cib7/AuthorizationBootstrap.java;/cib7-adminis handled by the cibseven-keycloak plugin'sadministratorGroupNamesetting (full admin powers). - Both webapp and SPA logins go through Keycloak SSO against the
cib7-pocrealm; the underlying user store iskeycloak/realm-export.json.
The SPA picks the role-appropriate UI from the JWT's realm roles:
- PartA — applicant: Services + My processes. Bart starts a process, fills the applicant form, and watches the status. If a civil servant sends the case back, the row's status shows "Sent back for corrections" and Bart can reopen the form (with the send-back reason shown as a banner) and resubmit.
- PartB — back office: Tasks + Incidents. Homer reviews the submitted application, then Accept (process ends approved) or Send back… (writes a reason variable and loops back to the applicant task).
Full realm in keycloak/realm-export.json.
Four services ship today — Vehicle Registration (vehicleRegistration),
Estonian OÜ Registration (businessRegistration), and the two ITS
demo scenarios from the Transport Authority Integrated Traffic System tender:
Transport Vehicle Registration (transportVehicleRegistration, Demo Scenario 1 —
fee-schedule preview, clearance checks, eligibility DMN, traffic-officer
review, EUR fee payment, plate allocation, certificate PDF) and Transport Driving Learner Permit (transportLearningPermit, Demo Scenario 2 — flat 6 EUR
fee, approved-optician eye-test lookup, Police Hospital weak-vision branch,
electronic license PDF). See
docs/business/services/transport-vehicle-registration/
and
docs/business/services/transport-learning-permit/.
The original services share the same shape; the vehicle flow end to end:
Vehicle Registration (BPMN + DMN)
start (initiator = applicant)
│
▼ Submit owner & vehicle details user task (applicant — PartA)
│ names, age, email, ID-document upload, a vehicle picked from the
│ curated registry, optional co-owners — assignee = ${initiator}
│ ◀───────────────────────────────────────────────────────────────────┐
▼ Attach owner ID document service task → backend /api/documents │
▼ Co-owner signatures multi-instance subprocess (email links, │
│ public /confirm-owner/{token} pages, message correlation) │
▼ Look up vehicle in registry service task (http-connector) │
│ GET {busBaseUrl}/api/public/vehicle-registry/vehicles/{vin} │
│ → price, vehicleAgeYears, make/model/year/fuelType │
▼ Auto-approval policy business rule task (DMN) │
│ age + price + vehicleAgeYears → autoDecision │
▼ Auto-approve? ── gateway ──else──▶ Transport Authority review │
│ user task (civil servant) │
│ ⏱ PT2M reminder · Accept / │
│ Send back ───────────────────────┘
▼ Generate + store state-fee invoice PDF (pdf-renderer → backend S3)
▼ Wait for state fee payment receive task — public /pay/{piId} page
▼ Generate + store registration certificate
▼ end — "Vehicle registered"
The OÜ flow swaps vehicle semantics for company founding (Articles of
Association upload, co-founder signing via /sign-founder/{token}, a flat
€265 fee, B-card extract at the end) — same building blocks throughout. Both
SPAs surface the case's position live: a case-progress stepper on every
case view, payment-required alerts in the applicant inbox, and wait-state
labels in the back-office worklist.
A business service is defined once as a markdown spec under
docs/business/services/<service>/. Everything else — BPMN, DMN, React
forms, FreeMarker payloads, the form registry — is generated from it by the
/service-builder skill. The
markdown folder is the portable unit: copy it into another instance of this
app, tweak the country-specific bits, regenerate, and you have the same
service localized.
flowchart LR
Analyst(("Analyst<br/>writes markdown only"))
subgraph EE["Estonia — cib7-react-poc instance"]
direction TB
EE_Spec[/"docs/business/services/<br/>business-registry/<br/>README.md · forms/*.md<br/>service-tasks/*.md · decisions/*.md"/]
EE_Builder[["/service-builder"]]
EE_Code["BPMN + DMN + FreeMarker<br/>React forms + registry<br/>(generated)"]
EE_Run["docker compose up<br/>then git commit"]
EE_Spec --> EE_Builder --> EE_Code --> EE_Run
end
subgraph FI["Finland — cib7-react-poc instance (same app code)"]
direction TB
FI_Spec[/"docs/business/services/<br/>business-registry/<br/>(copied + FI tweaks:<br/>labels, fields, rules, fees)"/]
FI_Builder[["/service-builder"]]
FI_Code["BPMN + DMN + FreeMarker<br/>React forms + registry<br/>(generated, FI variant)"]
FI_Run["docker compose up<br/>then git commit"]
FI_Spec --> FI_Builder --> FI_Code --> FI_Run
end
Analyst --> EE_Spec
EE_Spec -. "copy the<br/>service folder" .-> FI_Spec
- Portable: the markdown spec folder. One analyst-authored artifact describes the service end-to-end (flow, forms, integrations, decisions, roles, variables).
- Per-instance: the generated BPMN / React / DMN / FreeMarker
(re-derived on each side by
/service-builder) and the deployment (Docker, Keycloak realm, env vars). - Localization lives in the spec, not in code. The FI variant edits the same markdown files — different field labels, different DMN rules (e.g. local fee thresholds), different email copy — and runs the builder again. The app code stays untouched.
For the step-by-step workflow — what each markdown file must define, how to run the builder, how to test — see Add or modify a service.
React SPA ──OIDC PKCE──▶ Keycloak ◀──Admin REST── cib7 engine service
│ (keycloak-js) │ ▲ (identity provider plugin)
│ │ │ OAuth2 code flow
│ Bearer JWT │ │ (cib7-webapps client)
▼ │ │
/engine-rest │ ▼
/camunda/* ─────▶ CIB seven 2.2 engine + REST + Cockpit/Tasklist/Admin
│ (nginx / Vite (cib7/ — Spring Boot, embedded engine, in-memory H2;
│ proxy) plugins + connectors only, no business endpoints)
│ │
│ ├──▶ http-connector → backend /api
│ │ (vehicle registry lookup, document
│ │ move-pending / server-upload)
│ ├──▶ http-connector → Mailpit (notifications
│ │ + PDF attachments)
│ └──▶ http-connector → pdf-renderer → Gotenberg
│ (HTML → PDF, internal)
▼
/api/* ────────▶ backend business microservice (backend/ — Spring Boot 4)
public confirmations · payments · vehicle registry ·
documents (JPA metadata + RustFS S3 presigned URLs)
└──▶ /engine-rest (cib7-business service account)
- The browser logs in against Keycloak (OIDC, PKCE) and then calls the
same-origin paths
/engine-rest/...(engine) and/api/...(backend) with a Bearer JWT where required. In Docker, nginx/Traefik route the paths; in dev, the Vite dev server does. No CORS configuration needed. - The engine validates JWTs (Spring Security OAuth2 Resource Server) and the
CIB seven Keycloak Identity Provider Plugin (
cibseven-keycloak2.1.0) reads users and groups from Keycloak's Admin REST API. Engine authorization is on, socandidateGroupson user tasks is enforced. - The backend owns every business endpoint: the public token-link pages
(owner confirmations, founder signing, state-fee payments), the curated
vehicle registry, and document storage (presigned RustFS uploads, JPA
Documentmetadata). It talks to the engine only via/engine-rest, authenticated as thecib7-businessKeycloak service account. - The BPMN/DMN files live in the engine module under
processes/<service>/and are deployed on startup as one named engine deployment per service (ServiceDeployments.java), so each service versions and rolls back independently. - The Look up vehicle in registry service task calls the backend's
registry server-side via the official
http-connector. The email service tasks reuse the same connector against Mailpit's/api/v1/sendJSON endpoint; the PDF tasks call a tiny Node sidecar (pdf-renderer/) that fronts Gotenberg, then store the result through the backend's/api/documents/server-upload. - The CIB seven webapps (Cockpit / Tasklist / Admin) live under
/camunda/*on the engine. A secondSecurityFilterChaindrives the Spring Security OAuth2 Authorization Code flow against thecib7-webappsKeycloak client and bridges the OIDC user into the engine'sIdentityServicevia the cibseven-keycloak plugin'sContainerBasedAuthenticationProviderrecipe. - Both Java modules run in-memory H2 — process state and document metadata are lost together when the containers stop.
The same deployment is also reachable as an MCP server at
/mcp, so an MCP-capable AI assistant (Claude Desktop, Cursor, Codex,
Windsurf, claude.ai web Custom Connectors, …) can drive the deployment
in natural language. The sidecar serving this is at mcp/ —
see docs/mcp.md for the full module guide.
One-time setup (per AI client).
Clients that speak the URL form natively (claude.ai web Custom
Connectors, Cursor with connect_url, etc.) just need
http://localhost:3000/mcp in their connector settings. Claude Desktop
on Windows currently doesn't — it rejects the {"url":...} config — so
go through the stdio bridge:
npm install -g mcp-remoteThen in %APPDATA%\Claude\claude_desktop_config.json:
{
"mcpServers": {
"cib7": {
"command": "node",
"args": [
"C:\\Users\\<you>\\git\\cib7-react-poc\\mcp\\cib7-bridge.mjs"
]
}
}
}Fully quit Claude Desktop (tray → Quit) and reopen. The first MCP call
pops a browser to Keycloak — log in as a seeded user (bart / bart
applicant, homer / homer civil-servant) or click "Register" to
create your own account (verification email lands at Mailpit). The
client now has a valid Bearer and remembers it for the session.
What you can do, end-to-end. Eleven MCP tools cover the applicant round trip, civil-servant review, and onboarding without anyone opening the React SPA:
Process tools (forward the caller's Bearer to /engine-rest):
| Tool | Purpose |
|---|---|
list_services |
"What can I do here?" — enumerates vehicleRegistration and businessRegistration. |
describe_service(key) |
Returns the per-service variable schema + LLM training markdown. |
start_process(key, variables) |
Validates variables with Ajv, starts a real BPMN instance. |
list_my_tasks |
Tasks waiting on the current user (assigned OR claimable via candidate group). |
get_form_schema(taskId) |
Schema for a specific task's form. |
complete_task(taskId, variables) |
Validates against the task schema, auto-claims if needed, completes. |
list_my_processes |
Newest-first list of instances started by the current user, with state. |
query_user_history(variableName) |
Most recent value the user has ever entered — the autofill primitive. |
Identity tools (Keycloak; never handle a password):
| Tool | Purpose |
|---|---|
get_signup_url |
Returns the hosted Keycloak sign-up URL + steps. Pure URL lookup. |
get_password_reset_url |
Returns the hosted Keycloak password-reset URL + steps. Pure URL lookup. |
send_account_invitation(username, email, firstName, lastName) |
Creates an invite-pending Keycloak user and emails them a magic link. The invitee sets their own password in Keycloak's form. |
A canonical session as bart:
"What services are available on the cib7 server?" →
list_services"Register a company called Acme — board members Alice Aaver 38501234567 and Bob Bork 49012345678, share capital €5000." →describe_service('businessRegistration')thenstart_process('businessRegistration', {...})— auto-approved by DMN because Bart is an adult and capital ≥ €2500. Approval email lands in Mailpit.
A canonical session as a new user:
"I'd like to register a new user — username lisa, email lisa@x.com, first name Lisa, last name Simpson." →
send_account_invitation. Invitee opens Mailpit, clicks the magic link, sets a password in Keycloak's hosted form, and lands in the SPA signed in. The MCP service never sees a password — Claude never asks for one.
For the Homer (civil-servant) side, log in as homer / homer in the
same OAuth pop — Claude's list_my_tasks then surfaces the review tasks
even though they're owned by the civil-servant candidate group (the
tool merges assigned + claimable, and complete_task auto-claims).
The MCP sidecar is a stateless Bearer-proxy — every tool call
forwards the AI client's Bearer token to /engine-rest, the engine
validates issuer + audience + signature, and authorization runs against
the same IdentityService the SPA uses. There's no separate user store,
no separate audit trail, no "AI service account." Everything an AI
agent does is attributable to a real Keycloak user.
For the full architecture story (why a sidecar instead of an in-engine
plugin like
krixerx/cibseven-mcp-plugin,
how OAuth2 PKCE-loopback works, how the per-service manifests get
generated from the spec), read docs/mcp.md.
cib7-react-poc/
├── docker-compose.yml
├── cib7/ CIB seven 2.2 Spring Boot engine module
│ │ (engine + plugins + connectors ONLY — no
│ │ business endpoints; those live in backend/)
│ ├── pom.xml
│ ├── Dockerfile
│ └── src/main/
│ ├── java/com/poc/cib7/
│ │ ├── Cib7PocApplication.java
│ │ ├── ConnectorConfiguration.java registers the Connect plugin
│ │ ├── BusConfiguration.java exposes ${busBaseUrl} (integration bus)
│ │ ├── FrontendConfiguration.java exposes ${frontendBaseUrl} (email links)
│ │ ├── PdfHelper.java @Component("pdf") base64↔byte[]
│ │ ├── AuthorizationBootstrap.java grants /applicant engine perms
│ │ └── keycloak/ Spring Security + Keycloak identity wiring
│ └── resources/
│ ├── application.yaml
│ ├── processes/<service>/ BPMN + DMN — one engine deployment per folder
│ └── templates/ FreeMarker payloads for connectors
├── backend/ Business microservice (Spring Boot 4)
│ │ Owns every /api/** surface; talks to the
│ │ engine only via /engine-rest using the
│ │ cib7-business Keycloak service account
│ ├── pom.xml
│ ├── Dockerfile
│ └── src/main/
│ ├── java/com/poc/backend/
│ │ ├── BackendApplication.java
│ │ ├── engine/ EngineClient (REST) + OAuth2 client-credentials wiring
│ │ ├── security/ public / internal-token / JWT chains
│ │ ├── storage/ S3 client + presigner + bucket bootstrap (RustFS)
│ │ ├── documents/ Document JPA entity + repository + /api/documents
│ │ ├── owner/ /api/public/owner-confirmations
│ │ ├── founder/ /api/public/founder-signatures
│ │ ├── payment/ /api/public/payments
│ │ └── vehicleregistry/ /api/public/vehicle-registry (Liiklusregister stand-in)
│ └── resources/application.yaml
├── pdf-renderer/ Node sidecar (JSON-in/JSON-out over Gotenberg)
│ ├── server.js ~25 LOC Express wrapper
│ ├── package.json
│ └── Dockerfile
├── mcp/ MCP sidecar — AI-callable surface (see docs/mcp.md)
│ ├── src/
│ │ ├── server.ts Express + per-request MCP server/transport + 11 tools + LLM instructions
│ │ ├── auth/identity.ts parses preferred_username for query construction
│ │ ├── auth/verify.ts jose jwtVerify against Keycloak JWKS at the /mcp door
│ │ ├── engine/client.ts Bearer-forward fetch wrapper to /engine-rest
│ │ ├── engine/variables.ts plain JSON → Camunda { value, type } envelope
│ │ ├── keycloak/admin.ts cib7-backend service-account token + admin REST wrapper
│ │ └── services/manifest.ts walks /app/services-spec, Ajv-compiles schemas
│ ├── cib7-bridge.mjs stdio↔HTTP launcher for Claude Desktop on Windows
│ ├── package.json @modelcontextprotocol/sdk, express, ajv, jose, tsx
│ ├── tsconfig.json
│ ├── Dockerfile node:20-alpine; build context is repo root
│ └── README.md quick-start + verify steps + troubleshooting
└── frontend/ React + TypeScript + Vite app
└── src/
├── api/
│ ├── camundaClient.ts typed /engine-rest client
│ ├── bpmn.ts BPMN XML parsing (tasks, names, flow graph)
│ ├── documentsApi.ts /api/documents client (uploads, downloads)
│ └── *Api.ts payments / confirmations / vehicle registry
├── pages/ role-aware pages
│ ├── ServicesPage.tsx PartA — start a service
│ ├── MyProcessesPage.tsx PartA — applicant's instances + status
│ ├── TasksPage.tsx PartB — back-office task tree
│ ├── IncidentsPage.tsx PartB — open incidents + retry
│ ├── TaskDetailPage.tsx shared task form host
│ └── CompletedProcessPage.tsx shared finished-process view
└── forms/ formKey → React component
Just deploying, not developing? You don't need this repository or a build at all — pre-built images are on Docker Hub. See
deploy/README.mdfor the pull-only administrator guide (single-machine and TLS setups).
Requires Docker with Compose.
docker compose up --buildAll URLs and credentials are listed in the Test logins & consoles table above.
When the SPA loads it redirects to Keycloak's login form. Use bart / bart
to play the applicant or homer / homer to play the back-office reviewer.
Every /engine-rest call carries the JWT and the engine enforces
candidateGroups / assignee against the user's realm roles + group
membership.
As Bart (PartA): start a service on the Services page, fill the applicant form, and watch the row appear under My processes with a live status. If the back office sends the case back, reopen the row to see the reason banner and resubmit.
As Homer (PartB): the Tasks page groups every service's user tasks with the active instances waiting at each step. Open a review task, then Accept (process ends) or Send back with a reason (loops to the applicant).
Requires Java 17+ and Node.js 20+.
Engine (terminal 1):
cd cib7
mvn spring-boot:runBusiness backend (terminal 2):
cd backend
mvn spring-boot:runFrontend (terminal 3):
cd frontend
npm install
npm run devThen open http://localhost:5173. The Vite dev server proxies /engine-rest
to the engine on port 8080 and /api to the backend on port 8085.
Services are spec-first. The analyst owns the markdown; the code is generated from it. No one hand-edits BPMN or registers a form by hand.
1) Analyst writes spec 2) Service builder generates 3) Test 4) Commit
docs/business/services/ ─▶ cib7/.../processes/*.bpmn ─▶ docker ─▶ git
<service>/ cib7/.../processes/*.dmn compose add + commit
README.md cib7/.../templates/*.ftl up --build a single
forms/*.md frontend/src/forms/<id>/ atomic
service-tasks/*.md frontend/src/forms/registry.ts change
decisions/*.md (DMN) docs/.../README.md ▶ mermaid
One folder per service under docs/business/services/<service>/.
Two starting points:
- Blank skeleton —
.claude/skills/service-builder/spec-template/has emptyREADME.md,forms/example-form.md,service-tasks/example-task.md, anddecisions/example-decision.mdwith placeholder fields and inline documentation on every section. - Worked example —
vehicle-registration/is the canonical filled-in spec. Read it side-by-side with the templates to see what good looks like.
The folder is the single source of truth; if a fact isn't in the spec, the builder won't emit code for it.
What the spec must cover:
| File | Defines | Becomes |
|---|---|---|
README.md |
Flow narrative, mermaid diagram, role/authorization matrix, process variables, known trade-offs | BPMN skeleton; the mermaid block is rewritten from the generated BPMN by scripts/bpmn-to-mermaid.mjs |
forms/<form-id>.md |
One file per user task: form id, audience, fields (name / type / required / validation), submit variables, send-back behaviour | One React component per form + a registry.ts entry; one <bpmn:userTask camunda:formKey="react:<form-id>"> per file |
service-tasks/<task-id>.md |
One file per integration: HTTP method + URL, headers, payload template, response mapping, async semantics | One <bpmn:serviceTask> with inline http-connector config; FreeMarker payload under cib7/src/main/resources/templates/ if non-trivial |
decisions/<decision-id>.md (optional) |
DMN inputs, outputs, hit policy, rules table | One .dmn file under cib7/src/main/resources/processes/; one <bpmn:businessRuleTask camunda:decisionRef="..."> |
Conventions the builder relies on:
- Form ids and task ids are kebab-case and globally unique (the builder refuses duplicates).
- Process variable names are spelled exactly the same in
README.md, every form spec, every service-task spec, and every decision spec —firstName, notfirst_namein one andfirstnamein another. - Roles use slash-less Keycloak group ids (
applicant, not/applicant) incandidateGroups; see the project memory on cibseven-keycloak group-path stripping. - Large variables (PDFs, images, anything > 4 kB) are declared as
byte[]in the variables table so the engine spills them toACT_GE_BYTEARRAY— seedocs/cib7.md§ Large process variables. - DMN files must declare
historyTimeToLive(CIB seven 2.2 hard rule).
Run /service-builder on the
service folder. It reads every markdown file, validates them against the
conventions above, and writes:
cib7/src/main/resources/processes/<service>.bpmncib7/src/main/resources/processes/<decision>.dmn(if any)cib7/src/main/resources/templates/<task>.json.ftl(if any)frontend/src/forms/<form-id>/(one component performs/*.md)frontend/src/forms/registry.ts— entries added / removed in placedocs/business/services/<service>/README.md— the mermaid block is regenerated byscripts/bpmn-to-mermaid.mjs
Modifications work the same way — edit the markdown, re-run the builder, and the existing code is rewritten in place. Never hand-edit generated files; the next builder run will overwrite the change.
For a new service that needs a new top-level navigation entry in PartA,
the builder also drops a row into the Services page; for back-office tasks
it threads them into the Tasks tree via the standard formKey lookup, so
no extra wiring is needed.
docker compose up --buildThe engine redeploys the BPMN / DMN on startup —
ServiceDeployments.java
creates one named deployment per processes/<service>/ folder, with
duplicate filtering so unchanged services don't re-version. Walk the
happy path and at least one edge case through the SPA:
- PartA — start the service as
bart, fill each user form, watch the row in My processes advance through each step. - PartB — pick up the task as
homer, exercise every gateway branch (approve, send-back, timer-driven side effects, …). - Check Mailpit at http://localhost:8025 for any notification
emails the flow emits (requires
docker compose --profile dev up -d mailpit-uionce per session — the default profile keeps the inbox network-internal). - Check Cockpit at http://localhost:3000/camunda/app/cockpit/ for incidents; an incident means the engine hit something the spec didn't cover — fix the spec, re-run the builder, redeploy.
The frontend mounts the form via the registry, so an unknown
formKeyshows up as a clear runtime error in the task page. Cockpit shows connector / DMN / FreeMarker failures as engine incidents.
If the change is frontend-only, npm run dev (Vite, terminal 2) gives a
faster loop — see Run locally.
Commit the spec and the generated files in a single atomic change so the repo always builds:
docs/business/services/<service>/... (the source of truth)
cib7/src/main/resources/processes/... (generated)
cib7/src/main/resources/templates/... (generated, if any)
frontend/src/forms/... (generated)
frontend/src/forms/registry.ts (generated)
A commit message of the form <service>: <what changed in the spec>
keeps git log readable from the analyst's perspective.
The SPA is built on TEDI (@tedi-design-system/react
v18 — TEHIK's open-source design system, WCAG 2.2 AA) as the base component
library, complemented by MUI for the complex data components TEDI doesn't
cover. This mirrors the common public-sector tender requirement "TEDI as the
base, MUI for data grids / calendars / wizards".
- Package choice.
@tedi-design-system/reactis the current package — the older@tehik-ee/tedi-react(13.x) it superseded is what tender texts usually still name. MUI is pinned to v5 (@mui/material,@mui/x-data-grid) deliberately: TEDI bundles MUI v5 internally, so matching it keeps a single MUI/Emotion tree instead of two. - Wiring (
frontend/src/main.tsx): TEDI's stylesheet is imported beforestyles.css(portal overrides win), and the tree is wrapped in TEDI'sStyleProvider+LabelProviderand an MUIThemeProvider(frontend/src/theme/mui.tsmaps MUI's palette to the portal brand). TEDI ships its fonts (Roboto, Material Symbols) inside the package — no CDN calls, works offline. - Reference implementations.
TransportPermitApplicationFormis the TEDI reference form (TextField,NumberField,Select,Checkbox,Alert,Button— validation and the typed-variable payload unchanged), the template for converting the remaining forms.IncidentsPageis the MUI reference: a sortable, paginatedDataGridwith a TEDI Button rendered inside the actions cell — the "TEDI base + MUI for complex tables" composition in one screen. - Locale caveat. TEDI's internal labels exist only in Estonian, English,
and Russian, so
LabelProvideris pinned toen— under the Arabic UI the app's own i18n switches normally while TEDI-internal microcopy stays English.
Each BPMN user task carries a camunda:formKey:
<bpmn:userTask id="Task_SubmitDetails" name="Submit personal details"
camunda:formKey="react:owner-vehicle" />The React app reads the task's formKey from the REST API, strips the
react: prefix, and looks the form id up in src/forms/registry.ts:
export const formRegistry = {
'owner-vehicle': OwnerVehicleForm,
'vehicle-review': VehicleReviewForm,
// + business-details, review-business-registration
};To add a form: add a user task with a new camunda:formKey in the BPMN,
create the component under src/forms/, and add one registry entry.
The Look up vehicle in registry service task uses the official
cibseven-connect-http-client
connector — a CIB seven Connect SPI connector that wraps Apache HttpClient 5.
It is wired in two places:
-
Connect plugin —
ConnectorConfigurationregistersConnectProcessEnginePluginso the engine parses<camunda:connector>. Thecibseven-connect-http-clientdependency declared incib7/pom.xmlregisters the connector itself through the Connect SPI. -
BPMN — the service task carries the connector config inline. The response body comes back as the
responsevariable; Spin (bundled with the CIB seven engine) parses it inline — with per-property fallbacks so a malformed response degrades to "review" instead of crashing:<camunda:connector> <camunda:connectorId>http-connector</camunda:connectorId> <camunda:inputOutput> <camunda:inputParameter name="url">${busBaseUrl}/api/public/vehicle-registry/vehicles/${objectId}</camunda:inputParameter> <camunda:inputParameter name="method">GET</camunda:inputParameter> <camunda:inputParameter name="headers"> <camunda:map> <camunda:entry key="Accept">application/json</camunda:entry> </camunda:map> </camunda:inputParameter> <camunda:outputParameter name="price">${!S(response).hasProp('value') ? 9999 : S(response).prop('value').numberValue()}</camunda:outputParameter> </camunda:inputOutput> </camunda:connector>
The service task runs asyncBefore, so after the first form is confirmed the
job executor runs the connector — the Auto approval? DMN task runs next,
and depending on the outcome either the process ends or the Review
application task appears a moment later (use the Tasks page Refresh
button).
cib7/src/main/resources/processes/vehicle-registration/vehicle-auto-approval.dmn
is deployed alongside the BPMN. It has two inputs — age (Integer) and
price (Double) — and a single string output autoDecision. Hit policy is
FIRST: minors always go to review, adults with cheap picks auto-approve,
everything else goes to review.
The Business Rule Task references it inline, bound to the decision version that shipped in the same service deployment:
<bpmn:businessRuleTask id="Task_AutoDecide" name="Auto approval?"
camunda:decisionRef="vehicle-auto-approval"
camunda:decisionRefBinding="deployment"
camunda:mapDecisionResult="singleEntry"
camunda:resultVariable="autoDecision" />Each service's BPMN + DMN files live under
cib7/src/main/resources/processes/<service>/ and are deployed as one
named engine deployment per service by
ServiceDeployments.java
(the starter's single-bundle auto-deploy is off — camunda.bpm.auto-deployment-enabled: false
in application.yaml). Editing
one service re-versions only that service; the others are duplicate-filtered
no-ops, and each service can be rolled back or deleted in Cockpit
independently.
The Review application user task carries a non-interrupting timer
boundary event (R/PT2M). Every two minutes while the task is open the
engine job executor fires a parallel branch into a Send reminder email
service task — the user task itself stays open and can fire again. The
service task is just the http-connector POSTing to the integration bus,
which forwards to Mailpit's /api/v1/send JSON endpoint:
<camunda:inputParameter name="url">${busBaseUrl}/api/v1/send</camunda:inputParameter>
<camunda:inputParameter name="method">POST</camunda:inputParameter>
<camunda:inputParameter name="payload">{
"From": { "Email": "process@cib7-poc.local", "Name": "CIB7 POC" },
"To": [ { "Email": "civil-servant@cib7-poc.local" } ],
"Subject": "Reminder: application waiting for review",
"Text": "An application from ${firstName} ${lastName} has been waiting…"
}</camunda:inputParameter>The same connector is reused on the send-back path to email the applicant
(${initiator}@cib7-poc.local) the rejection reason before looping back. The
${busBaseUrl} variable is exposed by
BusConfiguration.java
as a Spring bean, driven by the BUS_URL env var (http://esb:8080 in
Docker). It points at the integration bus (esb, Apache Camel), which routes
/api/v1/send to Mailpit — the engine never addresses Mailpit directly. See
the Integration bus note.
Mailpit (axllent/mailpit:latest) is a tiny
SMTP server + web UI; the inbox at http://localhost:8025 visualizes every
email the process sends. The default docker compose up keeps Mailpit
network-internal — bring the inbox online with docker compose --profile dev up -d mailpit-ui, which spins up a socat sidecar that publishes
:8025 to the host on demand.
When the case ends in approval and the applicant provided an email, a
Generate approval PDF service task runs before the approval email. It is
yet another http-connector call — this time to the bus at
${busBaseUrl}/render, which routes to the pdf-renderer/ sidecar; it takes
JSON {html, filename}
and returns JSON {filename, base64}. The sidecar internally POSTs
multipart/form-data to Gotenberg (gotenberg/gotenberg:8, headless
Chromium) and base64-encodes the binary response — both warts that would
otherwise force the BPMN out of the connector pattern into a custom Java
delegate.
The decoded PDF lands in the approvalPdfBytes process variable as a
byte[] (not String), so the engine spills it into ACT_GE_BYTEARRAY
instead of the 4000-char ACT_HI_VARINST.TEXT_ column. The
approval-email.json.ftl template re-encodes to base64 with
${pdf.encode(approvalPdfBytes)} when assembling the Mailpit attachment.
See docs/cib7.md § Large process variables
for the rationale and the corresponding PdfHelper bean.
The cibseven-bpm-spring-boot-starter-webapp dependency mounts the classic
CIB seven webapps at /camunda/**. A second Spring Security filter chain
(com.poc.cib7.keycloak.webapp.WebappSecurityConfig) drives an OAuth2
Authorization Code flow against the cib7-webapps Keycloak client; once the
user is logged in, ContainerBasedAuthenticationFilter calls
KeycloakAuthenticationProvider.extractAuthenticatedUser, which reads the
OIDC user and queries groups via the cibseven-keycloak plugin's read-only
IdentityService — the same identity model the /engine-rest Bearer-JWT
filter uses. The three Java files under com/poc/cib7/keycloak/webapp/ are
the plugin's published recipe (examples/sso-kubernetes), repackaged.
URL split — internal vs browser-visible — is handled in
application.yaml by listing
every OAuth2 endpoint explicitly (no issuer-uri, which would trigger OIDC
discovery against an URL the engine container can't reach):
spring.security.oauth2.client.provider.keycloak:
authorization-uri: http://localhost:8180/... # browser
token-uri: http://keycloak:8080/... # backend
jwk-set-uri: http://keycloak:8080/... # backend
user-info-uri: http://keycloak:8080/... # backendLog in at http://localhost:3000/camunda as admin / admin for the
Cockpit / Tasklist / Admin webapps (the /cib7-admin group is the one
authorized for those webapps; homer and bart are intentionally
locked out).
All under /engine-rest (standard CIB seven / Camunda 7 REST API):
| Call | Purpose |
|---|---|
GET /process-definition?latestVersion=true |
List services (process definitions) |
GET /process-definition/key/{key}/xml |
BPMN XML — read the model's user tasks |
POST /process-definition/key/{key}/start |
Start a process instance |
GET /task |
List open tasks |
GET /task?processInstanceId={id} |
Open tasks of one instance |
GET /task/{id} |
Task details, including formKey |
GET /task/{id}/form-variables |
Process variables for the form |
POST /task/{id}/complete |
Complete the task with typed variables |
This POC intentionally simplifies docs/human-role-react-forms-spec.md:
| Spec | This POC | Why |
|---|---|---|
cib: BPMN namespace (§5.3) |
Standard camunda: namespace |
CIB seven 2.2 uses camunda: — confirmed against the official cibseven-get-started-spring-boot example. The spec's §5.3 is inaccurate. |
| BFF between React and engine (D11) | React calls /engine-rest directly with a Bearer JWT |
Bearer auth + the resource-server filter chain in front of the engine is the production-acceptable middle ground until a BFF is added. |
| Form manifest + publish-time validation (§11) | Omitted | The BPMN is a single static file, not dynamically generated. |
Single json Spin variable (§10) |
Plain typed variables (firstName, objectId, price, decision, …) |
Simpler; no Spin needed for a POC. |
| Separate edit/view form components (§8.2–8.3) | One component per form | The "entry then review" flow already gives one edit form and one read-only review form. |
| IdP groups → candidate groups | Keycloak groups /applicant (assignee via ${initiator}) and /civil-servant (candidateGroup civil-servant — slash stripped by the plugin) |
Implemented via cibseven-keycloak 2.1.0 with useGroupPathAsCamundaGroupId: true. The plugin maps path /civil-servant to engine group id civil-servant; see docs/cib7.md. |
For a production system the spec's BFF and manifest validation would be reinstated.
- In-memory H2 means process state is lost on backend restart.
- Keycloak runs in
start-devmode with its own in-memory H2 — the realm is re-imported fromkeycloak/realm-export.jsonon every container start, so user-created users/groups are also lost on restart. - The vehicle catalog is a hard-coded ten-entry stand-in served by the
backend (
/api/public/vehicle-registry) — no real Liiklusregister behind it. - The DMN's
PT2Mtimer cycle is a demo value — switch toPT8H/PT1Dfor anything real, otherwise Mailpit fills up fast. - Mailpit's storage is non-persistent (no volume mounted); restarting the container empties the inbox.