Skip to content
Architecture and development guide

Architecture and development guide

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

MicroB2B is a B2B message gateway: it receives, routes and delivers business messages between organizations and their trading partners. AS2 is the B2B protocol implemented today, with file system and HTTP POST integration on the other side; protocols are plugin modules, so more (AS4, ebXML) can be added without touching the core.

Rules

This is a green field project and we do not care about backwards compatibility AT ALL:

  • Never deprecate things for compatibility, delete them.
  • Never keep old function or table names and forward them to new implementations, just rename things if they need new names.
  • Do not take backwards compatibility into account at all.
  • This project uses ‘just’, not ‘make’

There are no deployments and no production data. None. Anywhere. The project is pre-release. This is not a hedge or an “as far as we know” — treat it as fact when weighing a change:

  • Never write a data migration to preserve existing rows. There are no existing rows. Change the schema and reset the database.
  • Never soften validation, decoding or a type constraint to tolerate values that are already stored somewhere. Nothing is already stored anywhere.
  • Prefer the strict, correct behaviour every time, even when it would be a breaking change for a hypothetical existing user. That user does not exist.
  • Do not reason about “pre-existing invalid data” as a risk. If invalid data can reach a code path, fix the path that let it in.

The payload is opaque. MicroB2B moves business documents and never reads them: no parsing, validation, mapping, transformation or field extraction of EDIFACT, X12, UBL, XML or any other document format, and no workflow or orchestration engine. A protocol’s envelope (AS2’s MIME, CMS and headers) is the protocol’s and is read; the document inside is stored, hashed and delivered as bytes. Whatever needs a document’s meaning (an invoice number, an order reference) is supplied as metadata by the system that understands the document, never extracted by the gateway. A feature that would need to look inside a payload is out of scope, whatever its benefit.

Prefer compile-time safety over run time type assertions. If you are considering a type assertion, reconsider. It’s not completely against the rules to use interface{} or any, but it should be avoided as much as possible. Instead, use more verbose code, code generation, whatever makes the most sense.

Domain string types (as2.EncryptionAlgorithm, as2.MICAlgorithm, as2.MdnMode, persistence.MessageStatus, …) should be validated at every boundary rather than cast. Each gets a ParseX function over a canonical constant slice, and UnmarshalJSON/MarshalJSON delegating to it, so no JSON entry point can admit a value the domain rejects. UI forms render their options from the same slice, so what is offered and what is accepted cannot drift apart. A type that is stored in a column also implements sql.Scanner and driver.Valuer over its ParseX (internal/dbvalue: Scan, Value, Optional where "" means none), so a persistence model holds the domain type itself and the database neither takes nor returns a value the domain refuses.

