Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
- 📋 Pas bedrijfsregels toe op endpoint-verkeer en houd een audit trail per object bij

]]></description>
<version>0.4.7-unstable.20260915120000</version>
<version>0.4.7-unstable.20260918150001</version>
<licence>EUPL-1.2</licence>
<author mail="info@conduction.nl" homepage="https://www.conduction.nl/">Conduction</author>
<namespace>Integriq</namespace>
Expand Down Expand Up @@ -104,6 +104,12 @@
posts that to OpenRegister. Nothing about the changed record is
written here. -->
<job>OCA\Integriq\BackgroundJob\RegistrySubscriptionPollJob</job>
<!-- berichtenbox-digital-post-adapter: the status job asks the providers
what became of the letters they took, every ten minutes, and only
about letters still on their way. The inbound job offers what
arrived to the document intake inbox. -->
<job>OCA\Integriq\BackgroundJob\DigitalPostStatusJob</job>
<job>OCA\Integriq\BackgroundJob\DigitalPostInboundJob</job>
</background-jobs>

<!-- ⚠️ CHILD ORDER IS FIXED BY THE APP STORE SCHEMA, and it is NOT the order
Expand Down
12 changes: 12 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@
// rule decides what opens a case and a reply leaves the building.
['name' => 'intakeChannels#inbound', 'url' => '/api/intake/channels/{channel}/inbound', 'verb' => 'POST', 'requirements' => ['channel' => '[a-z0-9\\-]+']],
['name' => 'intakeChannels#channels', 'url' => '/api/intake/channels', 'verb' => 'GET'],
// What digital post bindings this instance has, so the source form's
// provider picker is built from the registry rather than from a list
// written beside it and left to go stale.
['name' => 'digitalPostProviders#providers', 'url' => '/api/digital-post/providers', 'verb' => 'GET'],
['name' => 'intakeChannels#saveRule', 'url' => '/api/intake/routing-rules', 'verb' => 'POST'],
['name' => 'intakeChannels#saveRule', 'url' => '/api/intake/routing-rules/{id}', 'verb' => 'PUT', 'postfix' => 'update'],
['name' => 'intakeChannels#reply', 'url' => '/api/intake/messages/{id}/reply', 'verb' => 'POST'],
Expand Down Expand Up @@ -565,6 +569,14 @@
['name' => 'settings#rebase', 'url' => '/api/settings/rebase', 'verb' => 'POST'],

// ADR-023 action-authorization matrix (admin-only via #[AuthorizedAdminSetting])
// The environment allowlist: what an expression may read out of the
// process, and who said so. Administrator only, enforced by the
// #[AuthorizedAdminSetting] attribute AND again in each method body —
// this list is a code-execution-adjacent surface, so the guard must not
// live only in an attribute a hand-written route could miss.
['name' => 'expressionSource#index', 'url' => '/api/admin/expression-sources', 'verb' => 'GET'],
['name' => 'expressionSource#add', 'url' => '/api/admin/expression-sources/env', 'verb' => 'POST'],
['name' => 'expressionSource#remove', 'url' => '/api/admin/expression-sources/env/{key}', 'verb' => 'DELETE', 'requirements' => ['key' => '[^/]+']],
['name' => 'actionMatrix#getMatrix', 'url' => '/api/admin/action-matrix', 'verb' => 'GET'],
['name' => 'actionMatrix#setMatrix', 'url' => '/api/admin/action-matrix', 'verb' => 'PUT'],

