Typed, tagless-final clients for Germany’s public-administration messaging: a DVDV2 directory client and an XMeld-over-OSCI sender that resolves routing per recipient automatically. Per-tenant isolation, mandatory caching and both PKCS12 and PEM certificates, built on Cats Effect 3, http4s and BouncyCastle.
libraryDependencies += "de.thatscalaguy" %% "osci-xmeld" % "0.1.0-SNAPSHOT" A typed, tagless-final Scala 3 toolkit for the German public-administration messaging stack:
dvdv — a client for the DVDV2 v2 öffentliche API
(Deutsches Verwaltungsdiensteverzeichnis), with a configurable entry path
(default extern/standaloneauth/directory). Look up authorities,
categories, certificates and service descriptions.osci — speaks OSCI (Governikus osci-bibliothek) in both shapes:
synchronous request/response (XMeld Personensuche against a Meldebehörde,
routes resolved automatically from DVDV per recipient AGS) and asynchronous
messaging (XFamilie-style: store into a recipient's mailbox, fetch + ack
from your own mailbox at an intermediary).utils — the certificate plumbing both of the above share: load
PKCS12/PEM material, or resolve it by alias from an in-memory map or a
hot-reloaded directory.Built on Scala 3 · Cats Effect 3 · http4s 0.23 (Ember) · circe · mules · BouncyCastle · osci-bibliothek.
Design principles:
F[_]; run it with IO,
Resource[F, _], or your own effect..p12) or PEM (cert.pem + key.pem).client_credentials flow, EmbeddedBearer
token header, automatic single retry on 401.| Module | sbt name | Depends on | Purpose |
|---|---|---|---|
utils |
utils |
— | CertSource / CertLoader / CertManager — shared certificate material |
dvdv |
dvdv |
utils |
Tagless-final DVDV2 v2 directory client (JWT auth + mules caching) |
osci |
osci |
dvdv, utils |
OSCI client: sync request (XMeld), async send + mailbox (XFamilie); DVDV-driven routing; single- and multi-tenant |
Dependency direction:
osci ──▶ dvdv ──▶ utils
└────────────────▶ utils
// build.sbt — pick the module you need (transitive deps are pulled in)
libraryDependencies += "de.thatscalaguy" %% "zustellix-osci" % "0.3.0"
// or just the directory client:
libraryDependencies += "de.thatscalaguy" %% "zustellix-dvdv" % "0.3.0"
// or only the cert utilities:
libraryDependencies += "de.thatscalaguy" %% "zustellix-utils" % "0.3.0"
Migrating from 0.1.x:
zustellix-osci-xmeldis frozen at 0.1.1 and replaced byzustellix-osci. Packagede.thatscalaguy.zustellix.oscixmeld→de.thatscalaguy.zustellix.osci;OSCIXMeld.send→OsciClient.request;OSCIXMeldConfig→OsciConfig(the unusedcategory/requestTimeoutfields are gone);OSCIXMeldFacade→OsciFacade;OSCIXMeldError→OsciError.
Migrating to 0.4.x: feedback handling is now OSCI-1.2-conformant —
3xxxcodes are warnings, not errors (see OSCI feedback codes). When upgrading:
OsciReceiptandLaufzettelgained awarnings: List[OsciFeedback] = Nilfield. Construction and field access compile unchanged; pattern matches that destructure every field need the new one, and codecs/schemas derived from the case-class shape (circe, doobie, a DB table mirroringLaufzettel) must add it.Laufzettel.rawXmlis nowOption[String]and empty by default. The decrypted response XML of arequestcontains personal data, so it is no longer handed to the sink unless payload capture is explicitly enabled withOsciConfig.capturePayloads = true(properties keytenant.<id>.capturePayloads) — see Laufzettel. With capture on, a response without extractable content isNone(instead of the former""); forsendand failure records it is alwaysNone. Codecs/schemas derived from the case-class shape (circe, doobie, a DB table mirroringLaufzettel) must make the column nullable, and sinks that relied on the payload must opt in.OsciClient.request/OsciFacade.requestnow returnF[OsciResponse]instead ofF[String]: the response payload moved toOsciResponse.xml: Option[String]—None(instead of the former"") when the answer had no extractable content — and the intermediary'smessageId,statusand3xxxwarningsare now visible on the synchronous path too. Callers that only want the payload usersp.xml.getOrElse("").request/sendno longer raiseOsciError.OsciResponsefor3xxxfeedback (e.g.3802"Signatur des Empfängers über die Annahme- bzw. Bearbeitungsantwort fehlt") — the call succeeds and the codes land inwarnings. Move alerting/retry logic keyed on that exception to inspectingwarnings; a remainingOsciResponseis a real9xxxfailure.Laufzettel.statuscan now carry a3xxxcode, so "starts with0= success" checks must accept0xxxand3xxxas delivered —LaufzettelStatus.delivereddoes exactly that.- All feedback rows are scanned now: a
9xxxerror behind a per-language duplicate row raises where it previously slipped through, and the mailbox'spending/fetchno longer abort on3800/3801("more available than the fetch limit").OsciMailbox.pendingnow returnsF[PendingPage]instead ofF[List[PendingDelivery]]: the deliveries moved toPendingPage.deliveries, and the response's3xxxwarningsride along —PendingPage.truncated(derived from3800/3801) finally tells "mailbox fully listed" apart from "listing cut off atfetchLimit", whichsize == fetchLimitalone could not.OsciMessagegained awarnings: List[OsciFeedback] = Nilfield for the3xxxfeedback of the fetch response. Construction and field access compile unchanged; pattern matches that destructure every field need the new one.OsciError.OsciResponsegained amessageId: Option[String] = Nonefield. Construction compiles unchanged; pattern matches that destructure every field need the new one.- Failed deliveries now record a
Laufzetteltoo (see Laufzettel). Sinks that treated every record as a delivered message must checkstatus:0xxx/3xxxis delivered, anything else (a9xxxcode or an error kind likeOsciTransport) is a failure record withrawXml = None.- The AGS is now the opaque type
Agsinstead of a plainString— inOsciClient.request/send,OsciFacade.request/send,Laufzettel.recipientAgsand theagsfield ofAgsNotInDvdv,RecipientCertMissingandServiceElementMissing. Build one withAgs.from(s)(Either[OsciError.InvalidAgs, Ags], exactly 8 digits) orAgs.unsafe(s)(throws); read the raw string back with.value.OsciError.RecipientCertMissinggained akindfield naming the affected service element (OSCI_ADDRESSEE/OSCI_INTERMEDIARY) — it was previously appended to theagsstring.Laufzettel.statusis now aLaufzettelStatusinstead of aString:Feedback(code)carries an OSCI feedback code,Failed(kind)an error kind when no code exists.status.deliveredreplaces "starts with0or3" checks, andstatus.renderyields the previous plain string for logs and DB columns.- SOAP faults from the intermediary (osci-bibliothek's
OSCIErrorException/SoapServerException) now raiseOsciError.OsciResponsewith the fault's9xxxcode instead ofOsciError.OsciTransport— the same failure is typed identically whether it arrives as feedback rows or as a SOAP fault, and a failureLaufzettelrecordsFeedback(code)instead ofFailed("OsciTransport").IllegalArgumentException/IllegalStateExceptionescaping the OSCI library now surface asOsciError.Configinstead ofOsciTransport.OsciMailboxConfignow rejects a non-positivefetchLimitwithOsciError.Configat construction.- A DVDV service element with a missing or blank
serviceElementUrinow fails at resolve time withOsciError.ServiceElementMissinginstead of surfacing much later as a mislabeledOsciError.OsciTransportfrom inside the transport. And the intermediary's cipher certificate may now be supplied by a standaloneCIPHER_CERTIFICATEelement with the sameserviceElementDescriptionName— the fallback the addressee already had; previously that shape raisedRecipientCertMissing.OsciClient.resource(all overloads) andOsciFacade.fromConfigsnow require agiven LoggerFactory[F]in scope, like theDvdvClientconstructors — a failingLaufzettelSinkis now logged at warn instead of being silently swallowed. Recording stays best-effort: the operation still never fails on a sink error.
Migrating to 0.5.x: recipients are addressed by their full DVDV organization key, so keys that are not an AGS work too:
OsciClient.request/sendandOsciFacade.request/sendtake anOrganizationKey(e.g.ags:01001000). TheAgsvariants are kept and prependags:themselves — callers compile unchanged. Implementors (test fakes) override theOrganizationKeypair instead.Laufzettel.recipientAgs: Agsis nowrecipient: OrganizationKey.OsciError.AgsNotInDvdvis nowRecipientNotInDvdv; it,RecipientCertMissingandServiceElementMissingcarryrecipient: OrganizationKeyinstead ofags: Ags.
utils — certificatesEverything starts with a certificate. DVDV uses it to sign the
client_assertion JWT (RS256, not mTLS); OSCI uses it as the Originator's
signing + decryption key. Both consume the same material through utils.
CertSourceimport de.thatscalaguy.zustellix.utils.cert.CertSource
import java.nio.file.Paths
// PKCS12
CertSource.Pkcs12(
path = Paths.get("/secrets/client.p12"),
password = "changeit"
)
// PEM (cert + key in separate files)
CertSource.Pem(
certPath = Paths.get("/secrets/client-cert.pem"),
keyPath = Paths.get("/secrets/client-key.pem"),
keyPassword = None // Some("...") for encrypted PKCS#8 / RSA keys
)
When the material arrives from a secret manager rather than a readable file, use the in-memory variants — same loading, no temp file:
CertSource.Pkcs12Bytes(bytes = p12Bytes, password = "changeit")
CertSource.PemBytes(
cert = certBytes,
key = keyBytes,
keyPassword = None
)
CertLoader.load[F] turns any of them into a LoadedCert (private key, X509,
SHA-1 fingerprint hex, and the leaf-first certificate chain the source
provides). The DVDV/OSCI clients call this for you — you rarely touch it
directly.
A PKCS12 source must contain exactly one private-key entry — with several
entries loading fails rather than picking one arbitrarily — and the store
password is also used as the entry password, as produced by
openssl pkcs12 -export and keytool.
toString is redacted on every case, so a CertSource (or a config holding
one) can be logged without leaking its password.
CertManagerFor multi-tenant deployments, resolve credentials by a CertAlias instead of
hard-coding paths. A CertManager[F] returns a CertCredential (raw PKCS12
bytes + password) that both DVDV and OSCI can consume for the same tenant.
On this path the cert comes from the manager, so certSource is left unset on
DvdvConfig / OsciConfig — it defaults to None.
In memory, hot-swappable:
import cats.effect.IO
import de.thatscalaguy.zustellix.utils.cert.*
for {
mgr <- InMemoryCertManager.make[IO](Map(
CertAlias("flensburg") -> CertCredential(p12Bytes, "secret")
))
cred <- mgr.resolve(CertAlias("flensburg")) // raises UnknownCert if absent
// later, atomically replace the whole map (e.g. on config reload):
_ <- mgr.swap(Map(CertAlias("kiel") -> CertCredential(otherBytes, "s2")))
} yield ()
Backed by a directory, polled and hot-reloaded:
import cats.effect.IO
import de.thatscalaguy.zustellix.utils.cert.*
import org.typelevel.log4cats.slf4j.Slf4jFactory
import org.typelevel.log4cats.LoggerFactory
import java.nio.file.Paths
import scala.concurrent.duration.*
given LoggerFactory[IO] = Slf4jFactory.create[IO]
val cfg = DirectoryCertManagerConfig(
dir = Paths.get("/secrets/certs"), // scanned for <alias>.p12
interval = 30.seconds // rebuilt every interval
// passwordsFile defaults to <dir>/passwords.properties (alias=password)
)
DirectoryCertManager.resource[IO](cfg).use { certs =>
certs.knownAliases.flatMap(IO.println)
}
The first scan completes before the Resource is ready (a misconfigured
directory fails fast). A <alias>.p12 that has never loaded (e.g. corrupt)
is logged and skipped — the rest still swap in. Once an alias has loaded, a
transient per-file failure — a keystore read mid-overwrite during rotation,
or a password entry that has not landed yet — retains the previously loaded
credential instead of dropping the tenant, with the failure logged each scan
until the file loads again; an alias is dropped only when its .p12 file is
deleted from the directory (observed at the next scan). The .p12 extension
is matched case-insensitively, and a bare .p12 file (empty alias) or a
directory named like a keystore is ignored. Rotate files
atomically — write to a temp file in the same directory, then
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE) — since
cp/scp/configmap-style sync is not atomic, and a torn file would
otherwise be served from the retained previous credential until the write
completes.
DirectoryCertManager picks up *.p12 files only — PEM files placed in the
directory are ignored, since PKCS12 is the on-disk format both DVDV and OSCI
consume. PEM material can still back a tenant: repack it in memory with
CertCredential.fromPem(cert, key, keyPassword, storePassword) (or
CertCredential.fromSource for any CertSource) and hand the resulting
credential to InMemoryCertManager, or export it to a .p12
(openssl pkcs12 -export) for the directory.
zustellix-utils, zustellix-dvdv and zustellix-osci depend only on
log4cats-core, so to use Slf4jFactory as shown above, add
org.typelevel::log4cats-slf4j plus an SLF4J backend (e.g.
logback-classic) to your own build — or provide any other
LoggerFactory[F] implementation. The DvdvClient and OsciClient
constructors and OsciFacade.fromConfigs need the same LoggerFactory[F]
in scope.
dvdv — DVDV2 directory clientimport cats.effect.{IO, IOApp}
import de.thatscalaguy.zustellix.dvdv.*
import de.thatscalaguy.zustellix.dvdv.model.*
import de.thatscalaguy.zustellix.utils.cert.CertSource
import org.http4s.implicits.uri
import org.typelevel.log4cats.LoggerFactory
import org.typelevel.log4cats.slf4j.Slf4jFactory
import java.nio.file.Paths
object Demo extends IOApp.Simple:
given LoggerFactory[IO] = Slf4jFactory.create[IO]
val config = DvdvConfig(
baseUri = uri"https://your-dvdv-betreiber.example",
certSource = Some(CertSource.Pkcs12(
path = Paths.get("/secrets/my-client.p12"),
password = sys.env("MY_CLIENT_P12_PASSWORD")
))
)
def run: IO[Unit] =
DvdvClient.resource[IO](config).use { dvdv =>
for
cats <- dvdv.categories
org <- dvdv.findAuthorityDescription(Category.unsafe("Meldebehörde"), OrganizationKey.unsafe("ags:01999001"))
check <- dvdv.verifyCategory(Fingerprint.unsafe("0272c56c9742a62501329a3aa78974f1605c92a2"), Category.unsafe("Meldebehörde"))
_ <- IO.println(s"Got ${cats.size} top-level categories")
_ <- IO.println(s"Organization: ${org.flatMap(_.organization).map(_.nameDe)}")
_ <- IO.println(s"Category verification: ${check.verifyCategory}")
yield ()
}
The first call drives the full JWT → token → endpoint flow. The token is cached and refreshed ahead of expiry; cacheable responses are memoized.
// Ember-backed, single tenant from config.certSource (needs Async + Network).
// Raises DvdvError.Config if config.certSource is None:
DvdvClient.resource[IO](config)
// Ember-backed, signing cert resolved from a shared CertManager by alias:
DvdvClient.resource[IO](config, certManager, CertAlias("flensburg"))
// Bring your own http4s Client (tests, non-Ember backends; needs Async).
// config.requestTimeout is applied around the provided client:
DvdvClient.fromClient[IO](config, myClient)
DvdvClient.fromClient[IO](config, myClient, certManager, CertAlias("kiel"))
All constructors require a given LoggerFactory[F] in scope (the auth layer
warns on degenerate token TTL/skew combinations, and a 204 carrying the spec's
dvdv-warning-msg header — an invalid matching service exists — is logged at
warn) — see the
log4cats note at the end of the
utils section for how to supply one.
On the CertManager overloads the alias is resolved once at build time (so an
unknown alias fails fast) and then again on every token refresh — a cert
rotated in the manager (e.g. by DirectoryCertManager) signs the next
client_assertion without a restart. The certSource constructors load the
cert once and keep it for the lifetime of the client.
import de.thatscalaguy.zustellix.dvdv.{CacheConfig, DvdvConfig, DvdvEntryPath, RetryConfig}
import scala.concurrent.duration.*
val config = DvdvConfig(
baseUri = uri"https://your-dvdv-betreiber.example",
entryPath = DvdvEntryPath.StandaloneAuth,
// or InternDirectory (unauthenticated, no cert needed)
// or BundesmasterAuth (bring your own IAM auth)
certSource = Some(CertSource.Pkcs12(p12Path, password)),
// omit entirely when using CertManager + CertAlias
issuer = None, // JWT iss; defaults to "fp:<sha1-fingerprint>"
tokenEndpoint = None, // explicit token POST target; defaults to <active server>/extern/standaloneauth/token
jwtAudience = None, // JWT aud claim; defaults to the token endpoint actually contacted (follows failover)
jwtLifetime = 60.seconds, // client_assertion lifetime
tokenRefreshSkew = 30.seconds, // refresh this far ahead of expiry (clamped to at most half the token TTL)
defaultTokenTtl = 5.minutes, // token lifetime assumed when the token response has no expires_in
requestTimeout = 30.seconds, // per request attempt; applied by every constructor, incl. fromClient
retryConfig = RetryConfig(), // GET retries on 429/transient 5xx/transport errors: exp. backoff + jitter,
// honors Retry-After; RetryConfig.disabled turns it off
totalDeadline = Some(5.minutes), // hard cap on one call incl. failover + retries; None disables
cacheConfig = CacheConfig(
categoriesTtl = 2.hours, // override any subset
findAuthorityDescriptionTtl = 15.minutes,
verifyCategoryTtl = 1.minute,
negativeTtl = 5.minutes, // cap on caching a `None` miss
purgeInterval = 1.minute // background purge cadence for expired entries
)
)
// Disable caching entirely (useful in tests):
DvdvConfig(baseUri = ???, certSource = Some(???), cacheConfig = CacheConfig.disabled)
Default TTLs:
| Endpoint | Default TTL |
|---|---|
categories, intermediaries |
1 hour |
findCertificateByFingerprint |
1 hour |
findServiceSpecificationUrisByCategory |
1 hour |
findAuthorityDescription(s), findCategories |
10 minutes |
findServiceDescription, findOrganizationsByServiceElement |
10 minutes |
verifyCategory |
5 minutes |
serviceVersion, all batch* POSTs |
not cached |
Misses (None) from findAuthorityDescription, findCertificateByFingerprint and findServiceDescription are cached for at most negativeTtl (default: 5 minutes, never longer than the endpoint's TTL), so a newly onboarded authority or certificate becomes visible quickly.
Expired entries are also purged by a background fiber every purgeInterval (default: 1 minute), scoped to the client Resource, so the caches do not grow unboundedly between accesses.
// Category tree
dvdv.categories.flatMap { tree =>
IO {
tree.foreach { l1 =>
println(l1.name)
l1.children.toList.flatten.foreach(l2 => println(s" ${l2.name}"))
}
}
}
// Look up an organization (Option: 204 No Content → None).
// Category, OrganizationKey and Fingerprint are validating opaque types —
// `unsafe` throws on invalid input, `from` returns an Either.
import de.thatscalaguy.zustellix.dvdv.model.*
val org: IO[Option[OrganizationDescription]] =
dvdv.findAuthorityDescription(
category = Category.unsafe("Meldebehörde"),
organizationKey = OrganizationKey.unsafe("ags:01999001")
)
// Certificate by fingerprint. Fingerprint normalizes on construction —
// colons and whitespace are stripped and hex is lowercased, so the
// colon-separated uppercase form and the plain lowercase form are the SAME
// value (and the same cache entry):
dvdv.findCertificateByFingerprint(Fingerprint.unsafe("0272c56c9742a62501329a3aa78974f1605c92a2"))
.map(_.flatMap(_.nameSubject)) // Some("GRP: Stadt Flensburg XhD-T") | None
Fingerprint.unsafe("02:72:C5:6C:97:42:A6:25:01:32:9A:3A:A7:89:74:F1:60:5C:92:A2") ==
Fingerprint.unsafe("0272c56c9742a62501329a3aa78974f1605c92a2") // true
// Organizations by service element
dvdv.findOrganizationsByServiceElement(
serviceElementType = ServiceElementType.OSCI_ADDRESSEE,
parameterType = ParameterType.CIPHER_CERTIFICATE,
parameterValue = "80157bbb3934cb651fb4df94a98773fba0b02b03"
)
// ... or by an operator-configured (custom) service element type
dvdv.findOrganizationsByServiceElement(
customServiceElementType = "MY_TYPE",
parameterType = ParameterType.URI,
parameterValue = "01001000"
)
// Verify a fingerprint belongs to a category
dvdv.verifyCategory(
fingerPrint = Fingerprint.unsafe("02:72:C5:6C:97:42:A6:25:01:32:9A:3A:A7:89:74:F1:60:5C:92:A2"),
category = Category.unsafe("Behörde")
).map(_.verifyCategory) // Boolean
// Batch lookup
val batch = List(
Request(category = Some("Meldebehörde"), organizationKey = Some("ags:01001000")),
Request(category = Some("Meldebehörde"), organizationKey = Some("ags:02000000"))
)
// Results align positionally with the input; a per-item miss decodes to None.
// More than 200 requests raise DvdvError.BatchTooLarge before any HTTP call.
dvdv.batchFindAuthorityDescription(batch)
Every non-success response raises a typed DvdvError (a RuntimeException):
import de.thatscalaguy.zustellix.dvdv.DvdvError
dvdv.findAuthorityDescription(Category.unsafe("Meldebehörde"), OrganizationKey.unsafe("ags:irrtum")).attempt.flatMap {
case Right(Some(org)) => IO.println(org)
case Right(None) => IO.println("no match (204)")
case Left(DvdvError.NotFound(p)) => IO.println(s"404: ${p.detail}")
case Left(DvdvError.ValidationError(p)) => IO.println(s"400: ${p.detail}")
case Left(DvdvError.AuthenticationError(p)) => IO.println(s"401: ${p.detail}")
case Left(DvdvError.RateLimited(retryAfter, body, problem)) => IO.println(s"429 (after retries): $body")
case Left(DvdvError.Unexpected(status, body, problem)) => IO.println(s"$status: $body")
case Left(DvdvError.DecodingError(endpoint, cause)) => IO.println(s"$endpoint returned an undecodable body: $cause")
case Left(DvdvError.TransportError(cause)) => IO.println(s"transport: $cause")
}
On 401 the auth middleware releases the response, invalidates the cached
token, and retries the request exactly once before propagating the error.
trait DvdvClient[F[_]]:
def categories: F[List[DirectoryOrganizationCategoryLevel1DTO]]
def intermediaries: F[List[SummaryServiceElementDTO]]
def serviceVersion: F[ServiceVersion]
def findAuthorityDescription(category: Category, organizationKey: OrganizationKey): F[Option[OrganizationDescription]]
def findAuthorityDescriptions(organizationKey: OrganizationKey): F[List[OrganizationDescription]]
def findCategories(fingerPrint: Fingerprint, organizationKey: OrganizationKey): F[List[String]]
def findCertificateByFingerprint(fingerPrint: Fingerprint): F[Option[Certificate]]
def findOrganizationsByServiceElement(serviceElementType: ServiceElementType, parameterType: ParameterType, parameterValue: String): F[List[LightweightOrganization]]
def findOrganizationsByServiceElement(customServiceElementType: String, parameterType: ParameterType, parameterValue: String): F[List[LightweightOrganization]]
def findServiceDescription(organizationKey: OrganizationKey, serviceSpecificationUri: String): F[Option[Service]]
def findServiceSpecificationUrisByCategory(category: Category): F[List[String]]
def verifyCategory(fingerPrint: Fingerprint, category: Category): F[VerificationResult]
def batchFindAuthorityDescription(requests: List[Request]): F[List[Option[OrganizationDescription]]]
def batchFindCategories(requests: List[Request]): F[List[List[String]]]
def batchFindOrganizationsByServiceElement(requests: List[Request]): F[List[List[LightweightOrganization]]]
def batchFindServiceDescription(requests: List[Request]): F[List[Option[Service]]]
def batchFindServiceSpecificationUrisByCategory(requests: List[Request]): F[List[List[String]]]
def batchVerifyCategory(requests: List[Request]): F[List[VerificationResult]]
Six operations deviate from the response types dvdv-api.yaml declares — the
schema is wrong there; the client decodes what real servers actually send:
| Operation | Schema declares | Client decodes |
|---|---|---|
findOrganizationsByServiceElement |
single OrganizationDescription |
List[LightweightOrganization] |
findServiceSpecificationUrisByCategory |
array of ServiceBase objects |
List[String] (the URIs) |
batchFindAuthorityDescription |
single OrganizationDescription |
List[Option[OrganizationDescription]], positionally aligned |
batchFindOrganizationsByServiceElement |
single OrganizationDescription |
List[List[LightweightOrganization]] |
batchFindServiceDescription |
single Service |
List[Option[Service]], positionally aligned |
batchFindServiceSpecificationUrisByCategory |
single Request |
List[List[String]] |
These wire shapes are pinned by fixtures under dvdv/src/test/resources. The
batch positional-null miss encoding mirrors the single-call 204/404 miss
semantics, since the spec does not specify a batch miss encoding.
osci — OSCI messaging (sync + async)OsciClient covers the outbound directions; OsciMailbox covers the inbound
one:
| Operation | OSCI message type | Shape |
|---|---|---|
OsciClient.request(recipient, xml) |
MediateDelivery |
synchronous request/response (e.g. XMeld Personensuche), returns an OsciResponse |
OsciClient.send(recipient, xml) |
StoreDelivery |
asynchronous: stored in the recipient's mailbox, returns an OsciReceipt |
OsciMailbox.pending / fetch / drain |
FetchProcessCard / FetchDelivery |
asynchronous receive + ack from your own mailbox (e.g. XFamilie); drain batches listing + fetches into one dialog |
Recipients are addressed by their DVDV OrganizationKey (e.g.
OrganizationKey.unsafe("ags:01001000")). For municipalities there is the
Ags (amtlicher Gemeindeschlüssel) shortcut, which prepends ags: itself — an
opaque type that only admits well-formed keys: Ags.from("01001000")
validates (exactly 8 digits, Either[OsciError.InvalidAgs, Ags]),
Ags.unsafe(...) throws on bad input, .value reads the raw string back.
A typo fails at the call site instead of surfacing as a DVDV miss.
Every outbound operation:
dvdv.findServiceDescription(recipient, serviceUri) once per
call (memoized by the DVDV mules cache);OSCI_ADDRESSEE) and intermediary
(OSCI_INTERMEDIARY) routes out of that single service description —
neither is configured statically;Laufzettel to the configured sink — on failure too, so the
audit trail is not success-only (best-effort — a sink failure is logged
at warn and never fails the operation).osci-bibliothek itself consumes PKCS12 only, but the PEM
CertSourcevariants (Pem/PemBytes) are converted to an in-memory PKCS12 automatically — all four variants work here.
import cats.effect.{IO, IOApp}
import de.thatscalaguy.zustellix.dvdv.*
import de.thatscalaguy.zustellix.osci.*
import de.thatscalaguy.zustellix.utils.cert.CertSource
import org.http4s.implicits.uri
import org.typelevel.log4cats.LoggerFactory
import org.typelevel.log4cats.slf4j.Slf4jFactory
import java.nio.file.Paths
object SendDemo extends IOApp.Simple:
given LoggerFactory[IO] = Slf4jFactory.create[IO]
val cert = CertSource.Pkcs12(Paths.get("/secrets/flensburg.p12"), sys.env("P12_PW"))
val dvdvConfig = DvdvConfig(
baseUri = uri"https://your-dvdv-betreiber.example",
certSource = Some(cert)
)
val osciConfig = OsciConfig(
tenantId = TenantId("flensburg"),
certSource = Some(cert) // same PKCS12: DVDV signs the JWT, OSCI signs/decrypts
// serviceUri + subject default to the XMeld Personensuche profile
)
def run: IO[Unit] =
(for
dvdv <- DvdvClient.resource[IO](dvdvConfig)
osci <- OsciClient.resource[IO](osciConfig, dvdv, LaufzettelSink.console[IO])
yield osci).use { osci =>
osci.request(ags = Ags.unsafe("01001000"), xml = "<xmeld>...</xmeld>").flatMap { rsp =>
IO.println(s"[${rsp.status}] ${rsp.xml.getOrElse("<no content>")}")
}
}
The given DvdvClient is owned by the caller — the OsciClient resource does
not close it.
request returns an OsciResponse(xml, messageId, status, warnings):
xml: Option[String] — the recipient's decrypted (and per policy
signature-checked) response payload; None when the answer carried no
extractable content;messageId: String — empty by default: the default wire profile skips the
GetMessageId round trip. Set OsciConfig(explicitDialog = true) to get
an intermediary-issued id (the handle for any later process-card inquiry)
at the cost of an extra round trip — see
Wire round trips;status: String — the top OSCI feedback code (e.g. "0800");warnings: List[OsciFeedback] — warning-class (3xxx) feedback, e.g.
3802 (see OSCI feedback codes).For profiles whose recipients answer asynchronously (e.g. XFamilie), send
stores the message in the recipient's mailbox at their intermediary and
returns immediately with a receipt:
val xfamConfig = OsciConfig(
tenantId = TenantId("flensburg"),
certSource = Some(cert),
serviceUri = "urn:xfamilie:...", // the profile's DVDV service URI
subject = "XFamilie"
)
OsciClient.resource[IO](xfamConfig, dvdv, LaufzettelSink.console[IO]).use { osci =>
osci.send(ags = Ags.unsafe("01001000"), xml = "<xfamilie>...</xfamilie>").flatMap { receipt =>
IO.println(s"stored as ${receipt.messageId} (status ${receipt.status})")
}
}
By default each operation uses the leanest OSCI dialog shape the protocol (and osci-bibliothek) allows:
request = InitDialog + MediateDelivery + ExitDialog (3 round
trips). osci-bibliothek cannot send a MediateDelivery in an implicit
dialog, so the dialog frame stays; the saving is the skipped
GetMessageId — which also means OsciResponse.messageId /
Laufzettel.messageId are empty, and no request process card is written
at the intermediary (OSCI ties the process card — and the subject — to
the message id).send = GetMessageId + StoreDelivery, both in implicit dialogs (2
round trips). The receipt still carries the intermediary-issued
messageId — StoreDelivery requires one.OsciConfig(explicitDialog = true) restores the previous
GetMessageId + InitDialog + delivery + ExitDialog flow (4 round trips)
for both operations — use it when downstream systems key on request's
messageId, or for an intermediary that rejects implicit deliveries.
OsciMailbox (fetch + ack)The inbound leg of asynchronous profiles: other parties store messages into your mailbox at your intermediary, and you poll it. The mailbox is configured statically (it is yours — it is not resolved from DVDV):
import cats.syntax.all.*
import de.thatscalaguy.zustellix.osci.*
import java.io.FileInputStream
import java.net.URI
import java.security.cert.{CertificateFactory, X509Certificate}
// The intermediary's cipher cert, e.g. from a DER/PEM file:
val intermedCert: X509Certificate =
val in = new FileInputStream("/secrets/intermed.crt")
try CertificateFactory.getInstance("X.509")
.generateCertificate(in).asInstanceOf[X509Certificate]
finally in.close()
val mailboxConfig = OsciMailboxConfig(
intermedUri = URI.create("https://intermed.example/osci-manager"),
intermedCipherCert = intermedCert
// fetchLimit = 100
)
OsciMailbox.resource[IO](mailboxConfig, cert).use { mailbox =>
for
page <- mailbox.pending // un-fetched deliveries, process cards only
_ <- page.deliveries.traverse_ { p =>
mailbox.fetch(p.messageId).flatMap { msg =>
IO.println(s"${msg.messageId} [${msg.subject}]: ${msg.xml}")
}
}
_ <- IO.println("more waiting, poll again").whenA(page.truncated)
yield ()
}
pending lists deliveries oldest first — an order the intermediary's
FetchProcessCard response produces, not one this module imposes. A
pending listing is capped at fetchLimit. When the mailbox holds more,
the intermediary flags the response with feedback code 3800 / 3801 —
surfaced as PendingPage.truncated (the raw entries are in
PendingPage.warnings). Since fetching acknowledges (see below), fetch the
listed deliveries and call pending again for the next page; the count of a
page alone cannot tell a full listing from a cut-off one.
Draining in one dialog. Every pending / fetch call opens and closes
its own OSCI dialog (InitDialog … ExitDialog), so emptying a mailbox of N
messages that way costs 3 + 3N round trips. drain(maxMessages) batches the
whole sweep into ONE dialog — InitDialog + FetchProcessCard + one
FetchDelivery per message + ExitDialog, i.e. N+3 round trips:
OsciMailbox.resource[IO](mailboxConfig, cert).use { mailbox =>
mailbox.drain(maxMessages = 50).flatMap { result =>
result.messages.traverse_ { msg =>
IO.println(s"${msg.messageId} [${msg.subject}]: ${msg.xml}")
} *>
result.failure.traverse_(f => IO.println(s"drain stopped at ${f.messageId}: ${f.error}")) *>
IO.println("more waiting, drain again").unlessA(result.complete)
}
}
MailboxDrain carries the listed page (at most min(fetchLimit, maxMessages) process cards), the fetched messages in listing order, and an
optional failure. The fetches acknowledge exactly like fetch does — which
shapes the failure semantics: a failing fetch does not raise, because the
messages fetched before it are already acknowledged at the intermediary and
discarding them would lose acknowledged deliveries. Instead the drain stops,
returns the partial result and reports the failed id on
MailboxDrain.failure (deliveries never fetched stay pending for the next
drain; the failed one may itself already be acknowledged — surface or persist
the failure rather than waiting for a re-listing). result.complete is true
when nothing failed, the listing was not truncated and every listed delivery
was fetched. A failure of the listing (an InitDialog refusal, a
FetchProcessCard error) raises like pending would.
drain trades the strictest crash-safety for round trips: it cannot persist
ids between listing and fetching, so the strict at-least-once pattern below
(pending → persist ids → fetch) remains the choice for consumers that
must not lose a message to a crash mid-sweep.
Acknowledgement semantics. OSCI 1.2 has no separate ack message — a
successful fetch makes the intermediary record a reception entry on the
message's process card, which removes it from pending. The fetch is the
acknowledgement — there is no peek-without-ack: an un-acked (never fetched)
message keeps showing up in pending, a fetched one never does.
At-least-once processing is the caller's to build, and the API is shaped
for it: persist the messageIds from pending before fetching, and after a
crash re-fetch the unprocessed ids directly. Whether a re-fetch still
finds a delivery once its reception entry exists is the intermediary's
retention policy — this module cannot verify it. The gated integration test
(OsciBibBridgeIT, "re-fetch by id after reception still returns the
delivery", enabled via the OSCI_IT_MAILBOX_* variables) asserts
re-fetchability; treat the guarantee as verified only against intermediaries
that test has been run against. The possible loss window is between fetch
and durable processing by the caller: against an intermediary that purges on
reception, a crash there loses the message. Callers needing a stronger
guarantee should persist the raw message immediately on receipt, before any
further processing.
One mailbox can serve several profiles; filter on PendingDelivery.subject
client-side if you need to split them.
Received content — synchronous request answers and mailbox fetch
deliveries — is checked for the author's content signature
(Inhaltsdatensignatur) after decryption. The dialog-level checks of
osci-bibliothek only cover the intermediary envelope signature; the content
signature is what actually proves the author of the payload.
An invalid signature always raises OsciError.InvalidContentSignature,
regardless of configuration. What happens for content that carries no
signature at all is configurable per client/mailbox:
val osciConfig = OsciConfig(
tenantId = TenantId("flensburg"),
certSource = Some(cert),
contentSignatures = ContentSignaturePolicy.Require // default: Warn
)
val mailboxConfig = OsciMailboxConfig(
intermedUri = URI.create("https://intermed.example/osci-manager"),
intermedCipherCert = intermedCert,
contentSignatures = ContentSignaturePolicy.Require // default: Warn
)
ContentSignaturePolicy.Warn (default): unsigned content is accepted and
surfaced as ContentSignatureStatus.Unsigned — on OsciMessage.signature
for mailbox fetches and on Laufzettel.contentSignature for request
responses. Some gateways answer unsigned (the condition feedback code
3802 warns about), which is why this is the default.ContentSignaturePolicy.Require: unsigned content raises
OsciError.UnsignedContent.Fully verified content yields ContentSignatureStatus.Valid. The check
verifies the signatures cryptographically against the certificates embedded
in the message; certificate chain/trust validation stays with the caller.
All wire operations go through an OsciHttpTransport — a drop-in for
osci-bibliothek's sample HttpTransport that additionally sets a connect and
a read timeout on every HttpURLConnection, so a stalled intermediary
surfaces as an OsciError.OsciTransport instead of blocking the calling thread
forever (the bridges run inside Sync[F].blocking, which cannot be
cancelled once started). Both timeouts are configurable per client/mailbox
and default to 10 s connect / 120 s read:
import scala.concurrent.duration.*
val osciConfig = OsciConfig(
tenantId = TenantId("flensburg"),
certSource = Some(cert),
connectTimeout = 5.seconds,
readTimeout = 60.seconds
)
val mailboxConfig = OsciMailboxConfig(
intermedUri = URI.create("https://intermed.example/osci-manager"),
intermedCipherCert = intermedCert,
connectTimeout = 5.seconds,
readTimeout = 60.seconds
)
Note that readTimeout bounds each blocking read, not the whole exchange —
size it for the slowest expected synchronous round trip (request waits for
the addressee's answer within the call).
Both OsciClient.resource and OsciMailbox.resource also accept your own
de.osci.osci12.extinterfaces.TransportI as a trailing argument (proxies,
custom TLS, instrumentation, …); the config timeouts then do not apply — the
transport owns its own settings:
val myTransport: de.osci.osci12.extinterfaces.TransportI = ...
OsciClient.resource[IO](osciConfig, dvdv, LaufzettelSink.console[IO], myTransport)
OsciMailbox.resource[IO](mailboxConfig, cert, myTransport)
OsciHttpTransport opens plain HttpURLConnections via URL.openConnection(),
so outbound proxies are honored only through the JVM's default ProxySelector —
i.e. the standard proxy system properties (https.proxyHost / https.proxyPort,
http.proxyHost / http.proxyPort, http.nonProxyHosts, or
java.net.useSystemProxies). Anything richer — PAC scripts, per-route proxies,
proxy authentication — needs a custom TransportI as shown above.
OsciFacade dispatches request / send by tenant. Build it from a
ConfigSource (one OsciClient per tenant config), supplying the right
DvdvClient per tenant:
import de.thatscalaguy.zustellix.osci.*
// given LoggerFactory[IO] in scope, as in the single-tenant example
val configs = Map(
TenantId("flensburg") -> OsciConfig(TenantId("flensburg"), flensburgCert),
TenantId("kiel") -> OsciConfig(TenantId("kiel"), kielCert)
)
val src: ConfigSource[IO] = ConfigSource.static[IO](configs)
// or load from a java.util.Properties file:
val srcFromFile = ConfigSource.file[IO](Paths.get("/etc/zustellix/tenants.properties"))
def dvdvFor(t: TenantId): DvdvClient[IO] = clientsByTenant(t) // caller owns these
OsciFacade.fromConfigs[IO](src, dvdvFor, LaufzettelSink.console[IO]).use { facade =>
facade.request(TenantId("kiel"), ags = Ags.unsafe("01002000"), xml = "<xmeld>...</xmeld>")
}
Properties-file format for ConfigSource.file:
tenant.flensburg.cert.type = pkcs12
tenant.flensburg.cert.path = /secrets/flensburg.p12
tenant.flensburg.cert.password = s3cret
tenant.flensburg.serviceUri = http://www.osci.de/xmeld2605/xmeld2605Personensuche.wsdl
tenant.flensburg.subject = XMeld
tenant.flensburg.connectTimeoutMs = 5000
tenant.flensburg.readTimeoutMs = 60000
tenant.flensburg.contentSignatures = require
tenant.flensburg.capturePayloads = true
tenant.flensburg.explicitDialog = false
tenant.kiel.cert.type = pem
tenant.kiel.cert.path = /secrets/kiel-cert.pem
tenant.kiel.cert.keyPath = /secrets/kiel-key.pem
tenant.kiel.cert.password = optional-key-password
(serviceUri and subject are optional and default to the XMeld
Personensuche WSDL and XMeld; connectTimeoutMs / readTimeoutMs are
optional and default to 10 s / 120 s; contentSignatures is optional —
require or warn, default warn; capturePayloads is optional —
true stores the decrypted response XML on Laufzettel.rawXml, default
false, see Laufzettel; explicitDialog is optional —
true restores the 4-round-trip wire profile with an intermediary-issued
messageId for request, default false, see
Wire round trips.)
fromConfigs is all-or-nothing: every tenant client is built eagerly when
the resource is acquired, and a tenant whose cert fails to load fails the
whole facade with an OsciError.TenantInitFailed naming that tenant — there
is no partial boot.
Multi-tenant mailboxes are simply multiple OsciMailbox.resource calls — one
per tenant cert/intermediary. Register them beside the clients to dispatch
them through the facade too — facade.tenants then lists every registered
tenant and facade.mailbox(tenant) yields the tenant's mailbox
(pending / fetch / drain):
val registry = TenantRegistry.inMemory[IO](clientsByTenant, mailboxesByTenant)
val facade = OsciFacade.fromRegistry[IO](registry)
facade.mailbox(TenantId("kiel")).flatMap(_.drain(maxMessages = 10))
(fromConfigs registers no mailboxes — the properties file carries only
client configs — so facade.mailbox there raises OsciError.UnknownTenant.)
When DVDV and OSCI should use the same tenant cert from a CertManager,
build both against an alias — the Laufzettel is then recorded under that
alias as the tenant id:
val alias = CertAlias("flensburg")
// given LoggerFactory[IO] in scope, as in the single-tenant example
(for
dvdv <- DvdvClient.resource[IO](dvdvConfig, certManager, alias)
osci <- OsciClient.resource[IO](osciConfig, certManager, alias, dvdv, LaufzettelSink.console[IO])
yield osci).use(_.request(Ags.unsafe("01001000"), "<xmeld>...</xmeld>"))
// the mailbox takes the same alias:
OsciMailbox.resource[IO](mailboxConfig, certManager, alias)
On the CertManager overloads (OsciClient.resource(..., certManager, alias, ...) and OsciMailbox.resource(config, certManager, alias)) the alias is
resolved once at build time (so an unknown alias fails fast) and then again on
every operation — a cert rotated in the manager (e.g. by
DirectoryCertManager) signs and decrypts the next request / send /
pending / fetch without a rebuild; the built OSCI Originator is cached and
only rebuilt when the credential actually changes. The certSource-based
constructors load the cert once and keep it for the lifetime of the
client/mailbox.
Each request / send produces a Laufzettel(messageId, timestamp, recipient, recipientUri, status, rawXml, warnings, contentSignature)
handed to a LaufzettelSink[F]:
LaufzettelSink.console[IO] // prints a one-line summary
LaufzettelSink.noop[IO] // discards
// or your own — persist it, ship it to a queue, etc.
val toDb: LaufzettelSink[IO] = new LaufzettelSink[IO]:
def record(tenant: TenantId, l: Laufzettel): IO[Unit] = repo.insert(tenant, l)
status is a LaufzettelStatus: Feedback(code) when OSCI reported a
feedback code (0xxx / 3xxx / 9xxx), Failed(kind) when the delivery
failed before one existed. status.delivered is true for 0xxx / 3xxx
(the request was executed), and status.render gives the plain string —
the code or the error kind — for logs and DB columns.
rawXml is None by default. The decrypted response XML of a request
(e.g. an XMeld Personensuche answer) contains personal data, and whatever
the sink writes to — a DB, a queue, a log shipper — would persist it with
every record. Set OsciConfig.capturePayloads = true (properties key
tenant.<id>.capturePayloads) to store it on the Laufzettel; do that only
when the sink is meant to hold the payload and your data-protection rules
(retention, access, deletion) cover it. The caller always gets the payload
via OsciResponse.xml — capture only affects what the sink sees.
For send there is no response payload at store time, so rawXml and
contentSignature stay None regardless of the flag. The content signature
of a request response is verified independently of capture —
contentSignature is filled even when rawXml is not.
Failed deliveries are recorded too, so the sink sees the complete audit trail, not just successes. For a failure Laufzettel:
status is Feedback with the 9xxx code for an OSCI error response
(OsciError.OsciResponse) and Failed with the error kind otherwise
(e.g. OsciTransport, RecipientNotInDvdv);messageId is the id issued by GetMessageId when the delivery got that
far, "" otherwise;rawXml is None, warnings is Nil;recipientUri is empty when the resolver itself failed (no route exists).Recording stays best-effort in both directions: a sink failure is logged at
warn via the required LoggerFactory and then discarded — it never fails
the operation — and a failure record never replaces or swallows the raised
OsciError.
All failures are an OsciError (a RuntimeException):
| Error | When |
|---|---|
UnknownTenant |
facade dispatched to a tenant with no registered client |
InvalidAgs |
the given string is not a well-formed 8-digit AGS (raised by the Ags smart constructors) |
RecipientNotInDvdv |
DVDV has no service registered for the recipient key + service URI |
RecipientCertMissing |
the service description has no cipher certificate for the element in kind |
ServiceElementMissing |
the OSCI_ADDRESSEE / OSCI_INTERMEDIARY element is absent or carries no serviceElementUri |
OsciTransport |
osci-bibliothek transport / IO failure |
OsciResponse |
OSCI returned an error (9xxx) code — as feedback rows or as a SOAP fault; carries the messageId when one was already issued |
NoSuchMessage |
fetch(messageId) found no content for that id |
MessageIdMismatch |
fetch(messageId) got a response whose message id names a different delivery |
UnsignedContent |
received content carries no content signature and contentSignatures = Require |
InvalidContentSignature |
the content signature on received content failed verification (raised regardless of policy) |
Certificate |
cert / key decoding failure |
Config |
bad configuration (invalid URI, unknown cert type, non-positive fetchLimit, illegal argument/state raised by the OSCI library, …) |
OSCI-Transport 1.2 classifies feedback (Rückmeldungen) by the first digit of the four-digit code:
| Class | Meaning | Handling |
|---|---|---|
0xxx |
success | normal result |
3xxx |
warning — the request was executed | tolerated; surfaced as OsciFeedback(code, text) in OsciResponse.warnings, OsciReceipt.warnings, Laufzettel.warnings, PendingPage.warnings and OsciMessage.warnings |
9xxx |
error — the request was not executed | raised as OsciError.OsciResponse (with the intermediary's messageId when one was already issued); the code is also extracted when the intermediary reports it as a SOAP fault |
All feedback rows are inspected, so an error behind a per-language duplicate
row fails too. A common warning is 3802 ("Signatur des Empfängers über die
Annahme- bzw. Bearbeitungsantwort fehlt"): the recipient's gateway answered
without signing its response. The response payload is still delivered — only
the cryptographic proof over the recipient's answer is missing, which the
warnings list lets you log or escalate per your own policy. The author's
signature over the content itself is verified separately — see
Content signature verification.
The configured client certificate is used only to prove possession:
client_assertion JWT with it (RS256). It is not
installed as a TLS client certificate — DVDV2 verifies possession via the
signed JWT, not mTLS. Server TLS is verified against the JVM's default
truststore.PKCS12Signer / PKCS12Decrypter).The same PKCS12 therefore serves both, which is why the CertManager
alias-keyed constructors wire one credential into both clients.
sbt clean compile
sbt test # all modules
sbt dvdv/test # one module
Test / fork := true is set per module. Test fixtures
(src/test/resources/test-cert.p12, test-cert.pem, test-key.pem) are
generated with:
openssl req -x509 -newkey rsa:2048 -keyout test-key.pem -out test-cert.pem \
-days 3650 -nodes -subj "/CN=zustellix-test"
openssl pkcs12 -export -inkey test-key.pem -in test-cert.pem \
-out test-cert.p12 -password pass:test
Licensed under the Apache License, Version 2.0.
Built and maintained by Sven — need it in production, extended or reviewed?
GitHub ↗Hire the author →