Docs and comments describe how the system is, not how it got there: no “used to”, “no longer”, “previously”, and no issue numbers as backstory. internal/conventions fails on the plainest of these phrases in production comments, CLAUDE.md and docs/*.md.

Issue tracker

Issues, milestones and pull requests live on Gitea at git.dries.info (repository microb2b/microb2b). The tea CLI is installed and logged in as claude: tea issues list --state open, tea issues <index> (one issue with its text), tea milestones list, tea pulls.

Lint and format

  • golangci-lint (v2, configured by .golangci.yml) is not on PATH; just lint runs $(go env GOPATH)/bin/golangci-lint run ./... and again with --build-tags developer, so neither build variant rots. The tree is clean (zero findings) in both, so just lint is the gate; keep it at zero. A finding is fixed, or excluded with a reason in .golangci.yml (a rule for a class of code) or a //nolint:<linter> // reason (one line). CI does not lint.
  • Never invoke bare gofmt. A stale /usr/bin/gofmt may shadow the toolchain’s on PATH, and an older one fails to parse Go 1.27 generic methods with method must have no type parameters — which looks like a compile error in your code but is not. This project is on Go 1.27 and uses generic methods. Use just fmt / go fmt ./..., which resolve through the active toolchain, or $(go env GOROOT)/bin/gofmt if you need the binary directly.

Build & Development Commands

just generate          # go generate ./...: rpcgen's RPC clients/servers and uigen's forms (build and container run it first)
just build             # target/microb2b, the DEVELOPER build (-tags developer), version from git describe
just build-release     # linux/windows/darwin amd64 release binaries in target/ (never the developer tag)
just container         # developer container image microb2b:latest (container-release: the release image microb2b:release)
just compose-up        # the distributed stack of docker-compose.yml, after `just container` (`just compose-down` removes it and its volumes)
just run               # standalone with --reset-db and test/data fixtures, in target/ (macOS: also codesigns and opens the firewall)
just test              # go test -tags developer -v ./...   (test-short: -short, test-race: -race, test-coverage: coverage.html)
just test-release      # go test -v ./... without the tag: the release variant, and the check that its binary holds no developer code
just test-playwright   # Playwright browser tests of the web UI (test/playwright): builds the developer binary, starts it on free ports in a temp dir, runs the suite, stops it; arguments go to `playwright test`
just test-load        # load test (test/selftest-load): two standalone developer instances on free ports exchange N AS2 messages per protection profile, verified against both databases and the written files; flags go to the tool (`-n`, `-size-kib`, `-profiles`, ...)
just fmt / just vet    # go fmt / go vet (vet covers both variants)
just sql               # sqlite3 shell on target/microb2b.db (list-messages: SELECT * FROM messages)
just site-serve        # the website and documentation (site/, Hugo) at http://localhost:1313; `just site` builds it into site/public
just site-screenshots  # take docs/screenshots again from two demo instances (test/site-capture)

go test -v ./internal/protocol/as2 -run TestReceiver   # a single test

The justfile has recipes for Linux, macOS and Windows; just --list shows them all.

Build variants: release and developer

The binary comes in two compile-time variants, chosen with the Go build tag developer (//go:build developer / //go:build !developer). There is no environment variable, flag, setting or request that switches between them, and the release variant does not contain the developer code at all.

  • Release (no tag): one authenticator, auth.Login, with every way in at once: MicroB2B’s own users with a password and TOTP (always), and every enabled OpenID Connect provider configured in the UI or REST API (persistence.OIDCProvider, see “OIDC login”). It fails closed: auth.Select refuses to start the API server (the process exits non-zero, naming each bad variable) unless PUBLIC_URL and SESSION_KEY/_FILE are set and valid. Without providers and without users it starts with the password form alone; the first user is made with microb2b users create, who then adds the providers. No variable, flag or request disables authentication.
  • Developer (-tags developer): the same auth.Login plus a “Sign in as developer” button on the login page (POST /ui/login/developer, bound to the login form’s token) that signs in as the user record developer (created by the first press, enabled, password login off: an ordinary user on the Users page and in the audit log); the REST API accepts every request without a token, as developer; a loud startup warning, and every page shows the banner “Developer build: anybody can sign in as the developer”. It needs no authentication configuration: PUBLIC_URL and SESSION_KEY are optional (without PUBLIC_URL setup links are paths; without SESSION_KEY its cookies are sealed with a fixed developer session key, developerAccess.sessionKey, so a session outlives a restart); providers are offered only when PUBLIC_URL is set. The developer pieces (button, open API, banner) are internal/auth/developer.go, hooked in through the developerDoor interface select_developer.go passes to newLogin (release passes nil); TestReleaseBinaryContainsNoDeveloperCode lists auth.developerAccess, auth.newDeveloperAccess, auth.developerIdentity and the banner text. --reset-db and --fixtures exist only here (cmd/microb2b/developer.go). Its API server listens on loopback only (127.0.0.1, and ::1 where the host has it) unless DEVELOPER_LISTEN_ADDRESS names another IP address (internal/api/plugin/listen_developer.go replaces the untagged listenAddresses, which listens on every interface; config.LoadDeveloperListenAddress is in a developer-tagged file). The compose stack sets it to 0.0.0.0 for its apiserver and publishes every port on the host’s 127.0.0.1. An AS2 receiver’s address stays its instance’s Bind (partners must reach it). --pprof <ip:port> (cmd/microb2b/developer_pprof.go, registered through commandOptions on standalone and persistence) serves net/http/pprof (CPU, heap, allocs, goroutine, mutex and block profiles) on a listener of its own, never on the API server: only a loopback IP address is accepted (a host name or any other address stops the start), nothing is served unless the flag is given, and the flag also switches on mutex and block sampling. Every process of a standalone run takes its own address.
  • Everything that runs the application during development uses the developer build: just build/run/test/container, the interop and matrix scripts, the CI workflows, the compose stack, test/graceful-shutdown.sh (just test-shutdown). just build-release and container-release never do. A script that calls the REST API needs no key against it.
  • Developer-only code goes in a file guarded by //go:build developer and hooks in through a variable the untagged code declares (commandOptions, schemaInitializer, fixtureInitializer), never through an if on a runtime value. Both variants must build, vet, lint and pass go test -short ./...; CI builds and tests both. TestReleaseBinaryContainsNoDeveloperCode (cmd/microb2b, skipped with -short) builds both binaries and fails if the release one has a developer symbol or string (go tool nm): a new developer symbol goes into its list.

Website and documentation

The site at https://microb2b.app is site/ (Hugo, Hextra theme as a Hugo module, its own go.mod), published to Cloudflare Pages by .gitea/workflows/site.yml (switched off with if: false until the Cloudflare project exists); site/README.md describes it. The documentation’s text is docs/*.md, read in place: site/data/docs.yaml lists every file (an unlisted one stops the build) and orders the sidebar, internal: true puts a design note under Internals, and this file is published there as the architecture guide. Links between documents are plain other.md#anchor links (they work on Gitea, and the site’s render hook resolves them, failing the build on a dead one). A screenshot is ![...](screenshots/<name>-light.webp) on a line of its own; just site-screenshots takes every shot of test/site-capture/capture.mjs in both themes, so a changed page is photographed again, not edited. /api/ renders the OpenAPI document just site-openapi takes from a running developer build (not committed).

Architecture

Deployment Modes

  1. Standalone (microb2b standalone, just run): every module’s plugins, the persistence and the API server in one process, with an embedded NATS server.
  2. Distributed: one process per component, connected through an external NATS server named by the global --nats <url> flag: microb2b --nats <url> persistence (the database, served to the others over RPC), microb2b --nats <url> <plugin type> [instance name] for each plugin (apiserver, as2-receiver, …). See docker-compose.yml (just container, then just compose-up; every plugin process reads the payload storage from the global settings over the persistence RPC, as standalone reads them from the database).

Every process has one lifecycle, process.Run (internal/process): it attaches the log tail and alerts to the process’s dispatcher, starts what the process runs, waits (process.WaitForSignal: SIGTERM/SIGINT), stops it within process.ShutdownTimeout (30 s) and closes the dispatcher last. plugin.Manager.StopAll first calls Quiesce() on every plugin that implements the optional plugin.Quiescer (unsubscribe, claim nothing more, non-blocking), so no target claims new work while an earlier one drains, then stops the plugins in reverse start order, each within plugin.PluginStopTimeout (10 s). nats.Dispatcher owns a base context that Close cancels; a subscription handler’s context derives from it (a backstop: the plugins end their work in their own stop). process.ConnectNATS is the distributed processes’ connection. main loads the configuration, sets up logging and telemetry once for every command and shuts telemetry down after the command ended; every command logs the registered modules (module.Log) before it runs.

--reset-db (drop and recreate the schema) and --fixtures <file> exist only in the developer build (see “Build variants”).

Core Components

Dispatcher (message bus)

  • internal/dispatcher/: pub/sub over NATS (nats.NewInprocessDispatcher() for standalone, nats.NewNatsClientDispatcher() for distributed), with W3C trace context propagated in message headers.
  • Sources publish nothing on the bus. A source stores its message and dispatches it in the database (below); the only message-flow subject is the wake-up delivery.<target instance> (persistence.DeliverySubject), published after the dispatch commits and carrying only the delivery’s identity. A target subscribes to its own wake-up subject through delivery.Worker. Always build subjects with that helper, never by hand; party names contain no ., and * is only valid in a route.
  • Request/reply is dispatcher.Requestor.Request and dispatcher.Responder.Respond (the NATS dispatcher implements both; the handler’s context carries the requester’s trace, log attributes and deadline, a panic is answered empty, and no responder is dispatcher.ErrNoResponder). Both are in plugin.DiscoveryDependencies (Requestor, Responder) in standalone and in every plugin process; the persistence RPC is generated separately (rpcgen).
  • Example: the AS2 receiver stores an inbound message and dispatches it; the dispatch inserts a queued delivery for each target whose route matches, a file writer’s worker is woken on delivery.<writer instance>, claims the delivery and writes the payload. Outbound, a file poller dispatches and an AS2 sender’s worker delivers. A wake-up is lossy: the sweep wakes a delivery again, and a target takes up its due deliveries when it starts.

Persistence

  • internal/persistence/: persistence.Persistence holds the core’s data (messages and their history, routes, organizations, partners, certificates, plugin configs); each module has its own interface (as2.Persistence, …) and a PersistenceAdapter combining both. Plugins and the API server use only these interfaces, so they run unchanged against Bun directly (standalone) or the rpcgen RPC clients (distributed).
  • A Bun model is not the domain record, and converts to it by a Go conversion, not a field-by-field copy. The domain types carry no bun tags; a table’s model embeds an unexported struct with the record’s fields in its order and of its types plus the bun tags (receiverColumns, deliveryColumns, partyColumns, a settings group with embed:<group>_), and the two convert into each other (as2.ReceiverConfig(row.receiverColumns), receiverColumns(*config)): a field added to one and not the other is a compile error. Columns the domain lacks (bun.BaseModel, AuditableModel, a certificate’s fingerprint) sit beside the embedded struct; a field the domain has and the table does not is bun:"-" (Message.Origin). Only a row whose shape really differs (JSON columns, pointers, a nullable string) is converted by hand. A value whose Value refuses it is not written: Bun puts the error into the SQL as ?!(<error>), the database refuses the statement, and the query hook logs the reason as refused_value.
  • SQLite (default, fixed at one connection) or PostgreSQL through Bun. Each persistence plugin owns its tables (internal/persistence/bun/, internal/protocol/<module>/bun/), declared once as a persistenceBun.Schema (Models, Indexes; the core’s is CoreSchema, each module’s bun package has var Schema) whose Create (create-if-not-exists, at startup), Reset (--reset-db, tests) and Register read the same list. There are no migrations: a changed model means resetting the database.
  • A module’s persistence plugin is a plugin.ModulePersistence[P] (its module name, Schema, Local over the database, Remote the RPC client, Serve the RPC server), declared once as <module>/plugin.Persistence; its plugins, UI and REST open the module’s persistence with Persistence.Open(deps), and a plugin type whose instances are the enabled configurations is discovered by plugin.NamesDiscoverer(Persistence, P.GetEnabled...Names).
  • A duplicate is persistence.ErrDuplicate. Every bun insert or update of a record with a unique name passes its error through bun.UniqueViolation (internal/persistence/bun/duplicate.go, the one place that knows the drivers’ errors: Postgres 23505, SQLite’s “UNIQUE constraint failed”). Handlers and screens answer 409 on errors.Is(err, persistence.ErrDuplicate) and never import a driver.

Deliveries (dispatch, claims, the worker)

  • The persistence decides routing and tracks each target’s progress (design: docs/message-lifecycle.md, the operator’s view docs/protocol-agnostic-routing.md; the file poller and the AS2 receiver dispatch, the AS2 sender, file writer and HTTP poster are delivery.Deliverers). Persistence.InsertAndDispatchMessage / DispatchMessage match the routes of a message once, in Go (persistence.MatchRoutes over the route cache), and in one transaction insert one queued delivery (deliveries, unique per message and target instance) per distinct target, move the message to the stage dispatched (settled, status NotRouted, with one message_not_routed alert, when nothing matched) and write the history. A dispatch happens once: the message leaves the stage accepted by a conditional update, and DispatchMessage on any other stage changes nothing and returns the deliveries. A target name persistence.DeliverySubject refuses fails the dispatch with ErrInvalidTarget. RefuseMessage(id, refusal, reason) settles an accepted message with no deliveries: RefusalPartner ends Refused (no alert), RefusalInternal ends Error (message_failed).
  • Vocabulary: a message has a persistence.MessageStage (accepted: its source owns it; dispatched; settled) and a persistence.MessageStatus (Received, InProgress, Retrying, AwaitingReceipt, Stalled, Delivered, NotRouted, Refused, Error); a delivery has a persistence.DeliveryState (queued, delivering, retrying, awaiting_ack, delivered, failed, stalled; Final(), Redeliverable(), Tone()). Each is a domain string with a canonical slice, Parse* and validating JSON. The status is stored and derived, never set by a plugin; the first match of DeriveStatus(stage, refusal, deliveries) wins: accepted is Received; no deliveries is Refused (RefusalPartner), Error (RefusalInternal) or NotRouted; then any failed is Error, stalled Stalled, retrying Retrying, queued/delivering InProgress, awaiting_ack AwaitingReceipt, else Delivered. The core names no protocol, so “MDN” appears only in AS2’s own panel.
  • An operator’s message (Persistence.InsertAndDeliverMessage(msg, payloads, target), storage.Payloads.InsertAndDeliver) is stored and dispatched to the one named target without matching the routes, in one transaction, with SourcePlugin persistence.OperatorSource (@operator, outside the instance-name pattern, so no route names it); afterwards it is like any message (a Redispatch matches the routes for it as usual). Its callers are the AS2 test message and a message sent by hand.
  • A message sent by hand (internal/api/resource/send_message.go; page /ui/messages/send, “Send Message” in the nav below Messages, icon compose; REST POST /api/v1/messages, multipart/form-data with from, to, optional target and file, 201 with the message detail): both bind SendMessageForm (party pickers without Any, an optional Pick of the enabled route targets) over the multipart values, then SendMessage.Resolve (one party an organization and the other a partner, which gives the direction; an empty target is the one the routes from From to To name whatever their source, persistence.RouteTargets, else a failure on target), ReadUpload (one non-empty file of at most MaxSendMessageSize, 64 MiB; requestBodyLimit allows MaxSendMessageRequestSize on both paths) and SendByHand (storage.Payloads.InsertAndDeliver, source @operator, logged as operator action with action send). A refusal is 400: INVALID_INPUT keyed by field over REST, the page again with the field errors.
  • Every transition runs bunPersistence.transact: the conditional write, the delivery_changed history line and recomputeStatus (the message’s stage and status from persistence.DeriveStatus over its deliveries; nothing else writes them, TestStoredStatusIsTheDerivedStatus holds it; it writes the message only when its stage, status or error changed, and returns the message as it now is, so a caller never reads it again) are one transaction. A conditional update of a delivery reads the row back with the update itself (updateReturning, UPDATE ... RETURNING), never with a second SELECT; TestAClaimMakesNoQueryItDoesNotNeed pins the claim’s queries. What must happen after the commit is collected in effects and done by finish: wake-ups through the delivery notifier (bun.WithDeliveryNotifier(persistence.PublishWakeups(disp)), wired in standalone and in the persistence process’s RPC server), history announcements, message_failed, counters. Never publish or raise inside the transaction (SQLite’s single connection would wait for it).
  • A target claims before it works: ClaimDelivery is a compare-and-swap with a lease (queued; retrying when its next attempt has come or the sweep has woken it, retryIsDue; stalled only with fromStalled), CompleteDelivery(id, token, outcome) / ReleaseDelivery are conditional on delivering and the claim token (persistence.ErrClaimLost otherwise), SettleDelivery applies an out-of-band receipt to delivering, awaiting_ack or retrying and never downgrades (the first failure’s reason stands). persistence.DeliveryOutcome (delivered|awaiting_ack|retry|failed, Check) is what a target reports; last-error texts are bounded like history text and never hold a URL, header or payload.
  • Route cache (internal/persistence/bun/routes_cache.go): the whole route table, loaded lazily, dropped by this persistence’s CreateRoute/UpdateRoute/DeleteRoute after the write, by a persistence.RouteChange on events.routes from another persistence (bun.WithRouteChanges(disp), standalone and persistence process) and after persistence.RouteCacheTTL (30 s).
  • internal/delivery (core, names no module): delivery.Worker[T] works the jobs of a delivery.Store[T] (Claim with a lease, Complete, Release, ListDue; every write conditional, persistence.ErrClaimLost otherwise) with a delivery.Deliverer[T] (Deliver(ctx, T) Outcome, outcomes from delivery.Delivered/AwaitingAck/Retry/Failed), configured by delivery.Config[T] (name, store, deliverer, optional Wakeups subject and decoder, Annotate for log attributes, Options). It learns of jobs from its wake-ups, a look at the store at Start, and, with Options.PollInterval, a look at that interval and on Poke; RunOnce works off what is due and returns when it is recorded (tests). A new kind of job (another protocol’s receipts, the notifier’s notifications) is a store and a deliverer, never a second loop. A target plugin instance’s worker is delivery.NewTargetWorker over its deliveries (Deliverer[delivery.Attempt]): it subscribes to delivery.<instance> (persistence.DeliverySubject), takes a slot of Options.MaxConcurrent per wake-up, claims, runs the attempt under its timeout (the lease is twice that plus a minute) and records the outcome on a context that is not cancelled. The worker keeps no timer: a recorded Retry is woken by the sweep when it is due. At Start it takes up the target’s due deliveries (ListDueDeliveries: queued, retrying and due or woken, stalled). Quiesce unsubscribes and claims nothing more; Stop(ctx) waits for running attempts for DrainGrace (5 s) or until 2 s before ctx ends, then cancels the worker’s lifecycle context so stuck I/O aborts, and waits for the attempts to return. An attempt context is context.WithoutCancel(handler ctx) (keeps message id, delivery id, trace) with a cancel tied to the lifecycle and AttemptTimeout; as2.sendMessage and every other I/O of a target use it. A delivery the stop interrupted is given back with ReleaseDelivery on a context that is not cancelled (2 s): queued when the attempt had not started its I/O, retrying and due now when it had; a stop fails nothing. The target plugins implement plugin.Quiescer through Worker.Quiesce.
  • The sweep (persistence.Sweeper, implemented by the Bun persistence only, internal/persistence/bun/sweep.go; the loop is delivery.StartSweep, every persistence.SweepInterval = 5 s with one pass at start, not a plugin and not on the Services page) runs in the process that owns the database: standalone starts it after manager.StartAll (so its first pass finds the targets subscribed) and stops it before the plugins; the persistence process starts it beside its RPC servers with bun.NewSweeper. One pass, each step in batches of persistence.SweepBatch = 500, one transaction per batch with writes conditional on the state read (two sweepers cannot wake a delivery twice; effects after the commit): expired leases (delivering, next_dispatch_at <= now) become retrying due now with the history line “attempt interrupted: lease expired”; due queued/retrying deliveries get dispatch_count + 1, next_dispatch_at = now + persistence.DispatchBackoff(count) and a wake-up, or, at Settings.Delivery.MaxDispatches (read once at start), become stalled (“not claimed by after wake-ups”) with one delivery_stalled alert (subject the message, details target/wake_ups/from/to, dedupe key delivery_stalled:<target>); awaiting_ack past its deadline (DeliveryOutcome.Deadline, set by the target) becomes failed (“no receipt within ”), after which a late receipt is refused like any for a settled delivery. A retrying delivery the sweep woke is claimable although its next_dispatch_at now holds the next back-off step (dispatch_count > 0). Tests drive Sweep(ctx, now, limits) with the persistence’s injected clock (WithClock) and the worker’s Options.Now.
  • Redispatch and redeliver are the operator’s two actions (bun/actions.go, one transaction each through transact, so the wake-ups follow the commit): Persistence.Redispatch(ctx, messageID) matches the routes again for any message that left the stage accepted (persistence.CanRedispatch), inserts the deliveries of targets it has none for, puts queued and stalled ones back to queued (no attempts, wake-up count 0, last_error cleared) and leaves delivered, failed, retrying, delivering and awaiting ones alone; a call that changes nothing succeeds with changed false and writes no history (REST 200 unchanged, the UI flash says nothing was to redispatch); Persistence.Redeliver(ctx, deliveryID) does that for one failed, stalled, retrying or delivered delivery (DeliveryState.Redeliverable; sending a delivered one again is allowed for every target type because the file or the downstream copy may be lost, and each requeue counts in Delivery.Requeues, which the file writer reads to accept a file holding exactly the payload as its write). The page words a delivered delivery’s button “Send again to ” and confirms with the generic warning or the one the target’s module contributes (webui.Extension.SendAgainWarning; AS2: the partner treats it as a repeat with the same Message-ID, may answer with the stored receipt or none, and without a receipt the delivery fails at the receipt deadline). Both write redispatch_requested / redeliver_requested (the user of the context, logctx.User, in the text; the latter with delivery_id and target), then the delivery_changed lines and the recomputed status, and return the message. A refusal is *persistence.NotRedispatchableError{Stage} (only for a message still accepted) / *persistence.NotRedeliverableError{State} (codes not_redispatchable / not_redeliverable in ErrorVocabulary; Allowed() is what would have been accepted), a missing record sql.ErrNoRows; persistence.FindDelivery checks that a delivery belongs to the message of a request. Each logs operator action at INFO (action redispatch/redeliver; user, message_id, delivery_id come from the context). They are not audit events: the audit log records configuration, and a message’s history already names the operator beside what happened to the message.
  • Metrics: microb2b_delivery_transitions_total{state}, microb2b_delivery_wakeups_total{source}, microb2b_delivery_wakeups_failed_total, microb2b_delivery_claim_conflicts_total{target}; the sweep’s gauges microb2b_deliveries{state} and microb2b_deliveries_due and counter microb2b_deliveries_stalled_total{target}.

Storage

  • internal/storage/: payload blobs, separate from metadata, addressed by message id and name (StoreBlob(id, name, data), GetBlob); LocalFileStorage or S3Storage, built by storage.NewStorage(ctx, persistence.StorageSettings) from the global settings (see “Global settings”). Every process calls storage.Open once at startup (standalone over the database, a plugin process over the persistence RPC) and keeps that storage until it restarts; RuntimeDependencies.PayloadStorageSettings are the settings it runs with. S3 credentials are never settings: each process takes them from the AWS SDK credential chain (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, shared files, a role). storage.Probe (the “Test storage” action) stores, reads back and deletes a probe blob with given settings, bounded by ProbeTimeout, reporting no credential.
  • Message content goes through storage.Payloads, by message id and persistence.PayloadStage (unpacked = the message’s own payload, received, headers, the composed message = headers + received, and receipt = the receipt exchanged for it as it traveled, for AS2 the MDN). The message page’s stage selector downloads any stage it has (GET /ui/messages/{id}/payload?stage=), REST GET /api/v1/messages/{id}/payload?stage= (persistence.PayloadFileName names both). A target’s “Payload Stage” offers persistence.DeliverablePayloadStageOptions (every stage but receipt, which is recorded after the dispatch; PayloadStage.Deliverable). A new message is kept with Payloads.Insert: its blobs are written first, then the message and its payload records are inserted in one transaction (InsertMessage), and on any failure the blobs already written are deleted (Storage.DeleteBlob), so a message that was not inserted leaves no record and no blob. A stage of an existing message is added with Store, which writes the blob and records it (message_payloads, RecordMessagePayload, refused for a message that does not exist; the blob is deleted when it cannot be recorded). Load/Available go by that record, never by probing storage, and an unrecorded stage is a *storage.StageNotRecordedError. A consumer (file writer, HTTP poster) reads the stage it is configured with and fails loudly when the message lacks it.

Certificates

  • X.509 certificates (optionally with a private key) belong to the core: persistence.Certificate (internal/persistence/certificate.go; NewCertificate checks a key against its certificate), the certificates table, and the certificate methods of persistence.Persistence (a missing certificate is sql.ErrNoRows, like every missing record). Uploads (PEM, DER, PKCS#12) are read by internal/certimport, shared by the UI (/ui/certificates) and REST (/api/v1/certificates).
  • A certificate is identified by its SHA-256 fingerprint (unique column): the same certificate is stored once, with or without a key. CreateCertificate returns *persistence.CertificateExistsError (matches ErrCertificateExists, names the stored one); REST answers 409 CERTIFICATE_EXISTS, the UI re-renders the upload page with status 409 and the same message. Both go through persistence.StoreCertificates, which stores an upload all or nothing. Certificates are audited (kind certificate): an event records fingerprint, description and has_private_key (certificateAudit, which has no field for the key or the bytes), and an upload’s events are written in the transaction of its certificates (Persistence.CreateCertificates).
  • The private key is never held in the clear (see “Secrets”): Certificate.PrivateKey is a persistence.SealedPrivateKey (Bytes, an encrypted PKCS#8 PEM, columns private_key_bytes/private_key_kid), encrypted by NewCertificate(description, cert, key, keys) at upload, refused in the clear by DeriveFields (so by every write), carried over the persistence RPC encrypted, and decrypted only where it is used by Certificate.Signer(keys), which also checks it belongs to the certificate and returns a crypto.Signer (an RSA one is a crypto.Decrypter). A response is made of Certificate.Public(); Details(now, days, keys) says whether the stored key matches.
  • A module refers to a certificate by id and loads it through persistence.Persistence; it never stores certificates itself. AS2 reads each certificate once per message: its loaders (certificates.go) go through a cache that lives in the context of one message’s work (withCertificateCache, set by the receiver and the MDN receiver per request, the sender and the async MDN sender per attempt, the connection check per check), never across messages, so a replaced certificate is used from the next message on.
  • A certificate a record uses cannot be deleted. The references are plain id columns, no foreign keys or link tables (the tables are small, a delete is rare): a module whose records name certificates declares how in its persistence plugin, plugin.ModulePersistence.Certificates, a persistenceBun.CertificateReferences (func(ctx, bun.IDB, certificateID) ([]persistence.CertificateUser, error): kind, name, field labels, the UI page that changes the record). plugin.CertificateReferences() collects every registered plugin’s and the core persistence gets them with persistenceBun.WithCertificateReferences (standalone, the persistence process’s RPC server, testutil.SetupTestDB); the core names no module. DeleteCertificate runs them and the delete in one transaction (serializable on PostgreSQL) and refuses with *persistence.CertificateInUseError (matches ErrCertificateInUse, in the rpcerr vocabulary; at most CertificateUsersShown users, Total counts all): REST 409 CERTIFICATE_IN_USE with details.users/details.total, the UI the certificate page again with 409 and the same message (for htmx: the message as flash and HX-Refresh). CertificateUsage is the same lookup for the page’s Used by list. AS2 declares every certificate field of its local and remote profiles and its receivers (as2.LocalProfile.CertificateFields, OverridableProfileParameters.CertificateFields, ReceiverConfig.CertificateFields; as2/bun.CertificateReferences); its test finds the fields in the forms’ specs (formspec.Inspect: a Ref/Refs with a certificate source), so a new certificate field without a declaration fails. The other modules name no certificate.
  • internal/certmonitor is an application-wide plugin (certificate-monitor) that logs expired and expiring certificates hourly and reports them in the microb2b_certificates_expiry gauge.

Version

  • internal/version is the one source of the version string (version.String(), IsRelease(), Display()). Order: the ldflags value (-X microb2b/internal/version.Version=..., which every justfile recipe building a shipped binary passes from git describe --tags --always --dirty) unless it is the placeholder dev; else the embedded Go build info (a tagged module version, else the 7-character vcs.revision, -dirty when modified; a plain go build in a git checkout has it, go test binaries do not); else dev. The footer of every page behind the login ({{appVersion}} template function, so no page can omit it; the login page, which anybody can fetch, names the product only), the startup log and the default of OTEL_SERVICE_VERSION all use it; /health deliberately names no version

Global settings

  • What is global, edited at run time and has no better owner is a global setting (persistence.Settings, one row of the core’s table system_settings, id bun.SettingsRowID): the payload storage (StorageSettings: backend local/s3, a domain string with ParseStorageBackend, directory, S3 bucket, region, endpoint), the delivery policy (DeliverySettings.MaxDispatches, 1 to 20, default 5: the wake-ups a delivery nobody claims gets before it is stalled; the back-off between them is the constant persistence.DispatchBackoff) and the identification (IdentificationSettings.HideVersion, default false). How MicroB2B names itself is version.Product(): MicroB2B/<version>, or MicroB2B after version.HideVersion(true), which every process calls once at startup with the setting (beside storage.Open, which returns the settings it read). It is the User-Agent of every outgoing request (AS2 sender, async MDN sender, HTTP poster, notifier) and the Server of the AS2 receiver’s answers (as2.AnnounceServer wraps the receiver’s whole mux); a replayed stored receipt drops both from its stored headers (receiptHeaders). Our MDNs name it in their Reporting-UA field too (as2.ReportingUA(our AS2 ID)); a partner’s MDN’s Reporting-UA (as2.ParseReportingUA: control characters to spaces, at most MaxReportingUALength bytes) is recorded only for an opened MDN (MDNRecord.ReportingUA, as2_messages.mdn_reporting_ua) and shown as “Partner software” and protocols.as2.mdn.reporting_ua. The form has a “General” section for the global choices (the backend) and a section per backend for its own settings (“Local payload storage”, “S3 payload storage”), a “Delivery” and an “Identification” section. Deploy-time and bootstrap configuration (database, NATS, public URL and session key, OpenTelemetry, ports, log level) stays in environment variables: a process needs it before it can read the database. A module’s per-instance setting is a field of its instance configuration, not a global setting.
  • Persistence.GetSettings/SaveSettings (RPC generated; SaveSettings refuses what StorageSettings.Check refuses with ErrInvalidSettings, a vocabulary sentinel). A new database gets persistence.DefaultSettings() (local, ./payloads) from bun.SeedSettings, which runs after the fixtures, so a fixture’s SystemSettings row stands (and is checked like every fixture record).
  • A new database is seeded once (persistenceBun.SeedNewDatabase, in getInitializers of cmd/microb2b, so the standalone process and the persistence process both do it): a database with no system_settings row is new (the row is written with the database, after the seed, and never deleted), so a later start never seeds again and what a person deleted stays deleted; it works the same on SQLite and PostgreSQL. plugin.SeedDefaults runs PersistencePlugin.SeedDefaults of every registered persistence plugin: the core stores the local organization local, a module’s plugin.ModulePersistence.Defaults stores its default instances (AS2: as2_receiver_default, as2_sender_default; file: file_poller_default on data/outbox with {to}/{filename}, file_writer_default on data/inbox with {from}/{original_filename}; no AS2 local profile) and reports each as a plugin.DefaultInstance (Flow inbound/outbound, Role source/target); plugin.DefaultRoutes then routes every source of a flow to every target of it from persistence.RouteAny to RouteAny, so modules never name one another. Creates tolerate ErrDuplicate, so an interrupted seed can be repeated. A developer build with --fixtures skips the seed (the fixture defines the data). Creating a partner creates no directory (storage need not be a local file system); the file writer creates its own directories when it writes (CreateDirs). testutil.SetupTestDB seeds only the settings.
  • A change takes effect when a process restarts. Every process reads the settings once at startup; nothing hot-swaps. The page and GET /api/v1/settings show the storage the API server’s process runs with beside the saved one (restart_needed).
  • The form is resource.Settings (uigen -singleton: one record at /ui/settings, no identity/id; each group of settings an embedded struct and a section of SettingsGroups, JSON properties prefixed by the group, storage_*, delivery_*, identification_*), with the cross-field rule in ValidateForm (from StorageSettings.Check). UI: System > Settings (internal/api/ui/settings.go, settings.html, which embeds the generic form through webui_form_body), plus the “Test storage” action (POST /ui/settings/storage/test). REST: GET/PUT /api/v1/settings (a PUT decodes over the saved settings), POST /api/v1/settings/storage/test (200 with the probe’s outcome, ok false for a failed probe). A new group of settings: an embedded struct of resource.Settings with its section, fields in persistence.Settings and columns in systemSettings, and parity cases in internal/api/e2e_settings_test.go.

Secrets

  • What must stay secret is sealed with a master key that is not in the database (internal/secret; operator’s view “Secrets” in docs/deployment.md): SECRETS_KEY/_FILE (exactly 32 bytes, config.ParseSecretsConfig) and SECRETS_PREVIOUS_KEYS/_FILE (open, never seal). secret.Keyring: the key id (KeyID, first 4 bytes of HMAC-SHA256(key, “microb2b secret key id”), lowercase hex) names the key a value was sealed with; HKDF-SHA256 subkeys per purpose (microb2b secret v1, microb2b private key v1); Check() is the key check value. Every label, info string and format is fixed per version (a change is a new version).
  • Configuration values are secret.Sealed JSON documents ({"v":1,"k":..,"n":..,"c":..}, AES-256-GCM), AAD <table>.<column>\x00<scope>, the scope (the URL or Endpoint they are sent to, the notifier URL’s own: the instance name) recomputed from the row. Every write-only field (Secret, SecretLines sealed as one document, SecretURL) of a stored record is a part of the module’s secret.Record[T] (as2.Secrets, httppost.Secrets, notification.Secrets), and the module’s persistence is used through its sealing layer (as2.Sealing, …: seals on write, keeping the stored value when value and scope are unchanged, opens on read), applied by plugin.ModulePersistence.Open with DiscoveryDependencies.Secrets (ModulePersistence.Secrets, plugin.SealingOf); a module with secrets cannot be opened without a key (secret.ErrNoKey). Between the layer and the database the domain fields carry the sealed documents (a list as one element: Sealed.Lines, secret.CarriedLines), and the bun row stores them in secret.Sealed columns beside the embedded struct (the domain fields are bun:"-"), which refuse anything else, so a write that bypasses the layer stores nothing. testutil.AssertSealed (completeness against the struct’s generated WriteOnlyFields, canaries in a raw SELECT, rekey) and testutil.AssertSealingCovers (every interface method passing the record is the layer’s own) hold each module to it.
  • Who holds the key: the processes that seal or open (standalone, the API server, the AS2, HTTP poster and notifier processes; secret.Load, a release build without a key gets nil and a process that needs one stops). The persistence process never reads it: it stores and returns sealed values (so the RPC and NATS carry them sealed) and knows the sealed columns only to read key ids (bun.WithSealedColumns(plugin.SealedColumns()...)). The developer build falls back to a fixed developer key (internal/secret/developer.go, in TestReleaseBinaryContainsNoDeveloperCode’s lists); its fixtures write secrets in the clear and bun.InitFixtures seals them (sealFixture) before they are inserted. Tests use testutil.SecretKeys().
  • Startup check (persistence.CheckSecretsStore, standalone and every plugin process with a key): ClaimSecretsKeyCheck (the key check value in system_settings.secrets_key_check, claimed by a new database, taken over from a previous key in a rotation) must be the current key’s, and every key id SealedKeyIDs reports must be in the keyring. microb2b secrets rekey (bun.Rekey) seals every value not sealed with the current key again; not an audit event. A private key can be recovered with OpenSSL alone (docs/secrets-recovery.md; TestThePrivateKeyRecoveryProcedureWorks runs its commands).

Configuration & Fixtures

  • Environment variables (see below), read in internal/config; global settings in the database (above).
  • test/data/fixture.yaml defines the global settings (payloads in ./payloads, relative to the working directory), organizations, partners, certificates, AS2 profiles, plugin instances and routes for development; just run resets the database and loads it (in target/, so payloads go to target/payloads).

API Server

  • A plugin (internal/api/plugin/, type apiserver, a singleton); server in internal/api/, REST API under /api/v1 and web UI under /ui/.
  • internal/api/handlers/ serves the core’s own REST resources (organizations, partners, routes, certificates, identity providers, messages, events, stats, health) and the generic /api/v1/plugins/configs dispatch; internal/api/ui/ owns the page chrome and the core’s pages. Modules contribute the rest (see the extension sections below). Organizations, partners, routes, certificates and identity providers (handlers.OIDCProviders) are rest.RecordResources (handlers/records.go), see “Plugin REST extensions”.
  • Authentication is one abstraction, auth.Authenticator (internal/auth), which api.NewServer takes as a parameter: AuthenticateAPI (a /api/v1 request, a refusal is an *auth.Failure carrying the 401 code and message), AuthenticateSession (a /ui/ browser request; auth.ErrNoSession sends it to the login), Routes (the public login routes, GET /ui/login and whatever the flow needs, mounted on the router outside the authentication middleware; the host draws pages through auth.Pages), Logout (clears the session, says where to go next; POST /ui/logout stays under CSRF), Banner (a notice on every page, "" for none) and Accounts (auth.Accounts: the users.Service and RenewSession the Users and account pages use; nil for an authenticator without users, a test’s). auth.Pages draws LoginPage (one auth.LoginView: the password step or the code step, plus Providers (auth.LoginProvider: path and display name, one “Sign in with ” button per enabled provider) and DeveloperPath for the developer button) and SetupPage. middleware.Authenticate and ui.Authenticate put the auth.Identity (subject, display name) in the request context; the layout shows its display name. There is no authorization: an authenticated user is an administrator.
  • The variant is chosen by build tag, once, in the application wiring (auth.Select(ctx, auth.Dependencies{Store, Secrets}), called by internal/api/plugin): select_release.go (!developer) is auth.NewLogin(ctx, config.LoadAuthConfig(false), store, secrets, auth.LoginOptions{}), select_developer.go (developer) is newLogin(..., config.LoadAuthConfig(true), ..., newDeveloperAccess()). config.ParseAuthConfig(lookup, developer) gives AuthConfig{Session} (ParseSessionConfig(lookup, required = !developer)). auth.NewLogin(ctx, config.AuthConfig, auth.Store, *secret.Keyring, auth.LoginOptions{Now, OIDC auth.OIDCOptions}); auth.Store is users.Store plus auth.ProviderStore (ListOIDCProviders), so the login reads the users and the providers from the same persistence. Tests never use Select; they hand NewServer an authenticator, normally authtest.New() (bearer authtest.Token on the API, cookie authtest.SessionCookie on the UI, optional banner), or auth.NewLogin over a test database, so they need no build tag. No environment variable or request can make a release build skip authentication.
  • Cookies are chosen by build tag like the CSRF cookie (internal/auth/cookie_{release,developer}.go, exported auth.SessionCookie, FormCookie, OIDCCookie, SecureCookies): __Host-mb2b_session, __Host-mb2b_login (a password login in progress: the form’s token, its step, the name and the sealed outcome of the password, 10 minutes) and __Host-mb2b_oidc (a login at the provider in progress: state, nonce, verifier, return path, 10 minutes), Secure, in a release build; mb2b_session, mb2b_login, mb2b_oidc without Secure in the developer build (plain http, also to other machines). All are AES-256-GCM sealed (oidc_seal.go) with a key derived (HMAC-SHA256) from SESSION_KEY, the cookie’s purpose as authenticated data (a login cookie is no session), HttpOnly; SameSite=Lax; Path=/. Rotating the key signs everybody out.
  • Sessions (internal/auth/login.go): one session cookie for every way in, holding the user name, the user’s session generation, how it began (password, oidc:<provider name>, developer) and issued/expiry. AuthenticateSession checks it on every request against the user: exists, enabled, same generation, within SESSION_TTL. Disabling, any reset, a password or authenticator change, password login turned off and “sign out everywhere” raise the generation, so they end OIDC and developer sessions as well (the user’s own change renews the current one, Accounts.RenewSession). The identity’s subject is the user name, so it is the user of log lines and the audit actor for every way in.
  • OIDC login (internal/auth/oidc*.go, oidcMethod per provider, providers.go; built on github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 + go-jose; no hand-rolled JWT or crypto beyond sealing cookies). No refresh token and no server-side session are kept, the ID and access tokens of a login are dropped after the callback; what is stored is the user record of the identity.
    • Environment (internal/config/auth.go, config.SessionConfig from ParseSessionConfig: PUBLIC_URL, SESSION_KEY/_FILE, SESSION_TTL): strict, every problem reported at once naming its variable, the key (config.Secret, internal/config/secretvalues.go: redacted in logs/format/JSON, Reveal() is the one way out) never printed, SESSION_KEY has a _FILE variant (exactly one of the two). The public URL must be https except for loopback hosts and has no path. Nothing about a provider is an environment variable.
    • Providers are records, any number of them (persistence.OIDCProvider, internal/persistence/oidc_provider.go; core table oidc_providers, internal/persistence/bun/oidc_providers.go; persistence methods CreateOIDCProvider, GetOIDCProvider, ListOIDCProviders, UpdateOIDCProvider, DeleteOIDCProvider): id, name (identity pattern, unique; in its URLs and in the sessions it began), display name (the button: “Sign in with ”), enabled, issuer URL (unique; exactly as the discovery document names it; CheckOIDCIssuerURL: https, http only for a loopback host, no credentials, query or fragment), client id, client secret (write-only, sealed in oidc_providers.client_secret, persistence.OIDCClientSecretField, scope the issuer URL, so a changed issuer needs the secret again), scopes (space-separated, default persistence.DefaultOIDCScopes = openid profile email, CheckOIDCScopes: must include openid), API audience ("" = the client id, Audience()). Form resource.OIDCProviderSpec (internal/api/resource/forms.go, resource.OIDCProvider with ValidateForm and Seal(keys); secret.ErrNoKey without a key). UI: System > Users has an “Identity providers” button to /ui/oidc-providers (internal/api/ui/oidc_providers.go, oidc_providers_list.html: name, display name, status, issuer, redirect URI; a warning when PUBLIC_URL is not set), create and edit through the generated form as a webui.RecordScreen, delete in the edit page’s danger zone (the provider’s users stay and cannot sign in at it). REST /api/v1/oidc-providers (handlers.OIDCProviders, a rest.RecordResource, list key oidc_providers; client_secret required on create, kept by an update that leaves it out while issuer_url is unchanged, never in a response: OIDCProvider.WithoutSecret).
    • Changes count from the next sign-in, no restart (providers): the login reads the enabled providers on every use; a provider’s login (discovery, keys) is made at its first use and kept while its record’s updated_at is unchanged. No provider is offered without PUBLIC_URL (the redirect URIs are made of it). A provider that cannot be used (unreachable, issuer mismatch, no S256, missing endpoints, a secret that does not open) never stops startup: a WARN line, its button answers 502 “The identity provider cannot be reached”, password login and the other providers go on, and it is tried again after providerRetryAfter (30 s). A discovery is one attempt while the person waits (OIDCOptions.DiscoveryRetries, none by default; tests set it).
    • Web login (oidc_login.go): each enabled provider’s “Sign in with ” button, GET /ui/auth/{provider}/start?return_to= (sets the OIDC cookie, redirects to the IdP with PKCE S256, state, nonce), GET /ui/auth/{provider}/callback (exact redirect URI <PUBLIC_URL>/ui/auth/<name>/callback; takes the OIDC cookie once, constant-time state/nonce checks, code exchange with the PKCE verifier, ID token verified by go-oidc), then users.Service.SignInOIDC and a session via oidc:<name>. return_to is only ever a path under /ui/ (safeReturnTo: no scheme/host/backslash/dot segments, never the login or auth routes).
    • Provisioning (internal/users/oidc.go): the user is found by (issuer, subject) only (Persistence.FindUserByOIDC, unique index users_oidc_identity), never by name or e-mail, so a name at the IdP cannot take over a user. The first login creates one: name from preferred_username, else the e-mail’s local part, else user (lower case, every invalid character -), with -2, -3, … when taken; display name from name, preferred_username, email; enabled; password login off. A disabled user is refused at the callback (users.ErrUserDisabled, 403 “Your account is disabled in MicroB2B.”). An OIDC user has no second factor in MicroB2B (the provider’s).
    • Logout: POST /ui/logout (CSRF) clears the cookies and redirects to /ui/login, for every way in. A session at an identity provider is the provider’s and is left alone (no RP-initiated logout), so signing in there again is silent while it lasts.
    • Keys (oidc_jwks.go): own oidc.KeySet over go-jose: the JWKS is cached, an unknown key id refetches at most once per JWKSMinRefetch (10 s), algorithm allowlist RS/ES/PS 256/384/512 (so none and HS* never verify), Now shifted back by 30 s of clock skew.
    • REST bearer (AuthenticateAPI in login.go, oidc_bearer.go): Authorization: Bearer <JWT>; its iss, read unverified only to choose (tokenIssuer), selects the enabled provider with that issuer URL, whose keys verify it: iss, aud = that provider’s API audience, exp, nbf; 401 codes MISSING_AUTH_HEADER, INVALID_AUTH_HEADER, INVALID_TOKEN (never says why; also a token of no enabled provider), TOKEN_EXPIRED, with WWW-Authenticate: Bearer. The identity is the user record of (issuer, sub) when there is one (its name; a disabled user’s token is INVALID_TOKEN), else the token’s subject (a machine client). A session cookie is not accepted on /api/v1 and a bearer token not on /ui.
    • REST without a provider: a release build without an enabled provider answers every /api/v1 request 401 UNSUPPORTED_AUTHENTICATION (API keys of password users do not exist yet), so its first provider is added in the UI by a password user; the developer build lets every request through as developer.
    • Identity in logs: both middlewares put the subject in the context with logctx.WithUser, so a line logged with that context carries user.
    • Tests: internal/auth/oidctest is a fake provider (discovery, rotating JWKS, authorize, token endpoint checking client secret and PKCE verifier, token factory with every deviation); internal/api/oidc_e2e_test.go drives the real server with auth.NewLogin over it; test/oidc/keycloak-check.sh (opt-in, docker) does the same against real Keycloak, with a developer build whose provider it configures over POST /api/v1/oidc-providers (login, logout, a provider that cannot be reached), then the release build on the same database (Secure cookie, bearer tokens).
  • Users and sign-in (operator’s view “Authentication” in docs/deployment.md). Every way in signs in a persistence.User; every user is an administrator.
    • Pieces: auth.Login (internal/auth/login.go: password login, setup links, sessions, throttle.go), internal/users.Service (every operation on users, shared by the UI and the CLI, over users.Store; inputs refused as *users.InputError by field), internal/credential (argon2id PasswordHash with its version in the string, $argon2id-v1$, 64 MiB, 3 passes, 4 lanes; CheckPassword: 12 characters to 256 bytes, the embedded common_passwords.txt, not containing the names or microb2b, not too repetitive, no composition rules or expiry; TOTP of RFC 6238, SHA-1, 6 digits, 30 s, ±1 step; 10 recovery codes abcde-fghij and setup tokens, both stored hashed). persistence.User/UserName (1 to 64 of [a-z0-9._@-], starting with a letter or digit; PasswordLogin bool; OIDCIssuer/OIDCSubject, NULL when none, unique together; CanUsePassword(): enabled, password login on, password and TOTP set; CanSignInSomeWay(): enabled and password login on or an OIDC identity), the core table users, persistence methods CreateUser, GetUser, ListUsers, FindUserBySetupToken, FindUserByOIDC, UpdateUser(name, UserChange) (UserChange.PasswordLogin among the fields), DeleteUser, UseUserTOTPStep (a code is accepted once), UseUserRecoveryCode, RecordUserLogin; persistence.ErrLastUser (disabling, deleting or turning password login off for the last user who can sign in, CanSignInSomeWay, without force), ErrUserSetupInvalid, ErrUserChanged are in persistence.ErrorVocabulary. Distributed mode reaches them over the persistence RPC.
    • Password login is per user: on for a user made with users create or on the Users page, off for one the provider or the developer button made. users.Service.SetPasswordLogin(ctx, name, on, force) turns it on (returning a setup link when the password or TOTP is missing) or off (raising the session generation); Reset of a user without it is users.ErrPasswordLoginOff, and their setup links do not open. EnsureUser makes the developer user, SignInOIDC/FindOIDC the provider’s.
    • Setup: no default user or password. A new or reset user gets a setup link <PUBLIC_URL>/ui/setup/<token> (24 h, single use) to set a password and enroll TOTP (QR code rendered server-side as inline SVG, internal/api/ui/qr.go; a code proves it before it is kept); a reset that keeps one credential has the page ask for it (kept TOTP: a code or recovery code; kept password: the password). Setup paths are redacted in the access log (middleware.LoggedPath) and recorded by no trace (telemetry.HTTPHandler).
    • Login (GET/POST /ui/login): name and password, then a TOTP or recovery code, one refusal text whatever was wrong (a user without CanUsePassword is refused the same way). Throttled in memory per API server process: per account name 3 free failures, per client address 20, then 1 s doubling to 15 min, 429 with Retry-After; never a lock, so there is no unlock.
    • UI: System > Users in every build (/ui/users, internal/api/ui/users.go; 404 when Accounts() is nil): the list has a Sign-in column (Password / Identity provider); create, edit display name, turn password login on (the setup link shown once when needed) or off, reset password/authenticator/both (only with password login on; the new setup link shown once), disable/enable, sign out everywhere, delete; the user’s page shows “Password login: On/Off” and “Identity provider: at ”. /ui/account (the header’s name links to it): with password login on, change password, replace authenticator, new recovery codes, each proven with the current password; without it the page says the user signs in at the identity provider (or that password login is off).
    • Code fields: a six-digit code is an input with data-otp-digits="6", which static/js/otp.js draws as one box per digit (typing moves on, Backspace back, a paste or an autofill with autocomplete=one-time-code is spread over the boxes; with data-otp-submit, the login’s code step, the form is submitted when the last box is filled; the input itself stays the submitted field, so the form works without the script). A recovery code goes in its own field, recovery_code, behind “Use a recovery code instead” (recovery_alternative in components/credentials.html); the login and the setup page take it before code (auth.submittedCode). otp.js has no unit test: Playwright types into the boxes (enterCode in tests/local-auth/accounts.ts).
    • CLI microb2b users (cmd/microb2b/users.go): create [--display-name], list, reset-password, reset-totp, reset, password-login <name> on|off [--force] (on prints a setup link when needed), disable [--force], enable, delete [--yes] [--force], sign-out; refusals name “the last user who can sign in”; over the database of DB_TYPE/DB_URL (creating the schema) or with --nats over the persistence RPC; needs no session, API server or SECRETS_KEY; prints whole links when PUBLIC_URL is set. Actor cli:<OS user>.
    • Audit and secrets: user changes are audit events of kind persistence.AuditKindUser (user, page /ui/users/{name}), credentials recorded as secret changes without values; logins are log lines, not audit events. The actor of every way in is the user name; a REST token without a user record is its subject. Identity providers are audit events of kind persistence.AuditKindOIDCProvider (oidc-provider, “Identity provider”, page /ui/oidc-providers/{id}/edit), the client secret redacted (audit.Redact(OIDCProvider.WithoutSecret)). The sealed columns of the core persistence (persistencePlugin.SealedColumns) are the TOTP secret (persistence.TOTPSecretField, scope the user name) and the providers’ client secret (persistence.OIDCClientSecretField, scope the issuer URL), so microb2b secrets rekey and the startup key check cover both.
  • CSRF protection: stateless HMAC tokens in a cookie and a form field (webui.CSRFProtection), on every state-changing /ui/ route in both variants. A token is bound to the session’s subject (webui.CSRFSession, the server passes auth.IdentityFrom’s subject), so a token of another user is refused. The cookie is chosen by build tag (internal/webui/csrf_cookie_{release,developer}.go): __Host-mb2b_csrf with Secure in a release build, mb2b_csrf without Secure in the developer build (plain http); the form field is csrf_token (webui.CSRFField) in both. CORS by ALLOWED_ORIGINS.
  • Hardening in internal/api/middleware: SecurityHeaders (CSP with no unsafe-inline, so no inline <script>, style or event attribute in any template; frame-ancestors none, nosniff, Referrer-Policy), LimitBody (1 MiB, the certificate upload its own limit, applied before the CSRF check parses a form; also a per-request read deadline for requests with a body), StreamLimit (concurrent event streams), RequireContentType (an /api/v1 request with a body, and every POST/PUT/PATCH/DELETE with or without one, must declare application/json, the certificate upload multipart/form-data plus an X-Requested-With header (middleware.UploadHeader, any value), by apiBodyType in server.go; a wrong or missing type is 415 UNSUPPORTED_MEDIA_TYPE, a missing header 400 MISSING_HEADER, before anything happens, so a cross-origin page cannot send a state change without the CORS preflight; TestEveryRequestBodyIsDescribedWithTheTypeTheServerTakes and TestEveryStateChangeDescribesTheTypeItDeclares hold the OpenAPI document to it; the /ui/ routes are under CSRF instead and unaffected) and CORS with Vary: Origin. List endpoints cap limit at rest.MaxLimit. CSRF tokens are stateless (HMAC-signed, webui.CSRFTokenManager). /health pings the database (Persistence.Ping) and names no version. htmx runs with allowEval:false and selfRequestsOnly:true (a htmx-config meta in the layout).

Code Generation

Generated files are not committed; just generate regenerates them (see “Generated Code” below).

RPC (cmd/rpcgen)

Generates NATS RPC clients and servers for a persistence interface into its package’s rpc/client.gen.go and rpc/server.gen.go (with the standard Code generated ... DO NOT EDIT. header), from //go:generate go run microb2b/cmd/rpcgen <package path> <interface>. A request carries the caller’s trace, deadline and log attributes (logctx.Headers/FromHeaders: message id and user), so the persistence process’s lines about it carry them. TestGeneratedFilesAreUnchanged compares the output for cmd/rpcgen/testdata/svc with testdata/golden (go test ./cmd/rpcgen -update accepts an intended change); internal/persistence/rpc_errors_test.go drives the real generated code over an in-process NATS.

  • Errors cross the RPC with their identity. The server sends an error as an rpcerr.Wire ({code, message, details}, internal/rpcerr) and the client decodes it back, so errors.Is/errors.As answer the same in-process and over NATS and handlers test persistence errors one way, never by their text. Each interface’s package declares its error vocabulary as one table, var ErrorVocabulary rpcerr.Vocabulary (rpcgen refuses a package without it): persistence.ErrorVocabulary is the core’s (sql.ErrNoRows for a missing record, certificates included, ErrDuplicate, *CertificateExistsError with its id and description, *CertificateInUseError with the records that use the certificate, ErrInvalidCertificate, ErrInvalidPrivateKey, ErrInvalidCertificateDescription, ErrWildcardParty), a module’s is the core’s extended with its own (persistence.ErrorVocabulary.With(rpcerr.Sentinel("as2.message_not_found", as2.ErrAs2MessageNotFound))) or the core’s as is. An entry is rpcerr.Sentinel (matched with errors.Is, arrives as the sentinel itself, wrapped when the server added text) or rpcerr.Typed (matched with errors.As, its exported fields cross as details, so they must say nothing its text does not); the first matching entry wins, codes are unique. An error no entry knows arrives as its text. The message is always the server’s error text. A new error a caller tells apart is a row in its vocabulary; rpcerrtest.RoundTrip sends every entry over a real RPC (internal/persistence/rpc_errors_test.go, internal/protocol/as2/rpc_errors_test.go), and internal/api/e2e_rpc_errors_test.go runs REST 404/409 cases against a server whose persistences are the RPC clients.

UI forms (cmd/uigen)

  • A form is described by a typed spec, formspec.Form[T, S] (internal/webui/formspec), a package-level variable beside the struct (each package’s forms.go, //go:generate go run .../cmd/uigen -spec <Type>Spec). uigen reads the spec’s syntax (go/parser, never go/types) and writes <type>_form.gen.go: title/base-path constants, New<Type>() with the create-time defaults, <Type>Form(config, mode) *webui.ConfigForm, a Validate method, WriteOnlyFields (webui.WriteOnlyLister), <Type>Field<GoName> constants and, for Ref/Refs/NameRef/Pick fields, a <Type>Sources struct the caller fills. The doc comment of cmd/uigen/main.go is the reference for the spec, the widgets, their options and the errors the generator refuses with.
  • The compiler checks the spec: Fields: func(config *T, sources *S) []formspec.Field builds each widget from a pointer to its field (formspec.Int(&config.Port, formspec.IntOpts{...}) only compiles for an int field), a select’s options are the domain type’s option function, a parse, check or ValidateForm is a function value ((*T).ValidateForm), a scope a pointer to the URL field, a source a pointer to a member of S (the generated <Type>Sources, or formspec.NoSources), a default or bound may name a constant, and each widget’s options struct holds only the options it takes. uigen reads what is written (Min: 0 is a bound), checks what the compiler cannot (every field once, options that go together, sections) and production code never runs a spec; formspec.Check runs it on a zero value in each package’s forms_test.go.
  • Generated forms: as2.ReceiverConfig, as2.SenderConfig, as2.LocalProfile, as2.RemoteProfile, file.PollerConfig, file.WriterConfig, httppost.PosterConfig, notification.NotifierConfig, and the core’s records in internal/api/resource (Organization, Partner, Route, CertificateDescription, OIDCProvider; -by-id, converted to and from the persistence models, which stay free of the web contract; and Settings, -singleton: the one record of its kind, posting to its own page with webui.SingletonTarget).
  • Plugin configs are webui.CollectionScreen[T] (keyed by instance name), AS2 profiles hang off a parent (-parent-base-path/-segment) as webui.NestedScreen[T], the core’s records are webui.RecordScreen[T] (create and edit, internal/api/ui/record_screens.go; their list, detail and delete pages are the host’s). Organizations and partners are one kind of record, a party: internal/api/ui/party.go describes each kind (partyKind: nouns, paths, store, protocol panels) and serves the list, detail and delete of both over party_list.html and party_detail.html; the bun persistence stores both with one set of generic functions over their shared partyColumns (internal/persistence/bun/party.go).
  • Write-only values: a Secret (one value) or SecretLines (Name: value lines, e.g. extra HTTP headers, which carry API keys) field is never shown or returned. The form renders a secret empty and a SecretLines field as Name: ******** (names visible, values masked; a submitted line whose value is the mask keeps the stored line of that name, any other line replaces, a line left out is removed; a mask with nothing stored behind it, or arriving over REST, is refused); the field’s messages never repeat a line. Both widgets take a scope, a pointer to the URL field, and need an omitempty json tag (generator error otherwise). A stored secret is only kept while the address it is sent to is unchanged: a kept value with a changed URL is a field error on the secret (“must be entered again”), raised by webui.ConfigForm for the form (bound over the stored record) and for REST (ConfigForm.Previous), so both answer alike. A struct with write-only fields gets generated WithoutSecrets (the copy a response is made of, and the one an update decodes the body onto so a named property is told from an omitted one), KeepSecrets and ValidateUpdate; rest.ConfigResource requires Redact and ValidateUpdate exactly when the struct has write-only fields (its generated WriteOnlyFields), and no REST response carries a write-only value. webuitest.AssertWriteOnly fills every write-only field with a canary and asserts it is in neither the redacted JSON nor the rendered forms. A url field also refuses a credential-named query parameter (?token=).
  • SecretURL is a write-only absolute http(s) URL for an address that is itself the credential (a Slack/Teams/Discord webhook carries its token in the path or query): required on create, kept when a later submission leaves it empty, never shown or returned, and a Secret/SecretLines field may name it as its scope (so a kept header or signing secret has to be entered again when the URL changes). Credentials in the userinfo are refused; a token in the path or query is what it is for, and no message repeats the value. MultiSelect ([]T of a domain string type and its option function) is a set offered as checkboxes from the same canonical slice a select uses, kept once each in the slice’s order; none chosen is valid. webui.FormAction / CollectionScreen.Actions put a POST button (with CSRF) on an instance’s edit page for something the module does to the record (the notifier’s test); the module mounts the route at <BasePath>/{name}/<Segment>.
  • Every field of a generated struct is in its spec exactly once: a field the spec leaves out (or names twice) is a generator error, and the generated file ends in an unkeyed composite literal of the struct, so a stale generated file stops compiling.
  • An option names a constant directly (Default: DefaultReceiverPort), so a default that is also a runtime default is declared once; a long help text is a concatenation of literals, generated as one.
  • Cross-field invariants are a hand-written ValidateForm() []webui.FieldError on the struct, registered with -validate (the output does not compile without it).
  • One rule set for the form and the REST API. A record’s rules live in the webui field types the generated form instantiates. Every webui.Field has Bind (parse submitted text, apply the rule) and Check (apply the same rule to a value already held); the generated Validate(ctx[, sources], decoded...) builds the form over the struct and runs Check, and every REST handler for a generated struct validates through it, never with a tag-based validator or a hand-written check. A REST create decodes over New<Type>(), an update over the stored record, and the handler gets the same sources as the screen. A rule is changed in the spec or the webui field, never in a handler. webuitest.Run (internal/webui/webuitest) sends one submission to the form and the REST API as create and update and requires the same outcome; webuitest.AssertFormCoversEveryProperty fails on a JSON property the form has no field for.
  • ref/refs/nameref are pickers, never dropdowns (internal/webui/picker.go): only the chosen records and a search box are rendered, so a table of hundreds of certificates scales. A ref is one combobox (its empty= label shows as the value while nothing is chosen), a refs is rows plus an Add box. A webui.RefSource provides Search (bounded, stable order), Lookup (the chosen ids), Noun and Allows, which is the rule Bind and Check hold a submitted id to (as2.CertificateSource: signing, decryption and TLS client certificates need a private key). The screens mount the search fragment (webui.PickerPattern) themselves. static/js/picker.js has no automated test: keep it small and put every decision in the server-rendered markup, which internal/webui/picker_markup_test.go pins. A nameref is the same combobox over a record addressed by its name (webui.NameRef, a webui.NameSource with Lookup/Allows by name): organizations and partners, resource.PartySource (with Any for a route’s From/To, without it for the message list’s sender and recipient filters, which draw the pickers with webui_form_field and mount ui.MessagesPickerBase); webui.NameList is a NameSource over a short list known at request time (the AS2 test page’s “Test message from”: the organizations with an AS2 profile). Host and extension pages both have the form parts, so a page draws a picker with webui_form_field from a ConfigForm’s view and mounts its search with Host.ServePicker. The picker’s search box names a form that does not exist, so no surrounding form submits its q. A pick chooses one name from a short webui.OptionProvider list (a route’s plugin instances, resource.NewRouteSources).
  • Sections: a long form declares each section as a package-level webui.FormGroup variable (literals; uigen reads them), lists them in var <Type>Groups = []webui.FormGroup{...}, names that list as the spec’s Groups and gives every shown field the Group of its section’s variable. Each renders as a <details> that is open when AlwaysOpen, customized or failing validation, with a “Defaults” / “Customized: …” summary (none when all its fields are required). Markup only: every field is submitted either way. “Default” is one rule, webui.Field.IsDefault. Keep a section whose defaults hide risk AlwaysOpen. Only as2.RemoteProfile and resource.Settings have sections.
  • Typed text: formspec.TypedText(&config.X, ParseX, ...) edits a domain string type with its parse function (func(string) (T, error)); it becomes webui.TypedText, which applies the text rules (trim, required, length, pattern) and then the parse, whose error completes “
  • An int whose 0 means “inherit” says so with ZeroLabel (empty box with that placeholder); a plain int shows its number. Bool fields render as toggle switches (a real checkbox with role="switch").
  • Changing the generator: model and analysis rule first (analyse.go owns every check and error; templates only print), then a case in cmd/uigen/testdata/forms/models.go. TestGeneratedFilesAreUnchanged compares the output with cmd/uigen/testdata/golden/*.golden (go test ./cmd/uigen -run GeneratedFiles -update accepts an intended change); TestRepositoryFormsCompile runs every real uigen directive in place and vets the package. uigen reads with go/parser, never go/types, because the package depends on the file it writes.

Protocol Modules

Each lives in internal/protocol/<module>/ (the notifier, which is no protocol, is the module internal/notification, see “Notifier module”), with bun/ (tables), rpc/, plugin/, ui/, rest/, module/ and a <module>test test-support package.

AS2 (as2; plugins as2-receiver, as2-sender):

  • Receiver (HTTP handler: verify, decrypt, decompress, store, dispatch, answer with a sync or async MDN) and Sender (sign, encrypt, compress, post, process the MDN); crypto.go (PKCS7 via a fork of smallstep/pkcs7, see the replace in go.mod), mdn*.go.
  • The pkcs7 fork is the public module git.dries.info/microb2b/pkcs7 (MIT, upstream’s licence kept), so a build needs no credentials: by default it comes through the public Go proxy like any dependency, and with GOPRIVATE=git.dries.info/* (what CI sets) it is cloned anonymously over https from git.dries.info; go.sum pins it either way. It differs from upstream in: no package-level algorithm globals (the algorithms are arguments, with an EncryptionAlgorithm type), AES-192-CBC and 3DES-CBC encryption, the RFC 5084 encoding of AES-GCM parameters, a signer certificate found among the caller’s certificates when the signature embeds none, fragmented constructed BER strings flattened in ber2der, hardened parsing and decryption of untrusted input, and the CMS algorithm protection attribute of RFC 6211 (OIDAttributeCMSAlgorithmProtection): every signature carries it, and a signature that carries it verifies only when it names the SignerInfo’s digest and signature algorithms (otherwise *pkcs7.AlgorithmProtectionError, which the receiver answers with authentication-failed like any failed verification; a signature without it is accepted, older products do not send it). Upstream’s fixes are merged into it by hand; a change goes in as a new v0.2.3-microb2b-N tag and the replace names it.
  • An async MDN is posted to a URL the sender names (Receipt-Delivery-Option), so the receiver delivers one only to a host the partner’s RemoteProfile vouches for (AllowsMDNURL: the host of Endpoint or one of AsyncMDNHosts; anything else is refused with 400 before anything is stored) and only when asyncReceiptAllowed says so: always for a signed and verified message; for a failed verification or a missing signature the profile requires only with AsyncMDNOnSignatureFailure; for any other unsigned message only with AsyncMDNForUnsigned (both default false). Every other message is answered in the HTTP response alone.
  • Each message is accepted once per (sender AS2 id, Message-ID), and a signed one also once per (sender AS2 id, signature digest) (duplicate.go, replay.go, as2_inbound_messages, Persistence.ClaimInboundMessage/TakeOverInboundMessage/CompleteInboundMessage/ReleaseInboundMessage). The claim is one atomic INSERT in processMessage, made only after the message was unpacked (signature verified), so an unauthenticated message never takes a key; it is completed after the dispatch committed (the acceptance point: from then on the message is the core’s, and the positive MDN follows) and released by a deferred call when the message never got that far. A claim older than InboundClaimLease and not completed is reported as InboundClaim.Stale, not taken over by the claim itself: the receiver asks the core for the holder’s message, and a holder past the stage accepted is completed and the resend answered as a repeat, one still accepted is taken over with TakeOverInboundMessage (a conditional UPDATE). The receiver turns a message down with RefuseMessage(id, class, reason): the partner’s message refused on its merits (unmatched party, failed unpack or verification, repeat, replay, over the size limit) is persistence.RefusalPartner and ends Refused with no alert; our own failure (storage, database, a dispatch that failed) is RefusalInternal and ends Error with message_failed. Receiver.SettleAbandoned runs at Start and refuses the accepted rows no live process owns. A repeat is not dispatched: same payload digest, the stored MDN (the first message’s receipt stage) is sent again byte for byte (sync: the response; async: queued for the repeat’s own, profile-vouched URL, still subject to asyncReceiptAllowed) or, when none was kept, a new MDN processed/warning: duplicate-document; a different payload is refused with FailureReasonDuplicateMessageID; a repeat of an unfinished message is FailureReasonDuplicateInProgress. The Message-ID is an unsigned HTTP header, so the claim also carries SignatureDigest (signatureDigest: SHA-256 over each signer’s signed attributes as DER, or the signed content when there are none, so re-encoding, other embedded certificates or the ECDSA (r, n-s) twin do not change it; empty, stored NULL, for an unsigned message) under a unique constraint on (from_as2_id, signature_digest): the same signature under another Message-ID is InboundClaim.SignatureReplay, refused with FailureReasonReplayedSignature and never taken over, even from a stale claim; a release frees the signature with the Message-ID. A partner’s re-signed resend differs by its signing time. A refusal’s FailureReason is ours, its MDN’s disposition the standard’s (failure.go): failureTable is the one place a reason meets the wire, mapping each to the RFC 4130 7.4.3 disposition (processed/error: authentication-failed|decryption-failed|decompression-failed|insufficient-message-security|integrity-check-failed|unexpected-processing-error, Drummond’s duplicate-filename, or failed/failure: sender-equals-receiver|unsupported format|unsupported MIC-algorithms), whether it is ours (Ours, telemetry) and the HTTP status without an MDN (HTTPStatus); MDN.Refuse(reason) sets both, MDN.Failure is never written. FailureReasons is the canonical slice (ParseFailureReason, validating JSON); TestReceiptDispositionPerOutcome pins each disposition. ParseDisposition reads a partner’s type and modifier in any case. The repeat’s own message row ends Refused (“Repeat of message …”), the first message’s history gets MessageHistoryDuplicateReceived. processMessage returns a processing (receipt plus duplicateOf). See “Messages sent twice” in docs/as2-configuration.md.
  • A repeated file name is the partner profile’s choice (duplicate_file_name.go, Drummond FN-MDN): OverridableProfileParameters.DuplicateFileNameAction ("" accept, the default; warn; refuse; domain string with ParseDuplicateFileNameAction) within DuplicateFileNameWindowHours (0 = DefaultDuplicateFileNameWindowHours = 24, at most 8760). Receiver.checkFileName runs after the Message-ID claim (a resend is a repeat of its message, never a repeated name) and looks the name up with the core’s Persistence.FindMessageByFileName (party, name, since, except; a message its source turned down, Refused or Error with no deliveries, does not count; index messages_from_file_name): a lookup, not a claim, so two same-named messages at the same moment may both pass. warn dispatches with processed/warning: duplicate-filename; refuse settles the message Refused (RefusalPartner) with processed/error: duplicate-filename (MDN.Refuses: HTTP 400 without an MDN). See “Repeated file names” in docs/as2-configuration.md.
  • Message size and who is read. LocalProfile.MaxMessageSizeMiB (default as2.DefaultMaxMessageSizeMiB = 128 MiB, 1 to MaxMessageSizeMiBCeiling) is the organization’s limit and OverridableProfileParameters.MaxMessageSizeMiB the partner’s (0 inherits); EffectiveMaxMessageSize is the one place they combine. Receiver.HttpHandleFunc resolves both profiles from the AS2-From/AS2-To headers first (resolveParties); a message that matches none, names itself as receiver or requires a signed MDN we cannot make (ReceiptDeliveryOptions.Refusal) is refused by refuseUnread with its body unread and nothing created (no message, AS2 row, blob, kept or queued receipt). Only then is the AS2 record inserted and the body read through limitedBody (bodylimit.go: http.MaxBytesReader, refusal by Content-Length, per-read stall deadline and a whole-body deadline on the connection, no server ReadTimeout); over the limit is FailureReasonMessageTooLarge (HTTP 413 when no MDN was requested). The decompression limit of unpackCompressedData is the same effective size. MDNReceiver identifies the partner before reading and bounds the body by maxMDNResponseBytes.
  • A partner profile names certificates by whose they are: Our... are ours, with a private key (picker source OurCertificates), Partner... are the partner’s (PartnerCertificates); Go field, JSON, column and label follow the same name. PartnerCertificate does both jobs: messages are encrypted to it only when OutgoingMessageEncryptionAlgorithm is set, and the partner’s signatures are verified with it.
  • What a partner’s signature is verified against is one function, partnerSignerSource (partner_signers.go), for messages and MDNs: for an MDN, PartnerMdnCertificates when set; else PartnerCertificate ∪ AdditionalPartnerCertificates; else, for an MDN only, the embedded signer chained to the system trust store. A signed message with no partner certificate is refused (ErrNoPartnerCertificate). A signature is never accepted without a verified signer.
  • as2_messages records the algorithms, not booleans: MessageDetails.SignatureAlgorithm (MICAlgorithm, the signature’s digest algorithm; not MICAlgorithm, the receipt’s) and EncryptionAlgorithm (EncryptionAlgorithm), "" for none, with Signed()/Encrypted() the yes/no; Compressed stays a bool (zlib is the only AS2 compression). The sender records what it used (sentContent), the receiver what the CMS says (cms_algorithms.go): the content encryption algorithm of the EnvelopedData, read before decrypting with a small BER walker (readBER: partners send indefinite lengths), and the SignerInfo digest algorithm of a verified signature only. The OID tables (micAlgorithmOIDs, encryptionAlgorithmOIDs in crypto.go) are the one mapping both ways; an algorithm outside the domain (DES, RC2, SHA-224) is refused (decryption-failed/authentication-failed). Both are validated on write and read by the Bun persistence, shown on the message page’s AS2 section and in protocols.as2.
  • A new partner profile starts secure (as2.Default... constants in models.go): compressed, signed sha256, encrypted AES-256-CBC, a signed synchronous MDN requested, unsigned and unencrypted incoming messages refused. These are create-time defaults only; an explicit none always means none. Encryption without a partner certificate is refused on save (EncryptionWithoutCertificateMessage). A requested MDN needs the organization’s LocalProfile.MdnNotificationTo, which Sender.Send checks per send.
  • Sender.Deliver outcomes (the sender is a delivery.Deliverer behind delivery.Worker, Sender.WorkerOptions(maxConcurrent) with AttemptTimeout of an hour and a minute because a partner profile may set a connection timeout up to an hour). One failure that retrying cannot fix (profile lookup, file name, compression, signing, encryption, MDN settings) goes through failSend and is delivery.Failed(reason). One that may pass (payload storage, POST, TLS) goes through retryOrFail: delivery.Retry(now+delay, reason) while attempts-1 < maxRetries (delay: Retry Base Delay doubling, or the partner’s Resend Interval), else delivery.Failed("max retries exceeded: ..."). A synchronous MDN that is good is Delivered, a bad one Failed("synchronous MDN: ..."); when an asynchronous MDN was requested the outcome is delivery.AwaitingAck(text, now + RemoteProfile.ReceiptTimeoutHours) (receipt_timeout_hours, default 24, 1 to 720). The retry is the stored delivery (retrying, next_dispatch_at), so a restart loses nothing (the worker wakes itself when the retry is due, and a worker that starts finds the due retries); the sender holds no timer and runs no retry loop. recordOutgoing stores as2_messages.delivery_id; MDNReceiver finds the delivery of an MDN with locateSent and judges it with SettleDelivery. The message status is derived from the deliveries; the sender never writes it.
  • A partner’s TLS server certificate is verified against the system roots plus PartnerTLSServerCertificates (added, never replacing), host name included (tls_trust.go, one tlsClients cache keyed by trustKey for the sender and the async MDN sender). AcceptPinnedTLSCertificateAnyHost (off by default, refused without a pinned certificate in ValidateForm) waives the host name check for a leaf that is byte for byte one of the pinned certificates and valid now; that config sets InsecureSkipVerify and verifyPinnedOrNamed (its VerifyConnection) does the whole verification, every other certificate exactly as crypto/tls would (refused when the connection has no server name, an IP endpoint). The partner’s page shows it when on.
  • Testing a partner (connection_check.go, test_message.go; page /ui/partners/{id}/as2-profile/test, linked from the AS2 panel through webui.ProtocolPanel.Links; REST POST /api/v1/as2/remote-profiles/{id}/check-connection and .../test-message). The connection check is Sender.CheckConnection (resolve, connect, TLS with the profile’s trust over the same tlsClients.forProfile as a send but keeping no connection, one GET; steps ConnectionStep/ConnectionStepStatus are domain strings), asked of the running sender instance over the bus (RequestConnectionCheck on ConnectionCheckSubject(sender), answered by Sender.AnswerConnectionCheck, which the sender plugin registers with RuntimeDependencies.Responder in Start; no answer is ErrSenderNotRunning, REST 503 SENDER_NOT_RUNNING). The test message is SendTestMessage: fixed content, microb2b-test-<id>.txt, source persistence.OperatorSource, stored by storage.Payloads.InsertAndDeliver with one delivery to the chosen sender; it is a normal message. TestChoicesFor / TestRequest.Resolve choose sender and organization (the partner’s routes, else the only one, else the operator’s choice; one rule for page and REST).
  • Inbound concurrency cap. Each receiver instance has one as2.RequestLimiter (concurrency.go), sized by ReceiverConfig.MaxConcurrentRequests (default as2.DefaultMaxConcurrentRequests = 50, 1 to MaxConcurrentRequestsCeiling), wrapped by the receiver plugin around both the AS2 path and the MDN callback path (one shared budget). The slot is taken before the handler runs, so before resolveParties or limitedBody read anything, and given back by a deferred release (panics included). A request that finds no slot waits up to RequestSlotWait (2 s) and is then answered 503 with Retry-After (RefusedRetryAfter, 30 s), unread: no rows, blobs or MDN. Refusals are a warning at most once per minute per receiver (with the count since the last line), plus one line when it has room again; microb2b_inbound_requests_in_flight and microb2b_inbound_requests_refused_total (telemetry.RecordInboundInFlight/RecordInboundRefused, attributes protocol, instance).
  • TLS: a reverse proxy in front of the API server always (no native TLS there; recipes for Caddy and nginx in docs/deployment.md, extracted and run by test/reverse-proxy/check.sh, just test-reverse-proxy), in front of an AS2 receiver optionally: a receiver has two listeners on its Bind, each switched by its config, HTTPEnabled (plain HTTP on Port) and HTTPSEnabled (HTTPS on HTTPSPort with TLSCertificate, a stored certificate with a key), either or both (ValidateForm: at least one, HTTPS needs the certificate, two listeners two ports), served by one http.Server on the Path and the MDN Callback Path alike (receiverListeners binds both before serving and closes the first when the second fails). receiver_tls.go, ReceiverTLSConfig: the key opened with Certificate.Signer, TLSIntermediateCertificates after it, MinTLSVersion a domain string TLSVersion 1.2/1.3, HTTP/1.1 only, no client certificate asked. The certificate is read at Start, so a replaced one is used after a restart, and one that cannot be used fails Start. Both fields are in ReceiverConfig.CertificateFields, so as2/bun.CertificateReferences keeps them from being deleted. The OpenAS2 matrix sends every fifth inbound combo over HTTPS to a second, HTTPS-only receiver, as2_receiver_tls.
  • The default port of a new receiver is as2.DefaultReceiverPort (8081), deliberately not the API’s HTTP_PORT default (8080); a test keeps them apart.
  • Asynchronous MDNs we owe a partner are jobs of the receiver instance’s own delivery.Worker[as2.PendingAsyncMDN] over as2.AsyncMDNStore (as2_pending_async_mdns: Pending, Delivering under a claim token with the lease in next_attempt_at, Retrying, Delivered, Failed; ClaimPendingAsyncMDN/CompletePendingAsyncMDN/ReleasePendingAsyncMDN/ListDuePendingAsyncMDNs), not deliveries: their outcome never touches the message’s status. AsyncMDNSender is the Deliverer (a fresh connection per MDN; Retry with jittered back-off from MDNRetryBaseDelay while MDNMaxRetries allows, else Failed; reasons never name the URL). The worker polls every MDNPollInterval and Receiver.OnAsyncMDNQueued pokes it, so a queued MDN is attempted at once; CompletePendingAsyncMDN raises async_mdn_failed after a failure commits. The receiver plugin implements plugin.Quiescer for it.
  • Inbound Content-Transfer-Encoding is decoded at every layer (mime.DecodeTransferEncoding, through Sealed.Content): the request body per the HTTP header (entity, requestEntity in unpack.go), the signed part, the decrypted and the decompressed content per their own; base64 and quoted-printable are decoded, the identity encodings pass, anything else or undecodable content is ErrTransferEncoding and fails the MDN with unexpected-processing-error (unwrapping.failLayer), never a crypto reason. The MIC and the signature still cover the part as received (encoded body), RFC 4130 §7.3.1.
  • EDIINT-Features (RFC 6017) (features.go): featureTable is the one list of the optional features we know and which we implement; OurFeatures and announceFeatures make the header of every message (Sender), asynchronous MDN (setAsyncMDNRouting) and receiver answer (AnnounceServer) from it, and leave it out while none is implemented. A partner’s header is ParseFeatures (Feature/Features, ParseFeature: known names in their registered spelling, others kept as written, junk dropped, at most MaxPartnerFeatures), recorded as MessageDetails.PartnerFeatures (as2_messages.partner_features, NULL = not known, ’’ = none announced): from the message for an incoming one, from an opened MDN (MDNRecord.PartnerFeatures) for an outgoing one; shown as “Partner features” and protocols.as2.partner_features. Nothing acts on what a partner announces.
  • An AS2 identifier is as2.AS2ID (as2id.go, RFC 4130 section 6.2): ParseAS2ID (1 to MaxAS2IDLength printable ASCII characters and spaces, no " or \, no space at either end; its errors complete “ must …”), validating JSON, and the profile forms edit it as typed text (formspec.TypedText with ParseAS2ID). ParseAS2IDHeader reads AS2-From/AS2-To (quotes removed; an unquoted name with a space is accepted), so the receiver and the MDN receiver refuse an invalid header with 400 ErrInvalidAS2ID before anything is read; AS2ID.Header() quotes an id with a space and nothing else, for the sender, the async MDN routing and the MDN’s recipient fields. The Bun persistence refuses an invalid id on write and read.
  • The original file name is announced in and read from the payload’s Content-Disposition (internal/mime/disposition.go); a remote profile’s ExtensionContentTypes choose the payload Content-Type by its extension.

File (file; plugins file-poller, file-writer):

  • Poller.Prepare (called by the plugin’s Start) makes the base directory with MkdirAll and fails Start naming the directory and cause when that is impossible or the path is no directory; the processed and unroutable/ directories are made lazily, when the first file needs them; nothing else creates directories (no partner folders). Poller derives sender and recipient from the file’s path: PollerConfig.PathPattern (default {to}/{filename}, path_pattern.go) is matched against the path under base_directory with the placeholders of one table ({to}, {from}, {filename}); the scan goes exactly as deep as the pattern, never follows symlinks, and skips unroutable/ and the processed directory. from/to are each a fixed field or a placeholder, never both; no from means the single local organization. Captured names are only looked up as party names, never used as paths; a file naming no such party is moved to <base>/unroutable/ with a .reason.txt, never skipped.
  • Writer names its output with WriterConfig.FilenamePattern (filename_pattern.go): one table of placeholders ({message_id}, {from}, {to}, {original_filename}, {original_basename}, {original_extension}; {a|b} falls back to b) read by the expansion, the validation and the help-text test. A new parameter is a row in that table. Message values are cleaned of separators and each finished path element goes through persistence.SafeFileName (the one rule set for Windows-safe names: invalid characters <>:"|?* become _, trailing dots/spaces are removed, reserved device names such as CON.txt get a _ prefix); the result must stay under the base directory; an existing file is replaced only with Overwrite. Writer is a delivery.Deliverer (Writer.WorkerOptions from WriterConfig.MaxConcurrentDeliveries, default 1, 1 to 100): a failure that retrying cannot fix (stage not recorded, filename pattern error, name exists without Overwrite) is Failed at once, any other Retrys after the file.WriteRetryDelays (30 s, 2 min, 10 min) and then Failed; a re-attempt that finds the target file holding exactly the payload’s bytes is Delivered.

Outgoing HTTP (internal/httpout, core, names no module): the rules for an operator’s extra request headers (ParseHeaderLine with a base table of what the transport, the request and the trace own, plus each module’s HeaderRules: its own headers and prefix; AS2 leaves Host to the operator) and the pieces of a delivery attempt the HTTP poster and the notifier share: NewClient (no redirects), Classify (2xx delivered; 5xx, 408, 425, 429 retryable), TransportError (the reason never names the URL or the address), Backoff, Error{Reason, Retryable}.

HTTP Post (httppost; plugin http-poster): Poster is a delivery.Deliverer that POSTs the routed message’s payload to the configured URL. 2xx is Delivered; transport errors, timeouts, 5xx and 408/425/429 are delivery.Retry(now+retryWait(attempts), reason) until MaxAttempts, then Failed; anything else is Failed, and redirects are never followed. A retry is the stored delivery (the worker wakes itself when it is due, and a worker that starts finds the due ones), not a timer of the poster. PosterConfig.MaxConcurrentDeliveries (default 5, 1 to 100) is the worker’s MaxConcurrent (Poster.WorkerOptions). A delivery that ends Failed makes the message Error, which raises message_failed; the poster raises nothing itself. The password and the values of the extra headers are write-only (Secret / SecretLines widgets, see UI form generation): rest.ConfigResource.Redact (the generated WithoutSecrets) keeps them out of REST responses.

Original file name: persistence.OriginalFileName is a protocol-neutral, validated domain string on the message (bare name, at most 255 bytes, no separators or control characters; BareFileName reduces a partner’s untrusted value to one with SafeFileName; ParseOriginalFileName refuses a name SafeFileName would change: reserved Windows device names, trailing dots, <>:"|?*). The file poller sets it, it travels with the message, and the writer and AS2 sender use it.

Plugin Architecture

  • A module is one module.Module value (name, persistence plugin, plugin types with discoverer and factory, optional UI and REST factories) registered with one module.Register call from the init() of the module’s module package (internal/protocol/{as2,file,httppost}/module, internal/notification/module; the core persistence, the API server and certmonitor register from their own plugin packages). internal/module validates it and adds nothing unless all of it is valid; startup logs every module and what it contributed. The plugin, ui and rest subpackages export their pieces and register nothing.
  • cmd/microb2b and test setups import only module packages (_ "microb2b/internal/protocol/as2/module"); leaving one out leaves the module out entirely. plugin.Register, RegisterDiscoverer and RegisterPersistencePlugin are called only by internal/module; plugin.RegisterCommand also adds the application’s own subcommands.
  • An instance name is lower case ([a-z0-9_-]+, the pattern of every instance Name field) and unique across plugin types, compared without regard to case (routes, messages and deliveries name an instance by its name alone, and its delivery subject is delivery.<name>). Each module.Plugin lists the names of every instance of its type, enabled or not (Names, built with plugin.AllNames over the module persistence’s list call), and plugin.NewInstanceNames asks them all; the singletons (apiserver, certificate-monitor) hold their own name. Every create goes through one rule, plugin.InstanceNames.Check(type) (a webui.InstanceNameCheck): a name another type holds is a failure on the name field naming that type and instance. webui.CollectionScreen and rest.ConfigResource require it (NameCheck, with Name on the screen), so the form and the REST create answer alike (TestEndToEnd_AnInstanceNameIsUniqueAcrossPluginTypes); tests that build an extension without a database of every module pass plugintest.NoConflicts.
  • A plugin.Type is declared by its module (as2.SenderPluginType = "as2-sender"; no central list) and spelled the same everywhere: registry, CLI subcommand, REST type, /ui/plugin-configs/<type>. Instances are discovered from database configuration.
  • Modules have no environment variables and the core names no module. What differs per instance is a field of the instance’s configuration (e.g. as2.ReceiverConfig.MDNPollInterval), edited in the UI or REST API. internal/config and internal/telemetry mention no module: the protocol is an attribute of microb2b_messages_total, filled with the module’s own ModuleName constant (as2.ModuleName, file.ModuleName, httppost.ModuleName, which the module registers under), the status a telemetry.Status. Metrics describe the server as a whole: no metric carries a party (a series per trading pair would grow with every partner); a message’s parties are on its trace. Attributes are bounded by configuration (protocol, plugin instance, state), never by traffic.
  • internal/module/architecture_test.go enforces (production and test code): only internal/module registers with the plugin registry; the application imports only module packages; the core imports no module; no module imports another; config and telemetry name no module.
  • Routes (source_plugin, from, to, target_plugin) name plugin instances, which must exist and be enabled (each module answers EnabledEndpoints; vocabulary in internal/plugin/endpoints). from/to may be persistence.RouteAny (*, shown as “Any”), which party names (lower case, [a-z0-9_-]+, compared as such) and messages can never hold. Routing is decided once, at dispatch, from the route cache: persistence.MatchRoutes finds the routes whose source instance and from/to match (* matches any party) and the message gets one delivery per distinct target, so overlapping routes to one target deliver once. At save time resource.Route.CheckRedundancy (form and REST) refuses a route that covers or is covered by another with the same source and target (persistence.Route.Covers). A route applies to the next message dispatched, with no restart; a message already dispatched keeps its deliveries.

Plugin UI extensions

A module contributes its web UI as a webui.Extension, built by the Factory of its ui/ package (module.Module.UI) once the dependencies exist (ui.Handler.LoadExtensions, called by api.NewServer): nav entries, its templates, its routes, and optionally dashboard counts, route endpoints and organization/partner panels.

  • The host (internal/api/ui) owns the page chrome and lends screens a webui.Host; a module never imports internal/api, and the server never imports a module’s UI.
  • webui.Extension.MessagePanel adds a section to the message detail page (webui.MessagePanel, webui.Absence for “not known” / “not applicable”); persistence.Message and the host’s page stay protocol-neutral. AS2 contributes how the message was protected (signature and encryption algorithm, compression) and its receipt/MIC details; the MDN itself is the message’s receipt payload stage (mdn_record.go), offered by the page’s stage selector like every other. A panel that cannot be built is an error page, not an empty section.
  • A page about one record that does not exist is a 404, never an error page at 200: webui.Host.LoadFailed (sql.ErrNoRows is a 404 on the host’s error page titled “Not found” and is not logged; any other failure a 500, logged at ERROR) answers for the host’s detail pages and every generic screen (RecordScreen, NestedScreen, CollectionScreen); the host’s error page always carries its status (renderErrorPage(w, r, status, message), webui.Host.RenderError). TestEndToEnd_APageOfAMissingRecordIs404 covers every record page.
  • Template names are one namespace: prefix a module’s with its name (as2_<page>.html); Install refuses a collision.

Plugin REST extensions

A module contributes a rest.Extension (internal/api/rest), built by the Factory of its rest/ package (module.Module.REST); module.BuildREST builds them all and the server mounts them. The factories are held by internal/module because an Extension names plugin.Type.

  • An extension carries its Routes (full /api/v1/... patterns), PluginConfigs, the Endpoints routes may name, the Instances stats report, its OpenAPI fragment and MessageDetail (marshalled into the message detail response under protocols.<module>).
  • /api/v1/plugins/configs/... dispatches to the rest.PluginConfig registered for the type (unknown type: 400). Modules build them with rest.ConfigResource[T]: rest.Decode decodes property by property (an undecodable domain value is reported against its property) over New<Type>() or the stored config, then the generated Validate. A failure is response.WriteFieldErrors: 400 INVALID_INPUT with details keyed by field name (_form for none); there is no other validation on either surface.
  • A record addressed by a numeric id is a rest.RecordResource[T, R] (internal/api/rest/record.go): organizations, partners, routes, certificates, identity providers (internal/api/handlers/records.go) and the AS2 local and remote profiles (internal/protocol/as2/rest/as2_profiles.go). T is the struct with a form spec a client sends, R what is stored and shown (the same type for a profile); the resource names New, Edit (what an update decodes onto, without write-only values), Validate (the generated Validate/ValidateUpdate), an optional Check hook for rules beyond the record (route redundancy, the party an AS2 profile belongs to), View (redaction) and the persistence calls Find/Load/Insert/Save/Remove; a nil verb is not served (a certificate is created by its upload). Handlers().Mount(r, "/api/v1/<collection>") serves the one contract: a list is {"<collection>": [...], total, limit, offset} with rest.ParsePagination (default 20, at most rest.MaxLimit, an unusable limit/offset is a 400 naming it, on every list), create 201 and update 200 with the stored record, an update whose body names another id than the path a 400 on the id property, DELETE 204 without a body, timestamps time.Time. A new record resource is a constructor returning one, never a hand-written handler.
  • One error mapping, rest.WriteFailure (record and config resources, the certificate upload, messages): sql.ErrNoRows 404 NOT_FOUND, *persistence.CertificateExistsError 409 CERTIFICATE_EXISTS, *persistence.CertificateInUseError 409 CERTIFICATE_IN_USE, persistence.ErrDuplicate 409 CONFLICT, a *listfilter.Error 400 on its parameter, anything else 500 INTERNAL_ERROR logged once with slog.ErrorContext(r.Context(), ...). REST handlers log through the default logger with the request’s context (the one logger source; nothing injects a *slog.Logger into them, and rest.Factory takes only the runtime dependencies). A body that is not a JSON object is rest.WriteUndecodable (400 INVALID_INPUT, details.body).
  • The OpenAPI document is assembled at startup (internal/api/openapi, served at GET /api/v1/openapi.yaml): the core’s internal/api/openapi/openapi.yaml plus each module’s embedded fragment, with the plugin type enums and PluginConfig schema computed from what registered; an unsound result (duplicate, dangling $ref, reused operation id) is refused. internal/api/openapi_test.go checks that every mounted route is described and every described operation is mounted.
  • Modules use internal/api/response (WriteJSON, WriteError, WriteFieldErrors) and never import internal/api/handlers or internal/api.

UI styling

  • One stylesheet, internal/api/ui/static/css/style.css, embedded; no CSS framework and nothing from another origin (air-gapped deployments look the same). Host and module templates share it.
  • Every colour, size and radius is a custom property. Light is the :root block; dark is declared twice, in the prefers-color-scheme: dark block (system) and :root[data-theme="dark"] (the header’s theme switch), kept identical by TestChosenDarkIsTheSameAsSystemDark. A new colour used as text or fill gets its pair in pairs in internal/api/ui/style_test.go, which fails below WCAG AA (4.5:1) in either theme (nonTextPairs: 3:1 for controls).
  • Density and type scale. Text, spacing, control and row sizes are tokens in the :root block (--font-size-xs..2xl, --font-size-mono, --space-0..5, --control-height, --row-height, --cell-pad-*, --icon-size, …), all in rem/em/ch; text never takes a px value (borders and outlines may be 1-2px, --touch-target stays px). html is a percentage of the browser’s own default (75%, so 12px at the usual 16px), fluid up to 87.5% (14px) at 3840px wide through clamp(), and 81.25% under 900px, where controls also meet --touch-target; so browser font-size settings and zoom keep working. To add a size, add a step to the scale (and to the sameInBoth list in style_test.go), never a literal; to change the density of the whole UI, change the tokens, nothing else
  • Icons. A navigation entry has an icon: webui.NavItem.Icon is a webui.Icon (typed name; Extension.Validate refuses an empty or unknown one), drawn by the one icon template from the host’s sprite static/img/icons.svg (<svg class="icon"><use href="/static/img/icons.svg#icon-<name>">, stroke currentColor, sized in em). To add an icon: a <symbol id="icon-<name>" viewBox="0 0 24 24"> in the sprite (hand-drawn line icons, no third-party set), a constant and an entry in webui.Icons, then NavItem.Icon (host entries in components/nav.html use {{template "icon" "<name>"}}); tests fail if the sprite and webui.Icons differ or a symbol is shown nowhere (the menu or an icon button). The Deliveries table of the message page uses the restart icon button (named “Redeliver to ” / “Send again to ” by aria-label and title), and its Redispatch button is an icon-and-label button (button.button-with-icon, icon redispatch) in a wrapping .toolbar-wrap beside its explanation. An icon button (button.icon-button, in an .icon-actions row that never wraps; the Services page’s start/stop/restart) shows only an icon, carries aria-label and title naming the action and its target, is a --control-height-sm square (--touch-target under 900px) and tells actions apart by shape; icon-button-danger adds the danger tone, never as the only signal
  • Templates never use an inline style attribute, a <style> block or a third-party origin, and no declaration is !important (TestTemplatesDoNotReachAroundTheStylesheet). Use or add a class: column roles (col-id, col-date, col-actions), layout (toolbar, stack, grid-4, narrow/wide), spacing (mt-1/mb-1), colour (text-danger, badge-*, status-*). A class with a rule is used somewhere (TestEveryStylesheetClassIsUsed; a class a template composes at runtime, like alert-{{.FlashType}}, is on its composedClasses list): delete a rule with the last use of its class.
  • Content is left aligned on every page. The content column starts at the same left edge everywhere; the width classes (narrow, wide, --form-max-width) limit width and never centre. A rule with an automatic horizontal margin fails TestNoRuleCentresABlock unless it is on its allowlist (only the login card and its wrapper, which are outside the sidebar layout). Text centred inside a table cell or badge is fine; a centred block is not.
  • Responsive rules are the @media blocks at the end: under 900px the sidebar collapses behind a menu button (static/js/nav.js), under 720px wide tables scroll sideways and no column is hidden. Check a new page at a narrow width.
  • List tables: the row is the link, and a row has no buttons. <tr class="row-linked">, the primary cell holds {{template "row_link" ...}} (the one focusable link), every other cell starts with {{template "row_ghost" $href}} (components/row_link.html). Delete is a danger-zone section on the record’s page (delete_section, or Target.DeleteURL in webui_form.html): one hx-delete button with hx-confirm, answered by webui.Deleted; there is deliberately no delete without confirmation. The one table with buttons in its rows is the Deliveries table of the message page (a detail page, not a list): its Actions cell (col-actions nowrap) holds one hx-post button with hx-confirm per redeliverable delivery; TestEndToEnd_ListRowsAreTheLink and the list-template scans cover the list pages only. Enabled/disabled is a read-only pill in lists. TestEndToEnd_ListRowsAreTheLink enforces it.
  • Wrapping is by role: .nowrap on columns that stay on one line (ids, status, dates, pills), .wrap-anywhere on unbreakable values (names, fingerprints, paths); never on all cells (style_tables_test.go). A list timestamp is {{template "datetime" .CreatedAt}}.
  • A required field carries a red asterisk after its label, from one rule: webui.FieldView.Required (what the field itself refuses empty, in the form’s mode: an identity is required on create only; a secret, int and bool never) drives the marker, the HTML required attribute and aria-required on a picker’s combobox, with no second list. The marker is the required_mark partial (.required-mark, token --required in all three token blocks, paired in style_test.go); a form explains it once with .form-legend (FormView.ShowsRequired). Hand-written forms (login, certificate upload) use the same partial beside an input with required; TestHandWrittenFormsMarkRequiredFields fails when they disagree. Server-side validation stays authoritative.
  • Message status is {{template "status_badge" .Status}}; label and class come from persistence.MessageStatus (Label, BadgeClass), and style_test.go fails on a status without a contrast-checked badge. Other pills are .badge plus badge-success/-error/-warning/-info/-secondary.
  • Pagination is one pager in the table footer: the handler builds newPager(...) (internal/api/ui/pager.go) into its data as Pager, the table ends in {{template "table_footer" ...}}. No hand-written pagination (TestNoTemplateWritesItsOwnPager).
  • Breadcrumbs are typed data (webui.Trail(webui.Link(...), webui.Here(...)) on webui.Page, webui.FormPage or the host’s Page[T]) rendered by layout.html in the header; the generic screens build their own, the host’s are in internal/api/ui/breadcrumbs.go. A page never writes a <nav> (TestNoTemplateWritesItsOwnBreadcrumbs, TestEndToEnd_PagesShowTheirTrail).
  • List filters (q, and for certificates status/key) are parsed once for UI and REST by internal/api/listfilter, which refuses an unknown value, and passed to persistence as a typed filter.
  • One CSRF token per browser session, never per request (webui.CSRFProtection over /ui/): the first GET mints the CSRF cookie (webui.CSRFCookieName), later GETs of the same session reuse it, and a state-changing request carries the same token as form field. Never rotate it on a GET or consume it on use, or a picker search, another tab or a live update breaks the next submit (TestEndToEnd_AFormSubmitSurvivesOtherRequests). A page embeds it with webui.CSRFToken(r).
  • Forms post normally and redirect; htmx is only for fragments with an explicit target. A <form> answers 303 (except a user’s creation and reset, which answer with the user’s page because their setup link is shown once and never stored or put in a cookie), or re-renders at 400 with the reason as flash (renderPageStatus, never http.Error), or at 409 when a name is taken (persistence.ErrDuplicate, in every generic screen). A refused form shows again what was typed in each text, URL, number and identity field it refused (ConfigForm.Bind keeps the submission; write-only fields are never echoed), and webuitest.Run asserts it. An element with hx-post/hx-put/hx-patch/hx-delete names its hx-target or says hx-swap="none", a form never carries one, and a handler serving htmx never answers with a full-page redirect (webui.Deleted, webui.PageRefreshed). TestHTMXRequestsDeclareWhereTheAnswerGoes.
  • Flash messages are webui.SetFlash(w, webui.FlashType, message) before a redirect: the type is a domain string (FlashSuccess, FlashError; ParseFlashType, the layout’s alert-{{.FlashType}}), the message is encoded in the cookie, and the page that shows it clears it (webui.Flash(w, r)). A screen confirms a change to a record with webui.FlashRecordChange(w, noun, name, change) (“Partner ‘Müller’ created”).
  • A link a template shows must be served. GET /ui/ is a prefix pattern, so an unregistered /ui/... path renders the dashboard instead of 404; TestEveryLinkInATemplateIsServed (internal/api/e2e_links_test.go) checks every href, hx-* and form target, modules’ included. A download or fragment a page links to belongs under /ui/ (session), not /api/v1.

Alerts

A producer that knows something needs a person’s attention raises an alert; nobody parses logs for it. internal/alert (names no module) holds the Alert value, alert.Raise(ctx, a) and the publisher. Consumers (the notifier module, the notification log) subscribe to the dispatcher subject alert.EventSubject (events.alert); the core has none.

  • Alert: id, time, severity (info|warning|critical), code, subject {kind, id, name}, summary (one line), details (ordered {key, value}), dedupe_key, link (a UI path such as /ui/messages/<id>; whoever sends it out builds the absolute URL from the public URL). Severity, Code and SubjectKind are domain strings with canonical slices, Parse* and validating JSON. alert.New(code, subject, summary) fills id, time, the code’s default severity and a dedupe key; WithDetail/WithLink/WithDedupeKey/WithSeverity refine it.
  • Codes live in one table, alert.CodeTable (code, label, default severity): message_failed (critical), async_mdn_failed (warning), certificate_expiring (warning), certificate_expired (critical), plugin_failed (critical), file_unroutable (warning), delivery_stalled (critical), message_not_routed (warning), and notification_test (info, Test: true: raised only by a notifier’s test button, never offered as a filter by alert.CodeOptions). A new code is a constant and a row there.
  • Raise never blocks and never fails the caller. Alerts wait in a bounded queue (also before the process has a dispatcher); the newest are dropped and counted when it is full (logged as alerts dropped, never raised, so alerting cannot feed itself). Every process attaches the default publisher to its dispatcher (alert.Attach: standalone, persistence, each plugin process). The message id of ctx (logctx) is the event’s message id.
  • Content rules: ids, party and instance names, status, a short error text, a link; never payload bytes, headers, keys or a URL. Raise replaces any scheme://... in a text with <url> and bounds every length, but the producer still chooses what goes in.
  • Producers raise where the knowledge is, once per event. A message reaching status Error is raised as message_failed by the persistence’s status-change path (bunPersistence.logStateChange), one place for every protocol, for Error only: Refused raises nothing, and a target never raises for a failed delivery. A message that matches no route raises message_not_routed from the dispatch. The async MDN sender, the file poller (once per file moved aside), the plugin manager and certmonitor raise their own. certmonitor remembers in memory when it alerted for a certificate in a state and does not repeat inside WithReAlertInterval (default 24h); a restart may re-alert once.
  • Tests: alerttest.Install(t) makes the default publisher deliver to a capture (Alerts(), OfCode); a producer test asserts exactly one alert and no secret canary in it.

Audit log

Every change to configuration is an event in the core’s table audit_events (internal/persistence/bun/audit, model audit.Row; domain types in internal/persistence/audit.go: AuditAction created/updated/deleted/started/stopped/restarted with ParseAuditAction, AuditKindName, AuditChange, AuditEvent, AuditFilter, AuditVerification). Rows are never updated or deleted: the package has no such function and there is no RPC method for either. No retention.

  • Written in the bun write function, in the same transaction as the change, by the helper: audit.Create(ctx, tx, audit.Record{Kind, ID, Name}, after, opts...), audit.Update(ctx, tx, rec, before, after, opts...) (loads nothing: the caller reads the old row in the same transaction; writes nothing when no field differs), audit.Delete(ctx, tx, rec, before, opts...) and audit.Operate(ctx, tx, rec, action) for started/stopped/restarted. The diff is generic: both values are marshalled to JSON, nested objects flattened to dotted paths, id/created_at/updated_at ignored, so a new field is audited with no change. A write function is db.RunInTx(ctx, nil, func(ctx, tx bun.Tx) error {...}) and uses only tx inside (SQLite has one connection). Modules import this package from their own bun package (an architecture test allows nothing else outside cmd/microb2b and test support); it names no module. The actor is logctx.User(ctx) (both auth middlewares set it, the persistence RPC carries it), system when empty.
  • Secrets: a record with write-only fields passes its generated redaction, audit.Redact(T.WithoutSecrets). The diff compares the unredacted values, then records a field the redaction changed as AuditChange{Secret: true} with neither value. webuitest.AssertWriteOnly also holds the record to this (a canary in an audit change fails it).
  • Kinds are declared by the modules, never by the core for a module’s records: module.Module.AuditKinds []persistence.AuditKind (name, label, UI path with {id}/{name}), unique across modules and validated by module.Register; the core’s own are persistence.CoreAuditKinds, declared by the common module. module.AuditKinds() is what the filter parser, the REST list and the page read. Adding a kind is one entry there plus the helper calls in the write functions; the table stores the plain text.
  • Plugin instance configs (file-poller, file-writer, http-poster, notification-webhook; the kind is the plugin type) are audited by name (ID = Name, enabled is one of the fields); the poster and the notifier pass audit.Redact(T.WithoutSecrets).
  • Completeness: each persistence interface has an AuditCoverage table (persistence.CoreAuditCoverage, as2.AuditCoverage, …) listing its Create*/Update*/Delete*/Save* methods as audited (with its kind), runtime data or pending (with a reason); audittest.CheckCoverage in each package’s test fails for a method on none of them. Pending means a later step of the audit log; none may remain pending when the log is complete.
  • Reading: Persistence.ListAuditEvents(AuditFilter) (*AuditPage, error) (kind, action, actor, record id/name, time range; newest first; RecordGone says a deletion is at or after the event). Filters are parsed once for the page and REST by listfilter.Audit (an unknown value is an *Error naming the parameter). REST GET /api/v1/audit (unknown parameters are a 400) and POST /api/v1/audit/verify; UI System > Audit log (/ui/audit, internal/api/ui/audit.go, audit_log.html) with a Verify button (POST /ui/audit/verify, flash); a party’s page and the settings page link to the log filtered to the record (ui.AuditRecordLink).
  • Hash chain (audit/encode.go, audit/verify.go): hash = HMAC-SHA256(AUDIT_KEY, prev_hash || Encode(event)); Encode is documented on the function, defined over the event’s own fields only (v = persistence.AuditFormatVersion, id, at in Unix microseconds, actor, action, kind, record id and name, ordered changes) and pinned by a golden test; a different encoding is a new version, and Verify refuses a version it does not know. The id is the previous id + 1 assigned under a PostgreSQL advisory lock (SQLite: the single connection), so a gap is a removed event. The key is optional (config.AuditConfig, AUDIT_KEY/AUDIT_KEY_FILE, strict parse, unknown AUDIT_* is an error); audit.UseKey sets it per process (configureAudit in cmd/microb2b/audit.go, which warns once without one). Without a key events have empty hashes; the chain begins at the first hashed event; an unchained event after it is a break; verifying a chained log without the key is persistence.ErrAuditKeyRequired. audit.VerifyEvents(Source, key, Anchor) is the one verification, over DatabaseSource or FileSource (an export); Persistence.VerifyAuditLog runs it in the process that has the key. microb2b audit verify [--file f --after-id N --after-hash H] and microb2b audit export [--out f] [--after-id N] (JSON lines: audit.ExportLine, documented in docs/deployment.md). The chain is tamper-evident, not tamper-proof; see “Audit log” in docs/deployment.md.