Expand Down
30 changes: 30 additions & 0 deletions docs/sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,33 @@ This results in the following example for the `configuration.authentication` obj
"payload": "{\"iss\":\"my_zgw_client\",\"iat\":{{ 'now'|date('U') }},\"client_id\":\"my_zgw_client\",\"user_id\":\"my_zgw_client\",\"user_representation\":\"me@company.com\",\"aud\":\"my_zgw_client\"}"
}
```

## Digital post on a source

A source of type `digitalPost` sends letters. Which service it sends them
through is one field: `configuration.provider`.

Pick it on the source form. The picker lists what this instance actually
carries, because it reads `GET /apps/integriq/api/digital-post/providers`
rather than a list written beside it. A binding added to the registry shows up
without anybody editing the form, and a binding that is gone stops being
offered.

Three bindings ship today:

* `log` writes the letter to the log and delivers nothing. Use it to try a flow
without posting anything to a citizen.
* `berichtenbox` posts to the recipient's Berichtenbox through Logius. It needs
an OIN and a certificate reference, and refuses to activate without both,
naming the one that is missing.
* `postex` posts through Postex, over the shared gateway transport.

Each binding describes the settings it needs through its own config schema, so
the fields under the picker change with your choice.

If the picker says no binding is installed, none is: the instance carries no
digital post provider and a letter cannot be sent from it. Install one, or
point the source at an instance that has one.

Set `configuration.provider` to `log` first and send one letter. The log line
tells you the source is wired before any credential is involved.
6 changes: 5 additions & 1 deletion l10n/nl.js
Original file line number Diff line number Diff line change
Expand Up @@ -1151,7 +1151,11 @@ OC.L10N.register(
"Shown while the connection is switched off. Default: Switched off in the app's settings.": "Getoond zolang de koppeling uitgeschakeld is. Standaard: Switched off in the app's settings.",
"App-config key that holds the JSON object.": "App-configuratiesleutel die het JSON-object bevat.",
"Dot path to the value, such as enabled or sync.enabled.": "Pad met punten naar de waarde, zoals enabled of sync.enabled.",
"Settings of the declaring app that must all be filled. Each entry is an app-config key, always read as the whole key even when it contains dots, or {configKey, jsonPath} to read one value inside a JSON setting. An empty string, false, 0, null, an empty JSON list or object, or a missing path reads as not filled.": "Instellingen van de declarerende app die allemaal gevuld moeten zijn. Elk item is een app-configuratiesleutel, altijd gelezen als de hele sleutel, ook met punten erin, of {configKey, jsonPath} om één waarde in een JSON-instelling te lezen. Een lege tekst, false, 0, null, een lege JSON-lijst of een leeg JSON-object, of een ontbrekend pad telt als niet gevuld."
"Settings of the declaring app that must all be filled. Each entry is an app-config key, always read as the whole key even when it contains dots, or {configKey, jsonPath} to read one value inside a JSON setting. An empty string, false, 0, null, an empty JSON list or object, or a missing path reads as not filled.": "Instellingen van de declarerende app die allemaal gevuld moeten zijn. Elk item is een app-configuratiesleutel, altijd gelezen als de hele sleutel, ook met punten erin, of {configKey, jsonPath} om één waarde in een JSON-instelling te lezen. Een lege tekst, false, 0, null, een lege JSON-lijst of een leeg JSON-object, of een ontbrekend pad telt als niet gevuld.",
"Digital post binding": "Digitale-postkoppeling",
"Select a digital post binding": "Kies een digitale-postkoppeling",
"Which service this source posts letters through. Each binding asks for its own settings once you pick it.": "Via welke dienst deze bron brieven verstuurt. Elke koppeling vraagt om haar eigen instellingen zodra je die kiest.",
"This instance has no digital post binding installed, so a letter cannot be sent from here yet.": "Op deze omgeving is geen digitale-postkoppeling geïnstalleerd, dus vanaf hier kan nog geen brief worden verstuurd."
},
"nplurals=2; plural=(n != 1);"
)
6 changes: 5 additions & 1 deletion l10n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1150,7 +1150,11 @@
"Shown while the connection is switched off. Default: Switched off in the app's settings.": "Getoond zolang de koppeling uitgeschakeld is. Standaard: Switched off in the app's settings.",
"App-config key that holds the JSON object.": "App-configuratiesleutel die het JSON-object bevat.",
"Dot path to the value, such as enabled or sync.enabled.": "Pad met punten naar de waarde, zoals enabled of sync.enabled.",
"Settings of the declaring app that must all be filled. Each entry is an app-config key, always read as the whole key even when it contains dots, or {configKey, jsonPath} to read one value inside a JSON setting. An empty string, false, 0, null, an empty JSON list or object, or a missing path reads as not filled.": "Instellingen van de declarerende app die allemaal gevuld moeten zijn. Elk item is een app-configuratiesleutel, altijd gelezen als de hele sleutel, ook met punten erin, of {configKey, jsonPath} om één waarde in een JSON-instelling te lezen. Een lege tekst, false, 0, null, een lege JSON-lijst of een leeg JSON-object, of een ontbrekend pad telt als niet gevuld."
"Settings of the declaring app that must all be filled. Each entry is an app-config key, always read as the whole key even when it contains dots, or {configKey, jsonPath} to read one value inside a JSON setting. An empty string, false, 0, null, an empty JSON list or object, or a missing path reads as not filled.": "Instellingen van de declarerende app die allemaal gevuld moeten zijn. Elk item is een app-configuratiesleutel, altijd gelezen als de hele sleutel, ook met punten erin, of {configKey, jsonPath} om één waarde in een JSON-instelling te lezen. Een lege tekst, false, 0, null, een lege JSON-lijst of een leeg JSON-object, of een ontbrekend pad telt als niet gevuld.",
"Digital post binding": "Digitale-postkoppeling",
"Select a digital post binding": "Kies een digitale-postkoppeling",
"Which service this source posts letters through. Each binding asks for its own settings once you pick it.": "Via welke dienst deze bron brieven verstuurt. Elke koppeling vraagt om haar eigen instellingen zodra je die kiest.",
"This instance has no digital post binding installed, so a letter cannot be sent from here yet.": "Op deze omgeving is geen digitale-postkoppeling geïnstalleerd, dus vanaf hier kan nog geen brief worden verstuurd."
},
"plurals": {}
}
23 changes: 12 additions & 11 deletions lib/Adapters/Berichtenbox/BerichtenboxClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,24 @@ abstract public function flavour(): string;
/**
* Dispatch a BBK 1.7 message envelope to Logius.
*
* The signing material is named, never passed. A live binding resolves
* the certificate and its key inside integriq, through
* {@see \OCA\Integriq\Adapters\Digikoppeling\PkiOverheidCredentialResolver},
* exactly as `WusProfileService` does. A PEM has no business travelling
* through a method argument, a source configuration value or an app-config
* string, and this signature is what keeps that true (REQ-DPA-004).
*
* @param array<string,mixed> $message BBK 1.7-shaped envelope.
* @param string $pkiCert PEM-encoded
* PKIoverheid
* Services-server
* cert —
* required by
* live
* binding,
* ignored by
* mock.
* @param string $pkiKey PEM-encoded private key.
* @param string $certificateRef Reference to the PKIoverheid
* Services-server certificate the credential
* broker holds. Required by a live binding,
* ignored by the mock.
*
* @return array<string,mixed> Logius response envelope —
* logiusKenmerk, deliveryStatus,
* receivedAt.
*/
abstract public function dispatch(array $message, string $pkiCert, string $pkiKey): array;
abstract public function dispatch(array $message, string $certificateRef): array;

/**
* Verify the HMAC signature on an inbound Logius delivery-receipt
Expand Down
7 changes: 3 additions & 4 deletions lib/Adapters/Berichtenbox/BerichtenboxClientMock.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,12 @@ public function flavour(): string {
* Dormant dispatch — returns a synthetic Logius envelope.
*
* @param array<string,mixed> $message Message (ignored).
* @param string $pkiCert PKIoverheid cert (ignored).
* @param string $pkiKey Private key (ignored).
* @param string $certificateRef Certificate reference (ignored).
*
* @return array<string,mixed>
*/
public function dispatch(array $message, string $pkiCert, string $pkiKey): array {
unset($message, $pkiCert, $pkiKey);
public function dispatch(array $message, string $certificateRef): array {
unset($message, $certificateRef);
return [
'logiusKenmerk' => 'bbk-mock-' . bin2hex(random_bytes(8)),
'deliveryStatus' => 'queued',
Expand Down
118 changes: 118 additions & 0 deletions lib/Adapters/Berichtenbox/BerichtenboxClientUnavailable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

/**
* The binding an instance gets when it asks for the live Berichtenbox and the
* live Berichtenbox is not there.
*
* @category Adapter
* @package OCA\Integriq\Adapters\Berichtenbox
*
* @author Conduction Development Team <info@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* SPDX-License-Identifier: EUPL-1.2
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
*
* @link https://www.integriq.nl
*/

declare(strict_types=1);

namespace OCA\Integriq\Adapters\Berichtenbox;

use RuntimeException;

/**
* REQ-DPA-005: the mock must not be reachable on an instance whose flag is
* set. An operator who turns the flag on is saying "send real letters"; if the
* live network leg is not there, the honest answer is a refusal naming what is
* missing, not a mock reporting delivered for a letter that never left the
* building.
*
* `BerichtenboxClientHttp` is the class that will replace this one. It is
* blocked on three things the repo cannot supply for itself: Logius BBK OAuth
* client credentials, a PKIoverheid Services-server certificate, and
* `CredentialBrokerService::issueSigningMaterial` in OpenRegister, without
* which `PkiOverheidCredentialResolver` fails closed for every reference.
* Until all three clear, this is what a flagged instance resolves to.
*
* @spec openspec/changes/berichtenbox-digital-post-adapter/specs/digital-post-adapter/spec.md#requirement-the-feature-flag-selects-the-binding-and-a-flagged-instance-without-credentials-refuses-req-dpa-005
*/
class BerichtenboxClientUnavailable extends BerichtenboxClient {
/**
* What this binding is, in one word, for the structured log.
*
* @return string Always `unavailable`.
*/
public function flavour(): string {
return 'unavailable';
}//end flavour()

/**
* Refuse the dispatch, naming what is missing.
*
* @param array<string,mixed> $message BBK 1.7-shaped envelope.
* @param string $certificateRef The certificate reference.
*
* @return array<string,mixed> Never returns.
*
* @throws RuntimeException Always.
*/
public function dispatch(array $message, string $certificateRef): array {
unset($message);

throw new RuntimeException($this->refusal($certificateRef));
}//end dispatch()

/**
* Refuse to verify a webhook, rather than answering signatureValid false
* and letting a caller read that as a checked answer.
*
* @param string $rawBody Raw inbound body bytes.
* @param array<string,string> $headers Inbound headers.
*
* @return array<string,mixed> Never returns.
*
* @throws RuntimeException Always.
*/
public function verifyWebhook(string $rawBody, array $headers): array {
unset($rawBody, $headers);

throw new RuntimeException($this->refusal(''));
}//end verifyWebhook()

/**
* Refuse a mailbox check.
*
* @param string $bsn The BSN, never inspected.
*
* @return array<string,mixed> Never returns.
*
* @throws RuntimeException Always.
*/
public function checkMailbox(string $bsn): array {
unset($bsn);

throw new RuntimeException($this->refusal(''));
}//end checkMailbox()

/**
* The refusal an operator reads.
*
* @param string $certificateRef The certificate reference that was named, if any.
*
* @return string The message.
*/
private function refusal(string $certificateRef): string {
$missing = 'a PKIoverheid Services-server certificate';
if (trim($certificateRef) !== '') {
$missing = sprintf('a credential broker that can supply signing material for "%s"', $certificateRef);
}

return 'logius.berichtenbox.feature_flag is set, so this instance asked for the live Berichtenbox, '
. 'and the live Berichtenbox is not available: it needs Logius BBK OAuth client credentials, '
. $missing . '. Nothing was sent, and the mock is deliberately not served: a simulated delivery '
. 'on a flagged instance would be indistinguishable from a real one.';
}//end refusal()
}//end class
Loading
Loading