ValidSign phase-approval signing¶
A project leader signs a RIP phase-exit approval without leaving the Infra-board. The signed PDF and its evidence summary are archived into the project's eDOCS workspace, and the Operaton user task completes only once the signature has landed.
The feature is opt-in from the process model: it activates on a user task that carries ronl:signatureRef, and returns nothing for a task without it โ which is every ordinary task.
Verified against source
Read on main at 10bcf8b, 20 September 2026 (v2026.09.9). The feature
itself arrived in v2026.08.36; the callback's authentication was corrected
against a live platform in v2026.09.8.
Acceptance signs for real
Acceptance has been live since 14 September 2026 โ a real ceremony signed, callbacks accepted, and the evidence email received. The account below of what the callback accepts is written from that signing, not from the specification.
How it activates¶
The Linked Data Explorer's R2.1 bundle sets one attribute on the task that closes the phase:
<bpmn:userTask id="Task_AccorderenProjectplan4"
name="Accorderen Projectplan 4. Uitgangspunten VO-fase"
ronl:signatureRef="rip-pdp">
The backend resolves that attribute from a named BPMN user task to the document template deployed alongside the process. Where it is present, the Infra-board renders the signing panel in place of the task's ordinary form.
That is the whole switch. A single attribute in a model the LDE deploys turns on a feature implemented entirely here.
The three locks on live signing¶
The ValidSign licence is production-only โ there is no sandbox tenant, and the API key is account-wide. A misconfigured environment must therefore not be able to fire a real signature request. assertLiveAllowed() checks three conditions before anything reaches the network:
1. stub mode is off VALIDSIGN_STUB_MODE=false
2. an API key is present VALIDSIGN_API_KEY
3. the tier is allowlisted DEPLOYMENT_ENV โ VALIDSIGN_LIVE_TIERS
VALIDSIGN_LIVE_TIERS is empty by default
No tier may create real packages until one is explicitly named. This is an
allowlist, not a hardcoded exclusion of acceptance โ adding acceptance
makes ACC sign for real exactly as production does. That is a deliberate
choice: the environment that signs is a configuration decision, not
something the code decides on your behalf.
A blocked attempt throws VALIDSIGN_LIVE_BLOCKED naming the offending
DEPLOYMENT_ENV; a live-mode start with no key throws
VALIDSIGN_LIVE_MISCONFIGURED.
The five routes, across two routers¶
| Route | Router | Auth |
|---|---|---|
GET /v1/validsign/task/:taskId/spec |
main | JWT |
POST /v1/validsign/task/:taskId/package |
main | JWT |
GET /v1/validsign/task/:taskId/status |
main | JWT |
POST /v1/validsign/callback |
pre-auth | shared key, in any of five forms |
GET+POST /v1/validsign/stub/ceremony/:packageId |
pre-auth | capability URL (stub only) |
Two of the five sit outside JWT middleware, and neither is an oversight:
- The callback is posted by ValidSign's cloud, which holds no Keycloak token. It is verified against a shared key instead โ see What the callback accepts.
- The stub ceremony loads in an iframe, and an iframe cannot carry a bearer token.
The ceremony URL is a capability¶
Stub package ids were originally sequential. On any internet-reachable deployment, someone could enumerate a few values and approve a phase-exit that another person was in the middle of signing. They are now random UUIDs, which makes the URL itself the credential.
Do not run stub mode on acceptance from any commit before this fix
The change landed in v2026.08.36. Earlier commits carry the guessable ids.
Both pre-auth routes share a rate limiter โ 60 requests per minute โ keyed on the client IP, not the secret header. The header is attacker-controlled, so keying on it would hand out a fresh budget per request. Keying on IP also means ValidSign's callbacks and a browser's ceremony traffic never land in the same bucket, so ceremony traffic cannot exhaust the budget the callbacks rely on.
Completion: one path, two racing callers¶
A signature completes through a single idempotent path, reached from either direction:
ValidSign completes โโฌโโบ POST /callback โโ
โ โโโบ archive signed PDF + evidence
poller sweep โโ โ to eDOCS โ complete the
โโโบ Operaton user task
The poller is not belt-and-braces. ValidSign's cloud cannot reach a developer's localhost, so during local work the callback never arrives at all. It sweeps process instances awaiting a signature on VALIDSIGN_POLL_INTERVAL_MS (default 15s) and drives completion through the same path the webhook uses.
What the callback accepts¶
The route is registered with ValidSign under security type Bearer token.
What ValidSign actually sends is Authorization: Basic <key>, with the key raw
rather than base64-encoded. Both are accepted, along with three further forms,
all compared in constant time against VALIDSIGN_CALLBACK_SECRET:
| Credential | Logged as |
|---|---|
Authorization: Basic <key> |
basic-raw โ what ValidSign sends |
Authorization: Basic base64(<key>) |
basic-base64 |
Authorization: Basic base64(name:key) |
basic-base64-pair |
Authorization: Bearer <key> |
bearer |
x-validsign-secret: <key> |
x-validsign-secret |
The scheme is matched case-insensitively. A value that merely contains the
key, a wrong key in any form, and any other scheme such as Digest are all
still 401.
"ValidSign callback received" records which form matched, so the check can
be narrowed to the one ValidSign genuinely uses once that has been observed long
enough. A rejection logs which credential forms arrived and never their values:
header names and the Authorization scheme only, with a scheme-less value
logged as (no scheme) because it could be the key itself. An accepted callback
always answers 200, including for an event it does nothing with.
This was found because a rejection says enough to diagnose it
Until v2026.09.8 the route read only x-validsign-secret, so every real
callback was a 401 โ invisibly, because the poller completes the
signature anyway and the process looks healthy. The first live acceptance
signing rejected all six callbacks for package a2beacfa and logged
authorization:Basic; the poller finished seven seconds later. That one log
line is what identified the scheme, and it is why the log names the form
rather than the value.
The tell, if this ever regresses: a completion with no matching callback log line means the webhook never landed and the poller did the work. Nothing is lost when a callback fails โ signing still works, about fifteen seconds slower.
The signer's identity comes from the token¶
Package creation takes the signer's name and email entirely from the caller's Keycloak token. A real token carried no email claim and no name claim at all, so creation would have refused for every user โ working exactly as designed, and useless.
Three protocol mappers are required on the client:
| Mapper | Claim |
|---|---|
email |
email |
given_name |
given_name |
family_name |
family_name |
scripts/keycloak-add-token-claim-mappers.sh adds them. It talks only to the Admin REST protocol-mappers endpoint โ redirect URIs, web origins and the client secret appear in no request it builds โ and is idempotent, so re-running reads state rather than changing it.
Why not a realm import
A partial realm import cannot do this safely: SKIP policy skips an
existing client entirely, and OVERWRITE replaces the whole client
definition. The script was untracked until v2026.08.36 โ a file that changes
shared infrastructure for every environment is a worse candidate for
privacy, not a better one.
Documents: one representation, two renderings¶
A template deployed alongside the BPMN, plus the instance's process variables, is turned into a small intermediate representation. Markdown and PDF are emitted from it separately, so the archived human-readable copy and the signed artifact cannot drift apart.
pdfkit was chosen over pdf-lib because this generates a document from scratch and needs real text wrapping; pdf-lib is built for editing existing PDFs.
rip-pdp renders from its deployed template rather than the hardcoded switch the worker previously used โ that switch restated, as TypeScript string literals, content the deployed templates already define. Only rip-pdp migrated: it is the document the signature touches, and converting the other two would have altered documents the feature does not need to alter.
The seal used to land on the body text
ValidSign places fields in 96-DPI pixels while the PDF is authored in 72-DPI points, so every coordinate and size arrived at three-quarter scale and the seal covered the body instead of the signature block.
What live testing changed¶
Six claims in the design proved wrong once the feature ran against production ValidSign, live eDOCS and a real browser. They are worth recording, because each is the kind of thing a stub cannot surface:
| Symptom | Cause |
|---|---|
| The signing iframe showed the app's own landing page | In stub mode the backend returns a relative path, which the browser resolved against the board's origin rather than the API's |
| The browser refused to render the ceremony at all | helmet's global defaults set X-Frame-Options and a frame-ancestors policy, and the board is a different origin from the API |
| A successful signature looked like a stalled one | The panel polled status but could never observe completion |
| The first real signature archived nothing | Status came back failed and the signed PDF never reached eDOCS โ two separate defects |
| Archived documents would not open | The stub emitted 27-byte strings beginning with a PDF header; they uploaded perfectly and were unopenable |
| A second signature request could reach a real inbox | Package creation had no guard, so a second call put a second request in a person's inbox โ which cannot be recalled โ and left the process holding one of them |
A live signature request costs something against the licence, so the end-to-end guard now refuses before a package is requested, not after. It previously checked the ceremony URL, which only exists once the package has been created โ stopping the signature but not the request.
Testing it without filling twelve forms¶
Reaching the signature meant working an R2.1 instance through eleven user tasks by hand to get to the twelfth. A script now drives the process to the approval task in seconds, through the backend's own start endpoint rather than around it.
The panel's own tests mock the API module wholesale, so the three signing methods in the frontend client โ and every one of their catch blocks โ ran in no test at all. Those catch blocks are what turn a transport failure into something the panel can show a person, which makes the gap worse than the percentage suggested. They are covered directly now.
See Testing for the measured suite figures.
Configuration¶
| Variable | Default | Purpose |
|---|---|---|
VALIDSIGN_BASE_URL |
https://my.validsign.eu/api |
Platform endpoint |
VALIDSIGN_API_KEY |
(empty) | Account-wide; required in live mode |
VALIDSIGN_SENDER_EMAIL |
(empty) | Package sender |
VALIDSIGN_STUB_MODE |
true |
Stub unless explicitly disabled |
VALIDSIGN_CALLBACK_SECRET |
(empty) | Verifies the webhook. Must equal the callback key registered with ValidSign, and is matched against all five credential forms above |
VALIDSIGN_LIVE_TIERS |
(empty) | Allowlist of tiers permitted to sign for real |
VALIDSIGN_POLL_INTERVAL_MS |
15000 |
Poller sweep interval |
Related¶
- RIP R2.1 Bundle โ where
ronl:signatureRefis set - Infra-board โ the board the panel appears on
- Security & Compliance โ the wider posture
- Testing โ measured suite figures