Notifier module

internal/notification (a module like httppost: bun/, rpc/, plugin/, ui/, rest/, module/, notificationtest/; registered from internal/notification/module, imported by cmd/microb2b) delivers alerts out of the application. Plugin type notification-webhook (notification.WebhookPluginType), config notification.NotifierConfig (generated form and REST validation, one rule set; webuitest parity tests). Operator documentation is docs/notifications.md. The module’s architecture checks are internal/module/architecture_test.go (standaloneModules names it, so the core imports it nowhere and internal/config/internal/telemetry never name it).

  • The formats are one table, notification.FormatTable (formats.go): a row is Format (domain string with ParseFormat, JSON, FormatOptions for the form), label, Render func(alert.Alert, RenderContext) (Rendered{Body, ContentType, Headers}, error), Signs (the format’s receivers verify X-MicroB2B-Signature) and an optional Check (per-format rules over the whole config, reported against form field names). Nothing else names a format: the select, validation, delivery and signing all read the table. Adding a format is one row, one renderer file and golden files (testdata/golden/<format>_<case>.json for the cases of goldenCases; TestFormatsRenderTheirGoldenFiles iterates the table and fails for a row without them; go test ./internal/notification -run Formats -update accepts a change, review the diff). generic (format_generic.go: schema documented on genericDocument, schema_version raised on any change that is not additive) and slack (format_slack.go: top-level text fallback with mrkdwn:false and &<> as entities, Block Kit in an attachment for the colour; untrusted text goes in plain_text blocks, never mrkdwn) exist, and so do teams (Adaptive Card 1.4 in a Workflows message), discord (one embed, allowed_mentions.parse always empty), googlechat (cardsV2 plus a text fallback) and ntfy (plain-text body, Title/Priority/Tags/Click headers set by the format, which wins over the operator’s; its goldens are .txt: headers above the body); the formats that carry a long list share fitLines (format_common.go), which leaves out lines, last first, until the body fits the vendor’s limit and says how many; a renderer is a pure function that never reads the clock, the network or the config. A renderer must escape what its target interprets.
  • No env vars; links come from the instance: link_base_url (optional) is the web UI address recipients use; links are rendered only when it is set (RenderContext.Link). The notifier runs wherever its plugin runs, so it cannot read the API process’s PUBLIC_URL.
  • Secrets: the webhook url (SecretURL), headers (SecretLines, scoped to the URL) and signing_secret (Secret, scoped to the URL) are write-only (WithoutSecrets, AssertWriteOnly, canary tests). The URL, headers, signing secret and the endpoint’s answer are never logged, recorded in the notification log or put in an error text: the reasons are httpout.Errors, fixed phrases, and httpout.TransportError leaves out the URL and the address of a dial or DNS error. signing_secret is dropped for a format with Signs false.
  • Delivery (notifier.go, jobs.go): a kept alert is a queued row of the notification log (Persistence.QueueNotification, with the notification.Group a summary stands for), and that row is the job of the instance’s delivery.Worker[notification.Job] (store jobStore over ClaimNotification/CompleteNotification/ReleaseNotification/ListDueNotifications, job id the alert id, scoped to the instance; one attempt at a time, polling every PollInterval and poked when a row is queued; the clock is the notifier’s Clock). The Notifier is the Deliverer: it shares internal/httpout with the HTTP poster, 2xx Delivered, 429/5xx/408/425/timeouts/transport errors Retry with doubling backoff capped at MaxRetryWait up to max_attempts (a test: 1), other 4xx and redirects Failed, and a row older than MaxPendingAge (24 h) Failed without a request. A queued row survives a stop: Quiesce/Stop stop the worker, an interrupted attempt is released, and the next start takes up what is due. A row that cannot be queued is not sent (an ERROR line). A notifier never calls alert.Raise (a broken channel must not produce the alerts it would be asked to deliver; its failures are log lines and rows of the notification log; TestAFailingNotifierRaisesNoAlert).
  • Rules (rules.go) are applied in the bus handler, in memory, with an injectable Clock: filter (min_severity, codes; none = all), cooldown per dedupe_key (cooldown_minutes, default 15, 0 = off; a held-back alert is a suppressed_cooldown row), and a rate limit (rate_limit alerts per rate_window_seconds, defaults 5 and 60: the rest of the window is rows with outcome grouped and goes out as ONE summary alert, the most urgent held alert’s code and severity with RenderContext.Group, when the window ends). A held or sent alert counts as told, so the cooldown covers alerts that left in a summary. State is per process and lost on restart.
  • The notification log is the table notifications of the module’s own persistence (notification.Persistence, notification.Notification): one row per (alert, instance) (unique), with the alert’s fields, instance, format, Outcome (queued|delivered|failed|suppressed_cooldown|grouped, a domain string), attempts, last error, test, created_at, delivered_at. Alerts the filter drops are not recorded. Held-back rows (RecordNotification, which refuses queued) are written best effort; a queued row also holds next_attempt_at (shown while it waits, Notification.NextAttemptAt), the claim token and the summary’s group. A claim writes no change; its outcome does. Retention (RetentionMaxAge 30 days by created_at, RetentionMaxRows 10,000) is pruned by every running notifier at start and hourly (PruneNow, Persistence.PruneNotifications). The list for UI and REST is Persistence.ListNotifications(Filter) (*Page, error) (severity, code, instance, outcome, alert time range, limit/offset, newest first, Total).
  • Test send: notification.SendTestRequest publishes a notification_test info alert on notification.TestSubject(name) (notification.test.<name>) and polls GetNotification until the row is final or ErrNoAnswer (the instance is not running). The notifier answers it through its real path (format, headers, signature) but without filter, cooldown or rate limit, one attempt, row marked test. REST POST /api/v1/plugins/configs/notification-webhook/{name}/test (200 with the row even when it failed; 404, 409 disabled, 504 no answer) and a POST button on the instance’s edit page (webui.CollectionScreen.Actions, result page notification_test.html) both call it, so it works when the notifier is another process.
  • Tests: notificationtest is the module’s test support (Setup, FakeClock, an in-memory Bus dispatcher, a scripted Receiver). internal/notification/notifier_test.go covers delivery per format, signature, filter, cooldown, grouping, retries/backoff, 4xx, redirects, timeouts, canaries in logs and rows, no alert loop, test send, stop and queue overflow.
  • The Notifications page (internal/notification/ui: list.go, live.go, dashboard.go, templates notification_*.html; nav entry in the System section, webui.NavSystem, open to extensions) lists the log newest first. Filters are parsed by one parser for the page and the REST list, notification.ParseFilter (query.go: severity, code, instance, outcome, from/to as RFC 3339 or a bare UTC date, a bare date as to meaning that whole day, include_tests; every value checked, a failure names the parameter), ParsePaging for REST, Filter.Matches for the live page (a test keeps it equal to the stored list). Test notifications are left out unless IncludeTests. Severity and outcome badges are webui.Tones (severityTones, outcomeTones, a test fails for a constant without one), so no new color token was needed. An alert’s link is only rendered when it is a /ui/ path.
  • Changes and live updates: every write to the log (record, update, fail on restart) also adds a row to notification_changes (numbered, in the same transaction) and is announced as a notification.Change on notification.ChangesSubject (events.notification; PublishChanges/DecodeChange, wired as WithChangeNotifier in notification/plugin for in-process and for the persistence process’s RPC server). The page’s stream (GET /ui/events/notifications, first page only) is an eventstream.Serve over newFeed: one dispatcher subscription per stream, ids are change ids, Last-Event-ID/after resume through ListNotificationChangesAfter (the row as it is now), more than eventstream.DefaultResumeLimit behind is a reload event. Rows are upserted by element id on the client (data-tail-upsert), so an outcome update replaces its row. REST: GET /api/v1/notifications (rest/list.go, unknown parameters are a 400).
  • Dashboard card: Extension.DashboardCards returns webui.DashboardCard data (the host renders it in stat_cards, so the live dashboard and its poll show it): its figure is a tile among the counters in the group of its Window (CardNow, CardLast24h, CardLast7d; ui.statGroups lays the groups out), and a card with Items also gets a row of its own below them; a figure is colored only when it wants attention; the Alerts card is SummarizeCriticalNotifications (distinct critical alerts of the last 24 h, tests excluded, latest three). Extension.DashboardChanges is a channel the module signals (a critical real notification was written), merged by the host into eventstream.Config.Poke, which refreshes the coalesced summary without a message line. The card only knows alerts a notifier recorded: with no notifier configured it shows 0.

Services page (plugin manager control)

The Services page (/ui/services, nav section System, host-owned: internal/api/ui/services.go, templates/services.html) shows what plugin.Manager holds and lets an operator Start, Stop and Restart instances; GET /api/v1/services and POST /api/v1/services/{type}/{name}/{action} (internal/api/handlers/services.go, core OpenAPI document) are the REST counterpart. The dashboard’s “Manage Plugins” button and the plugin_failed alert link to the page.

  • An instance is identified by (type, name) in the manager (GetPlugin, StartPlugin, StopPlugin, RestartPlugin, Do); Manager.Add puts an instance under control (what discovery uses; a later reload would add instances the same way). GetPlugins lists by type, then name, for every consumer.
  • One validity rule: plugin.Action.Allowed(status) / PluginInfo.Actions() decide which button is offered and which request is accepted, for the page, the REST handler and the manager. Per-instance serialization is the status itself: Starting/Stopping mean an action is in progress and another is refused (ErrActionInProgress: 409 or a flash), not queued. Reads never wait for an action. StopAll keeps its order and timeouts; once it begins, actions are refused (ErrShuttingDown).
  • Controllable rule: a plugin that an operator must not control implements the optional plugin.Controllable interface returning false (apiserver does); every other plugin is controllable. The manager refuses actions on it (ErrNotControllable: 403 in UI and REST) and PluginInfo.Actions() is empty, so the page shows no buttons; no template or handler checks a name. The core persistence is wired outside the manager and is not listed.
  • A Start runs under plugin.StartTimeout (30 s; Manager.startTimeout overrides it in tests), StartAll and the start half of a restart included (StartAll still stops at the first failure). On timeout the instance is Error (“start timed out”), plugin_failed is raised and it can be acted on again. The abandoned Start goroutine cannot be killed: if it returns later it is ignored when another action has begun since (the entry’s generation changed), and otherwise the plugin is stopped again so it matches the recorded state.
  • An operator’s stop raises no alert; a failed start raises plugin_failed. Every operator action is logged at info (operator action, with action, type, name) and, when carried out, is an audit event (Manager.Do calls Persistence.AuditOperation through Manager.AuditOperationsWith, which api.NewServer sets to its persistence, so distributed mode goes over RPC): action started/stopped/restarted, kind service (persistence.AuditKindService), record id <type>/<name>, actor from the request context; a refused action (unknown, not controllable, busy, not valid in the state, shutting down) records nothing, a start that failed is recorded. The UI posts plain forms under CSRF (redirect and flash); the table fragment (GET /ui/services/list) polls itself with htmx, every second while an action is in progress and every 3 s otherwise.

Message page: deliveries and actions

The message page (internal/api/ui/routes_messages.go, message_detail.html) shows the message’s Deliveries (deliveryRow: target, state badge, attempts, next attempt for a retrying delivery only, last error, updated) with an Actions column holding the delivery’s button and, above the table, the message-level Redispatch, both from the same functions as REST (persistence.CanRedispatch, DeliveryState.Redeliverable), so the page cannot offer what the persistence refuses. State badges come from DeliveryState.Tone() through deliveryTone (TestEveryDeliveryStateHasABadge). Redispatch is a plain form (POST /ui/messages/{id}/redispatch, CSRF, 303 back with a flash; a refused one is the page again with 409 and the reason as flash). Redeliver to <target> / Send again to <target> is one icon button in the Actions cell of each redeliverable delivery’s row (hx-post to POST /ui/messages/{id}/deliveries/{delivery_id}/redeliver, hx-confirm naming the target, hx-swap="none"; the answer is a flash and webui.PageRefreshed, a refusal the same with the reason). A page about a delivery that is not one of the message’s is a 404. REST: POST /api/v1/messages/{id}/redispatch and .../deliveries/{delivery_id}/redeliver answer the message resource; rest.WriteFailure maps the refusals to 409 NOT_REDISPATCHABLE / NOT_REDELIVERABLE (details.stage|state, details.allowed). A delivery_changed line on the page’s stream carries the delivery’s row out of band (hx-swap-oob on delivery-<id>: in place for a changed delivery, appended to #delivery-rows for a new one), so the table follows live, button included (the row is rendered by the same deliveryRow as the page, with the session’s CSRF token). Every line of the stream is followed by a message-state event carrying, out of band, the parts that follow the message as it is now (its own event, swapped by a hidden hx-swap="none" sink, because the history event goes into a table body where an article or a div does not parse): the Message Information section (message_info: status, stage, error, updated), the Redispatch bar (redispatch_bar, empty while the message may not be redispatched) and the modules’ sections as one container (message_panels, so a section a module adds later has a place), rendered from the stored message by currentMessageParts.

Live tails

The stream machinery is the neutral package internal/eventstream (generic Feed[E]/Serve[E] over a Source naming subject, decoder, entry id and stored replay; eventstreamtest has the SSE test client and a subscription-counting dispatcher); internal/api/events binds it to the message history and holds the log ring. A module serves its own streams with it and wraps them with webui.Host.Stream (the server’s stream limit), and renders rows with Host.RenderFragment; the pager is webui.Pager. The message pages and the Logs page (/ui/logs) stream over SSE with the vendored htmx SSE extension (htmx-ext-sse.js, same version as htmx.min.js) and static/js/tail.js (pause, row cap). internal/api/events writes text/event-stream with per-write deadlines (http.ResponseController, never a server WriteTimeout); middleware that wraps the ResponseWriter must implement Unwrap.

  • Messages: whatever stores a message history line announces it on persistence.MessageHistorySubject (persistence.PublishMessageHistory, wired as WithMessageHistoryNotifier). The kinds are status_changed, redispatch_requested, redeliver_requested (the operator’s actions), delivery_changed (every delivery transition, with the target’s text; the status_changed line a transition causes carries no text of its own, while a refusal’s and the dispatch’s “No route matched” do), mdn_received, mdn_refused and duplicate_received. Each open stream is one dispatcher subscription with a bounded buffer; a client that overflows is disconnected and resumes from Last-Event-ID via ListMessageHistoryAfter, or gets a reload event beyond events.DefaultResumeLimit. UI: GET /ui/events/messages, GET /ui/messages/{id}/events; REST: GET /api/v1/events.
  • Logs: internal/logstream wraps each process’s slog.Handler and publishes every record (also standard library log output) on events.log without ever blocking or logging (bounded queue, drop count reported, text truncated); each process attaches with its source name (logstream.Attach). LOG_STREAM_LEVEL sets what is published. The API process keeps one events.LogRing whose sequence numbers are the SSE ids; a stream is a cursor on it, filtered on the server (level, text, process, message id). Newest line first. The process column and filter exist only in distributed mode. No REST counterpart.
  • Logs are shown as logged, to everyone with a UI session; there is no redaction. Attribute values are resolved with slog.Value.Resolve(), so a slog.LogValuer (a masked secret type) shows its masked form: anything sensitive must be a LogValuer or must not be logged. With OTEL_ENABLED the same lines leave the process, to the OTLP backend, under the same rule.

Database console (developer build)

internal/dbconsole is a module (persistence and UI, no plugin type, no REST) that only the developer build imports, from cmd/microb2b/developer_dbconsole.go; its symbols and page text are in TestReleaseBinaryContainsNoDeveloperCode’s lists. The page /ui/database (System section, icon database) lists the tables (ListTables, DescribeTable: columns from sqlite_master/pragma_table_info or information_schema, row count, only for a name the catalog lists), runs one statement (dbconsole.Statement, ParseStatement: a lexical scan that refuses a second statement after the first top-level ;, at most 64 KiB) and keeps the 50 latest statements in its table dbconsole_recent_statements (unique by SHA-256, upsert then prune; server side so the page needs no script). Execute runs the statement on one *sql.Conn in a transaction of its own (ExecTimeout 10 s, rolled back on failure or timeout), decides table versus text by the result’s columns (never the keyword), bounds the result (MaxRows 200, MaxValueRunes 200, MaxResultBytes 256 KiB, Truncated), scans every value into sql.NullString (NULL is not ‘’; non-UTF-8 bytes as 0x hex) and reports rows changed on SQLite from total_changes() (PostgreSQL: not known). A refused statement is the page again with 400 (webui.Page.Status); a database error is a result. Distributed mode goes through the module’s RPC to the persistence process. Never log a statement’s text or a value.

Logging

Levels: INFO is for things that happened, not for things that were checked. The log tail shows INFO and up and the Logs page itself makes requests, so a line that fires per request or per tick with nothing having happened is DEBUG; lifecycle and per-message lines are INFO. The access line (requestLevel, internal/api/middleware/logging.go): below 400 DEBUG, 401/403 WARN, other 4xx WARN (DEBUG on static assets and the stream/poll paths), 5xx ERROR. A plugin loop is silent at INFO on an idle tick.

Every log line that pertains to a message carries the message id, as message_id, and the call site never writes it. The id travels in the context.Context (internal/logctx), and logctx.Handler, outermost on every process’s logger (outside logstream.Wrap, so the log tail has it too), adds the context’s attributes to each record.

  • Entry points put the id in the context once and pass ctx down: delivery.Worker (every claimed delivery), as2.Receiver.HttpHandleFunc (logctx.EnsureMessage), file.Poller.DispatchFile, the async MDN sender and receiver, the REST/UI handlers acting on one message, the message history notifier. logctx.WithAttrs adds other per-message attributes. A goroutine that outlives its request keeps them with context.WithoutCancel(ctx). A line about a delivery also carries delivery_id (logctx.WithDelivery; the delivery worker and the persistence’s delivery methods set it, logctx.Headers carries it over the persistence RPC, and sloglint forbids writing it by hand like message_id).
  • Log with the context: slog.InfoContext(ctx, ...), never slog.Info(...) where a context exists, never the standard library log, never a message id attribute by hand. Lines not about a message (startup, lifecycle, NATS) stay context-less.
  • Enforced by sloglint (context: scope, forbidden id keys) and forbidigo (log.Print*/Fatal*/Panic*), scoped by the path-except rule in .golangci.yml to the packages that process messages or answer requests (the modules’ message code and their rest/ui, internal/api, internal/webui, internal/notification, internal/alert, internal/eventstream, internal/dispatcher, internal/delivery), so a request handler’s lines carry user (and message_id where it applies); to bring a package under the rule, add it there and fix what the linter reports.
  • Tests: testutil.CaptureLogs(t) records through the application’s handler; LogCapture.MessageIDs reports lines without, or with more than one, message id.

OpenTelemetry

Tracing, metrics and logs in internal/telemetry/, enabled by OTEL_ENABLED=true and exported over OTLP (OTEL_EXPORTER_OTLP_HEADERS carries a hosted backend’s credential, e.g. Uptrace’s uptrace-dsn); the operator’s view is “Telemetry” in docs/deployment.md. main installs the providers for every command (standalone, persistence, each plugin process) and shuts them down when the command has ended. Logs reach OTLP through telemetry.LogHandler (the otelslog bridge behind a level filter), one branch of an slog.NewMultiHandler inside logctx.Wrap, beside stderr and the log tail: it exports what the log tail publishes (LOG_STREAM_LEVEL, never below LOG_LEVEL), with the record’s attributes and its context’s trace and span. initLogging installs it with the global logger provider, which drops records until InitLogs installs the exporting one (and always without OTEL_ENABLED). Every histogram is recorded as a base-2 exponential histogram (telemetry.ExponentialHistograms, a view on the meter provider; telemetrytest installs it too), so no instrument chooses bucket boundaries; an explicit histogram’s defaults are made for milliseconds and would put every duration in seconds in one bucket.

  • Spans only within a trace. Dispatcher.Publish(ctx, ...), Request, the subscription and respond handlers, TracingQueryHook (bun) and telemetry.HTTPTransport record a span only when the context already has one, and so do delivery.claim, delivery.settle and delivery.sweep (telemetry.StartTraced), so background traffic (log tail, alerts, live updates, polls, sweep passes) makes no root traces; an asynchronous receipt’s delivery.settle links to the delivery’s trace; publish with context.Background() for traffic that belongs to no message.
  • Stored work keeps its trace: telemetry.TraceParent (W3C traceparent) is stored on each delivery at its dispatch (deliveries.trace, persistence.DeliveryClaim.Trace) and on each pending async MDN. Every wake-up is published under the delivery’s trace (pendingWakeup.trace, TraceParent.Context), and delivery.Worker starts delivery.attempt under Claim.Trace when it came to the job without a trace (catch-up, poll; TraceParent.Into). A new kind of stored job stores TraceParentOf(ctx) when it is queued and returns it in its Claim.
  • What a span may hold: never a URL of an outgoing request (webhook URLs carry tokens), a query string, a header, an SQL statement or value, or a payload. Servers are wrapped by telemetry.HTTPHandler (otelhttp, public endpoint: an incoming traceparent is a link, not the parent; static, /health and event streams unrecorded; a span is named <method> <route>, the route the innermost mux matched: a mux mounted under a prefix of the server’s, the API’s and the UI’s, is wrapped in telemetry.Route, because the middleware between them clones the request and its pattern would otherwise be lost, leaving every page /ui/; the metric’s http.route is the same), clients by telemetry.HTTPTransport (own span <method> <host[:port]>: method, origin (also as url.template), status, never path or query; propagates nothing to the remote side). Tests capture spans with telemetrytest.CaptureSpans.
  • Metrics beyond the microb2b_* instruments: Go runtime (contrib/instrumentation/runtime, started by InitMetrics), http.server/client.request.duration, and the connection pool (telemetry.ObserveDatabase, from initializeDatabase).

Key Environment Variables

VariableDefaultDescription
DB_TYPEsqlite3sqlite3 or postgres
DB_URLfile:microb2b.dbDatabase connection string
DB_MAX_OPEN_CONNS25Postgres only: maximum open connections per process (0 = unlimited)
DB_MAX_IDLE_CONNS10Postgres only: idle connections kept; at most DB_MAX_OPEN_CONNS when that is above 0
DB_CONN_MAX_LIFETIME30mPostgres only: maximum connection age, a Go duration (0 = forever)
DB_CONN_MAX_IDLE_TIME5mPostgres only: maximum idle time of a connection (0 = forever)
BUNDEBUG-1 or 2 logs every SQL query
HTTP_PORT8080API server port
DEVELOPER_LISTEN_ADDRESS- (loopback)Developer build only: IP address the API server listens on instead of 127.0.0.1/::1 (0.0.0.0 or :: = every interface); a release build listens on every interface and does not read it
ALLOWED_ORIGINS-CORS origins, comma-separated or *; empty allows same-origin only
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY-Standard AWS SDK credentials of S3 payload storage (the storage itself is a global setting, System > Settings)
LOG_LEVELinfodebug, info, warn, error; anything else is info
LOG_STREAM_LEVELinfoLowest level a process publishes to the log tail (cannot go below LOG_LEVEL)
NATS_USER, NATS_PASSWORD-Distributed mode: user name and password for the external NATS server (give both)
NATS_PASSWORD_FILE-File holding the password instead of NATS_PASSWORD (a Docker or Kubernetes secret)
NATS_TOKEN, NATS_TOKEN_FILE-Distributed mode: a NATS auth token, or the file holding it
NATS_CREDS_FILE-Distributed mode: NATS user credentials file (JWT and nkey seed)
NATS_TLS_CA_FILE-PEM bundle that signed the NATS server’s certificate (default: system roots); setting it turns TLS on
NATS_TLS_CERT_FILE, NATS_TLS_KEY_FILE-Client certificate and key for mutual TLS (give both)
SECRETS_KEY, SECRETS_KEY_FILEunset (release: required where secrets are sealed or opened; developer: a fixed developer key)Master key of the stored secrets and private keys: base64 or hex of exactly 32 random bytes, exactly one of the two. Read by standalone, the API server and the AS2/HTTP poster/notifier processes and microb2b secrets, never by the persistence process
SECRETS_PREVIOUS_KEYS, SECRETS_PREVIOUS_KEYS_FILEunsetEarlier master keys of a rotation (commas, spaces or lines between them): they open, never seal
AUDIT_KEY, AUDIT_KEY_FILEunset (no hashes)Key of the audit log’s hash chain: base64 or hex of at least 32 random bytes, exactly one of the two. Optional; read by the processes that write the log (standalone, persistence) and by microb2b audit
PUBLIC_URL- (required in a release build)API server process: the address browsers reach MicroB2B at, https://host[:port] without a path (http only for localhost, 127.0.0.1, ::1 in a release build; on any host in a developer build); an identity provider’s redirect URI is <it>/ui/auth/<provider name>/callback (no provider is offered without it), a setup link <it>/ui/setup/<token>; microb2b users reads it to print whole links. Optional in a developer build (links are then paths)
SESSION_KEY, SESSION_KEY_FILE- (one required in a release build)API server process: key material for the session cookies, base64 or hex of at least 32 random bytes (openssl rand -base64 32). Optional in a developer build (a fixed developer session key: sessions outlive a restart)
SESSION_TTL8hLength of a login, 5m to 72h
NATS_ALLOW_UNAUTHENTICATEDfalseDistributed mode: true lets a process connect to a NATS server without credentials (logged as a warning); without it such a process refuses to start
OTEL_ENABLEDfalsetrue enables tracing, metrics and logs export
OTEL_EXPORTER_OTLP_ENDPOINTlocalhost:4317OTLP endpoint (TLS)
OTEL_EXPORTER_OTLP_INSECUREfalsetrue = plaintext OTLP (collector without TLS)
OTEL_EXPORTER_OTLP_HEADERS-Headers of every export, k=v,k2=v2 (read by the SDK), e.g. uptrace-dsn=<DSN>
OTEL_SERVICE_NAMEmicrob2bService name
OTEL_SERVICE_VERSION1.0.0Service version

The NATS URL of distributed mode is the --nats flag, not a variable; its credentials and TLS (internal/config/nats.go -> nats.ClientOptions, validated in NewNatsClientDispatcher) are the NATS_* variables, never flags. A client without credentials refuses to start unless NATS_ALLOW_UNAUTHENTICATED=true. The URL never carries credentials (Validate refuses user info in it and names the variables); log NATS URLs only through nats.RedactURL, which replaces any user info. Plugin modules have no variables of their own. PUBLIC_URL and SESSION_* belong to the API server process only (read by auth.Select in both builds: required in a release build, optional in a developer build; the providers are records); no other process reads them, except that microb2b users reads PUBLIC_URL to print whole setup links.

Testing Considerations

  • testutil.SetupTestDB(t, modules...) gives an in-memory SQLite database with the schema of every persistence plugin registered in the test binary plus those passed; it knows no protocol module. A module’s tests use its test-support package (as2test, filetest, httpposttest): Setup(t) returns the database and the module’s persistence, Persistence() is the plugin to pass for a multi-module database. Only tests import these.
  • The core’s tests never import a module (internal/module/architecture_test.go). Host tests in internal/api/ui use fake extensions (fakeExtension, installPluginCounts, installRouteInstances); a module’s screens are tested in the module through internal/api/ui/uitest, which mounts the extension in the real page chrome. The exceptions are the end-to-end tests in internal/api (e2e_*_test.go), which assemble the server with the real modules and drive it like a browser (session, CSRF, method override).
  • Parity tests (internal/api/resource_parity_test.go, the modules’ ui/parity_test.go, routes in e2e_test.go) send one submission as a form post and as a REST request and require the same outcome; a new rule or record gets cases there.
  • Tests need no build tag: the API server tests give NewServer an authtest authenticator, and internal/auth tests the release Select (fail closed) untagged and the developer build’s button, open REST API and cookies under //go:build developer; the login and OIDC tests are untagged because only Select, the cookie names and developer.go depend on the tag. Run go test -short ./... with and without -tags developer.
  • Browser tests (test/playwright, just test-playwright; not part of just test): Playwright tests (TypeScript, playwright/test) of the web UI against a real standalone developer build. run.sh starts it with --reset-db, a temporary database and payload directory, free ports and PUBLIC_URL set to its own address (so setup links are whole links); HTTP_PORT and PW_RECEIVER_PORT override the ports. The seeded AS2 receiver’s fixed port may be taken, so the first start only seeds, the receiver is moved to PW_RECEIVER_PORT in the database, and the second start is the server the tests use; run.sh then runs the suite and stops the server. Every /ui/ page needs a session: the project developer-session (tests/developer-session.setup.ts) signs in with the login page’s “Sign in as developer” button and saves the storage state the project chromium (tests/) runs with; the project local-auth (tests/local-auth/) starts every test without a session and covers the sign-in itself (the developer button, and users it creates with microb2b users create over the server’s database and sets up with a password and TOTP, accounts.ts computing the codes). Playwright comes from test/playwright/node_modules (npm install) or else the global installation, and the browser from the Playwright cache. Every test runs with the page guard of fixtures.ts: a console error or warning, an uncaught exception, a CSP violation, a failed request or a 4xx/5xx answer to anything but a navigation fails it, so each page a test visits is also checked for htmx and CSP errors. Tests share one database and use unique names; they use auto-waiting locators, never sleeps.
  • Load test (test/selftest-load, just test-load; a command, not a go test, so go test -short ./... never starts it): A and B, two standalone developer-build processes with their own SQLite databases and free ports, send each other N files per protection profile (plain, signed, secure, async; one phase on a fresh pair each, fixtures rendered by the tool with a generated certificate pair), all staged before A starts. It fails when a file has not exactly one Delivered message on A and one on B, or B’s output differs from the input, and reports throughput, per-message times (from the logs: the database has second resolution), /proc peak RSS and CPU and the WARN/ERROR lines. -profile starts both processes with --pprof, takes CPU profiles back to back (1 s chunks, merged) with a goroutine summary after each while the phase runs, heap, allocs, mutex and block profiles at its end, keeps the scratch directory and prints go tool pprof -top lists in the report; see docs/deployment.md “Load testing”.
  • -short skips a few long-running tests; interop tests against live partners skip unless their environment variable is set (e.g. OPENAS2_OUTBOUND_INTEROP=1) and run in the .gitea/workflows interop jobs. Mocks are in mocks_test.go files; AS2 tests use real certificates and captured partner traffic (test/samples, internal/protocol/as2/testdata).

Common Gotchas

  1. Generated Code: RPC client/server files and UI form files are generated and not committed. Always run just generate after modifying an interface definition, a form’s spec or its struct; a checkout without it does not compile.
  2. Schema changes: a table is created from its Bun model (bun/models.go) by its persistence plugin’s Schema.Create (create-if-not-exists, never alters). Change the model and reset the database (--reset-db, which just run passes; developer build only); see Rules.

graphify

This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.

Rules:

  • For codebase questions, first run graphify query "<question>" when graphify-out/graph.json exists. Use graphify path "<A>" "<B>" for relationships and graphify explain "<concept>" for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
  • If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
  • Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
  • After modifying code, run graphify update . to keep the graph current (AST-only, no API cost).