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 onPATH;just lintruns$(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, sojust lintis 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/gofmtmay shadow the toolchain’s onPATH, and an older one fails to parse Go 1.27 generic methods withmethod 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. Usejust fmt/go fmt ./..., which resolve through the active toolchain, or$(go env GOROOT)/bin/gofmtif 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 testThe 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.Selectrefuses to start the API server (the process exits non-zero, naming each bad variable) unlessPUBLIC_URLandSESSION_KEY/_FILEare set and valid. Without providers and without users it starts with the password form alone; the first user is made withmicrob2b users create, who then adds the providers. No variable, flag or request disables authentication. - Developer (
-tags developer): the sameauth.Loginplus 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 recorddeveloper(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, asdeveloper; 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_URLandSESSION_KEYare optional (withoutPUBLIC_URLsetup links are paths; withoutSESSION_KEYits cookies are sealed with a fixed developer session key,developerAccess.sessionKey, so a session outlives a restart); providers are offered only whenPUBLIC_URLis set. The developer pieces (button, open API, banner) areinternal/auth/developer.go, hooked in through thedeveloperDoorinterfaceselect_developer.gopasses tonewLogin(release passes nil);TestReleaseBinaryContainsNoDeveloperCodelistsauth.developerAccess,auth.newDeveloperAccess,auth.developerIdentityand the banner text.--reset-dband--fixturesexist only here (cmd/microb2b/developer.go). Its API server listens on loopback only (127.0.0.1, and::1where the host has it) unlessDEVELOPER_LISTEN_ADDRESSnames another IP address (internal/api/plugin/listen_developer.goreplaces the untaggedlistenAddresses, which listens on every interface;config.LoadDeveloperListenAddressis in a developer-tagged file). The compose stack sets it to0.0.0.0for itsapiserverand publishes every port on the host’s127.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 throughcommandOptionsonstandaloneandpersistence) servesnet/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-releaseandcontainer-releasenever do. A script that calls the REST API needs no key against it. - Developer-only code goes in a file guarded by
//go:build developerand hooks in through a variable the untagged code declares (commandOptions,schemaInitializer,fixtureInitializer), never through anifon a runtime value. Both variants must build, vet, lint and passgo 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  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
- Standalone (
microb2b standalone,just run): every module’s plugins, the persistence and the API server in one process, with an embedded NATS server. - 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, …). Seedocker-compose.yml(just container, thenjust 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 throughdelivery.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.Requestanddispatcher.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 isdispatcher.ErrNoResponder). Both are inplugin.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
queueddelivery for each target whose route matches, a file writer’s worker is woken ondelivery.<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.Persistenceholds the core’s data (messages and their history, routes, organizations, partners, certificates, plugin configs); each module has its own interface (as2.Persistence, …) and aPersistenceAdaptercombining 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
buntags; a table’s model embeds an unexported struct with the record’s fields in its order and of its types plus thebuntags (receiverColumns,deliveryColumns,partyColumns, a settings group withembed:<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 isbun:"-"(Message.Origin). Only a row whose shape really differs (JSON columns, pointers, a nullable string) is converted by hand. A value whoseValuerefuses 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 asrefused_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 apersistenceBun.Schema(Models,Indexes; the core’s isCoreSchema, each module’s bun package hasvar Schema) whoseCreate(create-if-not-exists, at startup),Reset(--reset-db, tests) andRegisterread 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,Localover the database,Remotethe RPC client,Servethe RPC server), declared once as<module>/plugin.Persistence; its plugins, UI and REST open the module’s persistence withPersistence.Open(deps), and a plugin type whose instances are the enabled configurations is discovered byplugin.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 throughbun.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 onerrors.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 viewdocs/protocol-agnostic-routing.md; the file poller and the AS2 receiver dispatch, the AS2 sender, file writer and HTTP poster aredelivery.Deliverers).Persistence.InsertAndDispatchMessage/DispatchMessagematch the routes of a message once, in Go (persistence.MatchRoutesover the route cache), and in one transaction insert onequeueddelivery (deliveries, unique per message and target instance) per distinct target, move the message to the stagedispatched(settled, statusNotRouted, with onemessage_not_routedalert, when nothing matched) and write the history. A dispatch happens once: the message leaves the stageacceptedby a conditional update, andDispatchMessageon any other stage changes nothing and returns the deliveries. A target namepersistence.DeliverySubjectrefuses fails the dispatch withErrInvalidTarget.RefuseMessage(id, refusal, reason)settles an accepted message with no deliveries:RefusalPartnerendsRefused(no alert),RefusalInternalendsError(message_failed). - Vocabulary: a message has a
persistence.MessageStage(accepted: its source owns it;dispatched;settled) and apersistence.MessageStatus(Received,InProgress,Retrying,AwaitingReceipt,Stalled,Delivered,NotRouted,Refused,Error); a delivery has apersistence.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 ofDeriveStatus(stage, refusal, deliveries)wins:acceptedisReceived; no deliveries isRefused(RefusalPartner),Error(RefusalInternal) orNotRouted; then anyfailedisError,stalledStalled,retryingRetrying,queued/deliveringInProgress,awaiting_ackAwaitingReceipt, elseDelivered. 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, withSourcePluginpersistence.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, iconcompose; RESTPOST /api/v1/messages,multipart/form-datawithfrom,to, optionaltargetandfile, 201 with the message detail): both bindSendMessageForm(party pickers without Any, an optionalPickof the enabled route targets) over the multipart values, thenSendMessage.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 ontarget),ReadUpload(one non-empty file of at mostMaxSendMessageSize, 64 MiB;requestBodyLimitallowsMaxSendMessageRequestSizeon both paths) andSendByHand(storage.Payloads.InsertAndDeliver, source@operator, logged asoperator actionwithactionsend). A refusal is 400:INVALID_INPUTkeyed by field over REST, the page again with the field errors. - Every transition runs
bunPersistence.transact: the conditional write, thedelivery_changedhistory line andrecomputeStatus(the message’s stage and status frompersistence.DeriveStatusover its deliveries; nothing else writes them,TestStoredStatusIsTheDerivedStatusholds 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;TestAClaimMakesNoQueryItDoesNotNeedpins the claim’s queries. What must happen after the commit is collected ineffectsand done byfinish: 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:
ClaimDeliveryis a compare-and-swap with a lease (queued;retryingwhen its next attempt has come or the sweep has woken it,retryIsDue;stalledonly withfromStalled),CompleteDelivery(id, token, outcome)/ReleaseDeliveryare conditional ondeliveringand the claim token (persistence.ErrClaimLostotherwise),SettleDeliveryapplies an out-of-band receipt todelivering,awaiting_ackorretryingand 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’sCreateRoute/UpdateRoute/DeleteRouteafter the write, by apersistence.RouteChangeonevents.routesfrom another persistence (bun.WithRouteChanges(disp), standalone and persistence process) and afterpersistence.RouteCacheTTL(30 s). internal/delivery(core, names no module):delivery.Worker[T]works the jobs of adelivery.Store[T](Claimwith a lease,Complete,Release,ListDue; every write conditional,persistence.ErrClaimLostotherwise) with adelivery.Deliverer[T](Deliver(ctx, T) Outcome, outcomes fromdelivery.Delivered/AwaitingAck/Retry/Failed), configured bydelivery.Config[T](name, store, deliverer, optionalWakeupssubject and decoder,Annotatefor log attributes,Options). It learns of jobs from its wake-ups, a look at the store atStart, and, withOptions.PollInterval, a look at that interval and onPoke;RunOnceworks 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 isdelivery.NewTargetWorkerover its deliveries (Deliverer[delivery.Attempt]): it subscribes todelivery.<instance>(persistence.DeliverySubject), takes a slot ofOptions.MaxConcurrentper 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 recordedRetryis woken by the sweep when it is due. AtStartit takes up the target’s due deliveries (ListDueDeliveries: queued, retrying and due or woken, stalled).Quiesceunsubscribes and claims nothing more;Stop(ctx)waits for running attempts forDrainGrace(5 s) or until 2 s beforectxends, then cancels the worker’s lifecycle context so stuck I/O aborts, and waits for the attempts to return. An attempt context iscontext.WithoutCancel(handler ctx)(keeps message id, delivery id, trace) with a cancel tied to the lifecycle andAttemptTimeout;as2.sendMessageand every other I/O of a target use it. A delivery the stop interrupted is given back withReleaseDeliveryon a context that is not cancelled (2 s):queuedwhen the attempt had not started its I/O,retryingand due now when it had; a stop fails nothing. The target plugins implementplugin.QuiescerthroughWorker.Quiesce.- The sweep (
persistence.Sweeper, implemented by the Bun persistence only,internal/persistence/bun/sweep.go; the loop isdelivery.StartSweep, everypersistence.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 aftermanager.StartAll(so its first pass finds the targets subscribed) and stops it before the plugins; the persistence process starts it beside its RPC servers withbun.NewSweeper. One pass, each step in batches ofpersistence.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) becomeretryingdue now with the history line “attempt interrupted: lease expired”; duequeued/retryingdeliveries getdispatch_count + 1,next_dispatch_at = now + persistence.DispatchBackoff(count)and a wake-up, or, atSettings.Delivery.MaxDispatches(read once at start), becomestalled(“not claimed byafter wake-ups”) with one delivery_stalledalert (subject the message, details target/wake_ups/from/to, dedupe keydelivery_stalled:<target>);awaiting_ackpast its deadline (DeliveryOutcome.Deadline, set by the target) becomesfailed(“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_atnow holds the next back-off step (dispatch_count > 0). Tests driveSweep(ctx, now, limits)with the persistence’s injected clock (WithClock) and the worker’sOptions.Now. - Redispatch and redeliver are the operator’s two actions (
bun/actions.go, one transaction each throughtransact, so the wake-ups follow the commit):Persistence.Redispatch(ctx, messageID)matches the routes again for any message that left the stageaccepted(persistence.CanRedispatch), inserts the deliveries of targets it has none for, puts queued and stalled ones back toqueued(no attempts, wake-up count 0,last_errorcleared) and leaves delivered, failed, retrying, delivering and awaiting ones alone; a call that changes nothing succeeds withchangedfalse and writes no history (REST 200 unchanged, the UI flash says nothing was to redispatch);Persistence.Redeliver(ctx, deliveryID)does that for onefailed,stalled,retryingordelivereddelivery (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 inDelivery.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 writeredispatch_requested/redeliver_requested(the user of the context,logctx.User, in the text; the latter withdelivery_idandtarget), then thedelivery_changedlines and the recomputed status, and return the message. A refusal is*persistence.NotRedispatchableError{Stage}(only for a message stillaccepted) /*persistence.NotRedeliverableError{State}(codesnot_redispatchable/not_redeliverableinErrorVocabulary;Allowed()is what would have been accepted), a missing recordsql.ErrNoRows;persistence.FindDeliverychecks that a delivery belongs to the message of a request. Each logsoperator actionat INFO (actionredispatch/redeliver;user,message_id,delivery_idcome 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 gaugesmicrob2b_deliveries{state}andmicrob2b_deliveries_dueand countermicrob2b_deliveries_stalled_total{target}.
Storage
internal/storage/: payload blobs, separate from metadata, addressed by message id and name (StoreBlob(id, name, data),GetBlob);LocalFileStorageorS3Storage, built bystorage.NewStorage(ctx, persistence.StorageSettings)from the global settings (see “Global settings”). Every process callsstorage.Openonce at startup (standalone over the database, a plugin process over the persistence RPC) and keeps that storage until it restarts;RuntimeDependencies.PayloadStorageSettingsare 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 byProbeTimeout, reporting no credential.- Message content goes through
storage.Payloads, by message id andpersistence.PayloadStage(unpacked= the message’s own payload,received,headers, the composedmessage= headers + received, andreceipt= 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=), RESTGET /api/v1/messages/{id}/payload?stage=(persistence.PayloadFileNamenames both). A target’s “Payload Stage” offerspersistence.DeliverablePayloadStageOptions(every stage butreceipt, which is recorded after the dispatch;PayloadStage.Deliverable). A new message is kept withPayloads.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 withStore, 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/Availablego 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;NewCertificatechecks a key against its certificate), thecertificatestable, and the certificate methods ofpersistence.Persistence(a missing certificate issql.ErrNoRows, like every missing record). Uploads (PEM, DER, PKCS#12) are read byinternal/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.
CreateCertificatereturns*persistence.CertificateExistsError(matchesErrCertificateExists, names the stored one); REST answers 409CERTIFICATE_EXISTS, the UI re-renders the upload page with status 409 and the same message. Both go throughpersistence.StoreCertificates, which stores an upload all or nothing. Certificates are audited (kindcertificate): an event records fingerprint, description andhas_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.PrivateKeyis apersistence.SealedPrivateKey(Bytes, an encrypted PKCS#8 PEM, columnsprivate_key_bytes/private_key_kid), encrypted byNewCertificate(description, cert, key, keys)at upload, refused in the clear byDeriveFields(so by every write), carried over the persistence RPC encrypted, and decrypted only where it is used byCertificate.Signer(keys), which also checks it belongs to the certificate and returns acrypto.Signer(an RSA one is acrypto.Decrypter). A response is made ofCertificate.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, apersistenceBun.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 withpersistenceBun.WithCertificateReferences(standalone, the persistence process’s RPC server,testutil.SetupTestDB); the core names no module.DeleteCertificateruns them and the delete in one transaction (serializable on PostgreSQL) and refuses with*persistence.CertificateInUseError(matchesErrCertificateInUse, in the rpcerr vocabulary; at mostCertificateUsersShownusers,Totalcounts all): REST 409CERTIFICATE_IN_USEwithdetails.users/details.total, the UI the certificate page again with 409 and the same message (for htmx: the message as flash andHX-Refresh).CertificateUsageis 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: aRef/Refswith a certificate source), so a new certificate field without a declaration fails. The other modules name no certificate. internal/certmonitoris an application-wide plugin (certificate-monitor) that logs expired and expiring certificates hourly and reports them in themicrob2b_certificates_expirygauge.
Version
internal/versionis 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 fromgit describe --tags --always --dirty) unless it is the placeholderdev; else the embedded Go build info (a tagged module version, else the 7-charactervcs.revision,-dirtywhen modified; a plaingo buildin a git checkout has it,go testbinaries do not); elsedev. 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 ofOTEL_SERVICE_VERSIONall use it;/healthdeliberately 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 tablesystem_settings, idbun.SettingsRowID): the payload storage (StorageSettings: backendlocal/s3, a domain string withParseStorageBackend, 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 constantpersistence.DispatchBackoff) and the identification (IdentificationSettings.HideVersion, default false). How MicroB2B names itself isversion.Product():MicroB2B/<version>, orMicroB2Bafterversion.HideVersion(true), which every process calls once at startup with the setting (besidestorage.Open, which returns the settings it read). It is theUser-Agentof every outgoing request (AS2 sender, async MDN sender, HTTP poster, notifier) and theServerof the AS2 receiver’s answers (as2.AnnounceServerwraps the receiver’s whole mux); a replayed stored receipt drops both from its stored headers (receiptHeaders). Our MDNs name it in theirReporting-UAfield too (as2.ReportingUA(our AS2 ID)); a partner’s MDN’sReporting-UA(as2.ParseReportingUA: control characters to spaces, at mostMaxReportingUALengthbytes) is recorded only for an opened MDN (MDNRecord.ReportingUA,as2_messages.mdn_reporting_ua) and shown as “Partner software” andprotocols.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;SaveSettingsrefuses whatStorageSettings.Checkrefuses withErrInvalidSettings, a vocabulary sentinel). A new database getspersistence.DefaultSettings()(local,./payloads) frombun.SeedSettings, which runs after the fixtures, so a fixture’sSystemSettingsrow stands (and is checked like every fixture record).- A new database is seeded once (
persistenceBun.SeedNewDatabase, ingetInitializersofcmd/microb2b, so the standalone process and the persistence process both do it): a database with nosystem_settingsrow 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.SeedDefaultsrunsPersistencePlugin.SeedDefaultsof every registered persistence plugin: the core stores the local organizationlocal, a module’splugin.ModulePersistence.Defaultsstores its default instances (AS2:as2_receiver_default,as2_sender_default; file:file_poller_defaultondata/outboxwith{to}/{filename},file_writer_defaultondata/inboxwith{from}/{original_filename}; no AS2 local profile) and reports each as aplugin.DefaultInstance(Flowinbound/outbound,Rolesource/target);plugin.DefaultRoutesthen routes every source of a flow to every target of it frompersistence.RouteAnytoRouteAny, so modules never name one another. Creates tolerateErrDuplicate, so an interrupted seed can be repeated. A developer build with--fixturesskips 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.SetupTestDBseeds 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/settingsshow 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 ofSettingsGroups, JSON properties prefixed by the group,storage_*,delivery_*,identification_*), with the cross-field rule inValidateForm(fromStorageSettings.Check). UI: System > Settings (internal/api/ui/settings.go,settings.html, which embeds the generic form throughwebui_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,okfalse for a failed probe). A new group of settings: an embedded struct ofresource.Settingswith its section, fields inpersistence.Settingsand columns insystemSettings, and parity cases ininternal/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” indocs/deployment.md):SECRETS_KEY/_FILE(exactly 32 bytes,config.ParseSecretsConfig) andSECRETS_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.SealedJSON 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,SecretLinessealed as one document,SecretURL) of a stored record is a part of the module’ssecret.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 byplugin.ModulePersistence.OpenwithDiscoveryDependencies.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 insecret.Sealedcolumns beside the embedded struct (the domain fields arebun:"-"), which refuse anything else, so a write that bypasses the layer stores nothing.testutil.AssertSealed(completeness against the struct’s generatedWriteOnlyFields, canaries in a rawSELECT, rekey) andtestutil.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, inTestReleaseBinaryContainsNoDeveloperCode’s lists); its fixtures write secrets in the clear andbun.InitFixturesseals them (sealFixture) before they are inserted. Tests usetestutil.SecretKeys(). - Startup check (
persistence.CheckSecretsStore, standalone and every plugin process with a key):ClaimSecretsKeyCheck(the key check value insystem_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 idSealedKeyIDsreports 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;TestThePrivateKeyRecoveryProcedureWorksruns its commands).
Configuration & Fixtures
- Environment variables (see below), read in
internal/config; global settings in the database (above). test/data/fixture.yamldefines the global settings (payloads in./payloads, relative to the working directory), organizations, partners, certificates, AS2 profiles, plugin instances and routes for development;just runresets the database and loads it (intarget/, so payloads go totarget/payloads).
API Server
- A plugin (
internal/api/plugin/, typeapiserver, a singleton); server ininternal/api/, REST API under/api/v1and 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/configsdispatch;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) arerest.RecordResources (handlers/records.go), see “Plugin REST extensions”.- Authentication is one abstraction,
auth.Authenticator(internal/auth), whichapi.NewServertakes as a parameter:AuthenticateAPI(a/api/v1request, a refusal is an*auth.Failurecarrying the 401 code and message),AuthenticateSession(a/ui/browser request;auth.ErrNoSessionsends it to the login),Routes(the public login routes,GET /ui/loginand whatever the flow needs, mounted on the router outside the authentication middleware; the host draws pages throughauth.Pages),Logout(clears the session, says where to go next;POST /ui/logoutstays under CSRF),Banner(a notice on every page, "" for none) andAccounts(auth.Accounts: theusers.ServiceandRenewSessionthe Users and account pages use; nil for an authenticator without users, a test’s).auth.PagesdrawsLoginPage(oneauth.LoginView: the password step or the code step, plusProviders(auth.LoginProvider: path and display name, one “Sign in with” button per enabled provider) and DeveloperPathfor the developer button) andSetupPage.middleware.Authenticateandui.Authenticateput theauth.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 byinternal/api/plugin):select_release.go(!developer) isauth.NewLogin(ctx, config.LoadAuthConfig(false), store, secrets, auth.LoginOptions{}),select_developer.go(developer) isnewLogin(..., config.LoadAuthConfig(true), ..., newDeveloperAccess()).config.ParseAuthConfig(lookup, developer)givesAuthConfig{Session}(ParseSessionConfig(lookup, required = !developer)).auth.NewLogin(ctx, config.AuthConfig, auth.Store, *secret.Keyring, auth.LoginOptions{Now, OIDC auth.OIDCOptions});auth.Storeisusers.Storeplusauth.ProviderStore(ListOIDCProviders), so the login reads the users and the providers from the same persistence. Tests never useSelect; they handNewServeran authenticator, normallyauthtest.New()(bearerauthtest.Tokenon the API, cookieauthtest.SessionCookieon the UI, optional banner), orauth.NewLoginover 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, exportedauth.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_oidcwithoutSecurein the developer build (plain http, also to other machines). All are AES-256-GCM sealed (oidc_seal.go) with a key derived (HMAC-SHA256) fromSESSION_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.AuthenticateSessionchecks it on every request against the user: exists, enabled, same generation, withinSESSION_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 theuserof log lines and the audit actor for every way in. - OIDC login (
internal/auth/oidc*.go,oidcMethodper provider,providers.go; built ongithub.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.SessionConfigfromParseSessionConfig: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_KEYhas a_FILEvariant (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 tableoidc_providers,internal/persistence/bun/oidc_providers.go; persistence methodsCreateOIDCProvider,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 inoidc_providers.client_secret,persistence.OIDCClientSecretField, scope the issuer URL, so a changed issuer needs the secret again), scopes (space-separated, defaultpersistence.DefaultOIDCScopes=openid profile email,CheckOIDCScopes: must includeopenid), API audience ("" = the client id,Audience()). Formresource.OIDCProviderSpec(internal/api/resource/forms.go,resource.OIDCProviderwithValidateFormandSeal(keys);secret.ErrNoKeywithout 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 whenPUBLIC_URLis not set), create and edit through the generated form as awebui.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, arest.RecordResource, list keyoidc_providers;client_secretrequired on create, kept by an update that leaves it out whileissuer_urlis 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’supdated_atis unchanged. No provider is offered withoutPUBLIC_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 afterproviderRetryAfter(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-timestate/noncechecks, code exchange with the PKCE verifier, ID token verified by go-oidc), thenusers.Service.SignInOIDCand a sessionviaoidc:<name>.return_tois 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 indexusers_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 frompreferred_username, else the e-mail’s local part, elseuser(lower case, every invalid character-), with-2,-3, … when taken; display name fromname,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): ownoidc.KeySetover go-jose: the JWKS is cached, an unknown key id refetches at most once perJWKSMinRefetch(10 s), algorithm allowlist RS/ES/PS 256/384/512 (sononeand HS* never verify),Nowshifted back by 30 s of clock skew. - REST bearer (
AuthenticateAPIinlogin.go,oidc_bearer.go):Authorization: Bearer <JWT>; itsiss, 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 codesMISSING_AUTH_HEADER,INVALID_AUTH_HEADER,INVALID_TOKEN(never says why; also a token of no enabled provider),TOKEN_EXPIRED, withWWW-Authenticate: Bearer. The identity is the user record of (issuer,sub) when there is one (its name; a disabled user’s token isINVALID_TOKEN), else the token’s subject (a machine client). A session cookie is not accepted on/api/v1and a bearer token not on/ui. - REST without a provider: a release build without an enabled provider answers every
/api/v1request 401UNSUPPORTED_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 asdeveloper. - Identity in logs: both middlewares put the subject in the context with
logctx.WithUser, so a line logged with that context carriesuser. - Tests:
internal/auth/oidctestis 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.godrives the real server withauth.NewLoginover it;test/oidc/keycloak-check.sh(opt-in, docker) does the same against real Keycloak, with a developer build whose provider it configures overPOST /api/v1/oidc-providers(login, logout, a provider that cannot be reached), then the release build on the same database (Secure cookie, bearer tokens).
- Environment (
- Users and sign-in (operator’s view “Authentication” in
docs/deployment.md). Every way in signs in apersistence.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, overusers.Store; inputs refused as*users.InputErrorby field),internal/credential(argon2idPasswordHashwith its version in the string,$argon2id-v1$, 64 MiB, 3 passes, 4 lanes;CheckPassword: 12 characters to 256 bytes, the embeddedcommon_passwords.txt, not containing the names ormicrob2b, not too repetitive, no composition rules or expiry; TOTP of RFC 6238, SHA-1, 6 digits, 30 s, ±1 step; 10 recovery codesabcde-fghijand 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 tableusers, persistence methodsCreateUser,GetUser,ListUsers,FindUserBySetupToken,FindUserByOIDC,UpdateUser(name, UserChange)(UserChange.PasswordLoginamong 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,ErrUserChangedare inpersistence.ErrorVocabulary. Distributed mode reaches them over the persistence RPC. - Password login is per user: on for a user made with
users createor 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);Resetof a user without it isusers.ErrPasswordLoginOff, and their setup links do not open.EnsureUsermakes the developer user,SignInOIDC/FindOIDCthe 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 withoutCanUsePasswordis 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 withRetry-After; never a lock, so there is no unlock. - UI: System > Users in every build (
/ui/users,internal/api/ui/users.go; 404 whenAccounts()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", whichstatic/js/otp.jsdraws as one box per digit (typing moves on, Backspace back, a paste or an autofill withautocomplete=one-time-codeis spread over the boxes; withdata-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_alternativeincomponents/credentials.html); the login and the setup page take it beforecode(auth.submittedCode).otp.jshas no unit test: Playwright types into the boxes (enterCodeintests/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 ofDB_TYPE/DB_URL(creating the schema) or with--natsover the persistence RPC; needs no session, API server orSECRETS_KEY; prints whole links whenPUBLIC_URLis set. Actorcli:<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 kindpersistence.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), somicrob2b secrets rekeyand the startup key check cover both.
- Pieces:
- 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 passesauth.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_csrfwithSecurein a release build,mb2b_csrfwithoutSecurein the developer build (plain http); the form field iscsrf_token(webui.CSRFField) in both. CORS byALLOWED_ORIGINS. - Hardening in
internal/api/middleware:SecurityHeaders(CSP with nounsafe-inline, so no inline<script>,styleor 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/v1request with a body, and every POST/PUT/PATCH/DELETE with or without one, must declareapplication/json, the certificate uploadmultipart/form-dataplus anX-Requested-Withheader (middleware.UploadHeader, any value), byapiBodyTypeinserver.go; a wrong or missing type is 415UNSUPPORTED_MEDIA_TYPE, a missing header 400MISSING_HEADER, before anything happens, so a cross-origin page cannot send a state change without the CORS preflight;TestEveryRequestBodyIsDescribedWithTheTypeTheServerTakesandTestEveryStateChangeDescribesTheTypeItDeclareshold the OpenAPI document to it; the/ui/routes are under CSRF instead and unaffected) and CORS withVary: Origin. List endpoints caplimitatrest.MaxLimit. CSRF tokens are stateless (HMAC-signed,webui.CSRFTokenManager)./healthpings the database (Persistence.Ping) and names no version. htmx runs withallowEval:falseandselfRequestsOnly:true(ahtmx-configmeta 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, soerrors.Is/errors.Asanswer 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.ErrorVocabularyis the core’s (sql.ErrNoRowsfor a missing record, certificates included,ErrDuplicate,*CertificateExistsErrorwith its id and description,*CertificateInUseErrorwith 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 isrpcerr.Sentinel(matched witherrors.Is, arrives as the sentinel itself, wrapped when the server added text) orrpcerr.Typed(matched witherrors.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.RoundTripsends every entry over a real RPC (internal/persistence/rpc_errors_test.go,internal/protocol/as2/rpc_errors_test.go), andinternal/api/e2e_rpc_errors_test.goruns 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’sforms.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, aValidatemethod,WriteOnlyFields(webui.WriteOnlyLister),<Type>Field<GoName>constants and, forRef/Refs/NameRef/Pickfields, a<Type>Sourcesstruct the caller fills. The doc comment ofcmd/uigen/main.gois 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.Fieldbuilds each widget from a pointer to its field (formspec.Int(&config.Port, formspec.IntOpts{...})only compiles for anintfield), a select’s options are the domain type’s option function, a parse, check orValidateFormis a function value ((*T).ValidateForm), a scope a pointer to the URL field, a source a pointer to a member ofS(the generated<Type>Sources, orformspec.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: 0is a bound), checks what the compiler cannot (every field once, options that go together, sections) and production code never runs a spec;formspec.Checkruns it on a zero value in each package’sforms_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 ininternal/api/resource(Organization,Partner,Route,CertificateDescription,OIDCProvider;-by-id, converted to and from the persistence models, which stay free of the web contract; andSettings,-singleton: the one record of its kind, posting to its own page withwebui.SingletonTarget). - Plugin configs are
webui.CollectionScreen[T](keyed by instance name), AS2 profiles hang off a parent (-parent-base-path/-segment) aswebui.NestedScreen[T], the core’s records arewebui.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.godescribes each kind (partyKind: nouns, paths, store, protocol panels) and serves the list, detail and delete of both overparty_list.htmlandparty_detail.html; the bun persistence stores both with one set of generic functions over their sharedpartyColumns(internal/persistence/bun/party.go). - Write-only values: a
Secret(one value) orSecretLines(Name: valuelines, e.g. extra HTTP headers, which carry API keys) field is never shown or returned. The form renders a secret empty and aSecretLinesfield asName: ********(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 anomitemptyjson 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 bywebui.ConfigFormfor the form (bound over the stored record) and for REST (ConfigForm.Previous), so both answer alike. A struct with write-only fields gets generatedWithoutSecrets(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),KeepSecretsandValidateUpdate;rest.ConfigResourcerequiresRedactandValidateUpdateexactly when the struct has write-only fields (its generatedWriteOnlyFields), and no REST response carries a write-only value.webuitest.AssertWriteOnlyfills 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=). SecretURLis 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 aSecret/SecretLinesfield 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([]Tof 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.Actionsput 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.FieldErroron 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
webuifield types the generated form instantiates. Everywebui.FieldhasBind(parse submitted text, apply the rule) andCheck(apply the same rule to a value already held); the generatedValidate(ctx[, sources], decoded...)builds the form over the struct and runsCheck, 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 overNew<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 thewebuifield, 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.AssertFormCoversEveryPropertyfails on a JSON property the form has no field for. ref/refs/namerefare 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. Arefis one combobox (itsempty=label shows as the value while nothing is chosen), arefsis rows plus an Add box. Awebui.RefSourceprovidesSearch(bounded, stable order),Lookup(the chosen ids),NounandAllows, which is the ruleBindandCheckhold 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.jshas no automated test: keep it small and put every decision in the server-rendered markup, whichinternal/webui/picker_markup_test.gopins. Anamerefis the same combobox over a record addressed by its name (webui.NameRef, awebui.NameSourcewithLookup/Allowsby 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 withwebui_form_fieldand mountui.MessagesPickerBase);webui.NameListis aNameSourceover 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 withwebui_form_fieldfrom aConfigForm’s view and mounts its search withHost.ServePicker. The picker’s search box names a form that does not exist, so no surrounding form submits itsq. Apickchooses one name from a shortwebui.OptionProviderlist (a route’s plugin instances,resource.NewRouteSources).- Sections: a long form declares each section as a package-level
webui.FormGroupvariable (literals; uigen reads them), lists them invar <Type>Groups = []webui.FormGroup{...}, names that list as the spec’sGroupsand gives every shown field theGroupof its section’s variable. Each renders as a<details>that is open whenAlwaysOpen, 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 riskAlwaysOpen. Onlyas2.RemoteProfileandresource.Settingshave sections. - Typed text:
formspec.TypedText(&config.X, ParseX, ...)edits a domain string type with its parse function (func(string) (T, error)); it becomeswebui.TypedText, which applies the text rules (trim, required, length, pattern) and then the parse, whose error completes “ - An
intwhose 0 means “inherit” says so withZeroLabel(empty box with that placeholder); a plainintshows its number. Bool fields render as toggle switches (a real checkbox withrole="switch"). - Changing the generator: model and analysis rule first (
analyse.goowns every check and error; templates only print), then a case incmd/uigen/testdata/forms/models.go.TestGeneratedFilesAreUnchangedcompares the output withcmd/uigen/testdata/golden/*.golden(go test ./cmd/uigen -run GeneratedFiles -updateaccepts an intended change);TestRepositoryFormsCompileruns 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) andSender(sign, encrypt, compress, post, process the MDN);crypto.go(PKCS7 via a fork ofsmallstep/pkcs7, see thereplaceingo.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 withGOPRIVATE=git.dries.info/*(what CI sets) it is cloned anonymously over https fromgit.dries.info;go.sumpins it either way. It differs from upstream in: no package-level algorithm globals (the algorithms are arguments, with anEncryptionAlgorithmtype), 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 inber2der, 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 withauthentication-failedlike 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 newv0.2.3-microb2b-Ntag and thereplacenames 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’sRemoteProfilevouches for (AllowsMDNURL: the host ofEndpointor one ofAsyncMDNHosts; anything else is refused with 400 before anything is stored) and only whenasyncReceiptAllowedsays so: always for a signed and verified message; for a failed verification or a missing signature the profile requires only withAsyncMDNOnSignatureFailure; for any other unsigned message only withAsyncMDNForUnsigned(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 inprocessMessage, 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 thanInboundClaimLeaseand not completed is reported asInboundClaim.Stale, not taken over by the claim itself: the receiver asks the core for the holder’s message, and a holder past the stageacceptedis completed and the resend answered as a repeat, one stillacceptedis taken over withTakeOverInboundMessage(a conditional UPDATE). The receiver turns a message down withRefuseMessage(id, class, reason): the partner’s message refused on its merits (unmatched party, failed unpack or verification, repeat, replay, over the size limit) ispersistence.RefusalPartnerand endsRefusedwith no alert; our own failure (storage, database, a dispatch that failed) isRefusalInternaland endsErrorwithmessage_failed.Receiver.SettleAbandonedruns at Start and refuses theacceptedrows no live process owns. A repeat is not dispatched: same payload digest, the stored MDN (the first message’sreceiptstage) is sent again byte for byte (sync: the response; async: queued for the repeat’s own, profile-vouched URL, still subject toasyncReceiptAllowed) or, when none was kept, a new MDNprocessed/warning: duplicate-document; a different payload is refused withFailureReasonDuplicateMessageID; a repeat of an unfinished message isFailureReasonDuplicateInProgress. The Message-ID is an unsigned HTTP header, so the claim also carriesSignatureDigest(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 isInboundClaim.SignatureReplay, refused withFailureReasonReplayedSignatureand 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’sFailureReasonis ours, its MDN’s disposition the standard’s (failure.go):failureTableis 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’sduplicate-filename, orfailed/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.Failureis never written.FailureReasonsis the canonical slice (ParseFailureReason, validating JSON);TestReceiptDispositionPerOutcomepins each disposition.ParseDispositionreads a partner’s type and modifier in any case. The repeat’s own message row endsRefused(“Repeat of message …”), the first message’s history getsMessageHistoryDuplicateReceived.processMessagereturns aprocessing(receipt plusduplicateOf). See “Messages sent twice” indocs/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 withParseDuplicateFileNameAction) withinDuplicateFileNameWindowHours(0 =DefaultDuplicateFileNameWindowHours= 24, at most 8760).Receiver.checkFileNameruns 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’sPersistence.FindMessageByFileName(party, name, since, except; a message its source turned down, Refused or Error with no deliveries, does not count; indexmessages_from_file_name): a lookup, not a claim, so two same-named messages at the same moment may both pass.warndispatches withprocessed/warning: duplicate-filename;refusesettles the messageRefused(RefusalPartner) withprocessed/error: duplicate-filename(MDN.Refuses: HTTP 400 without an MDN). See “Repeated file names” indocs/as2-configuration.md. - Message size and who is read.
LocalProfile.MaxMessageSizeMiB(defaultas2.DefaultMaxMessageSizeMiB= 128 MiB, 1 toMaxMessageSizeMiBCeiling) is the organization’s limit andOverridableProfileParameters.MaxMessageSizeMiBthe partner’s (0 inherits);EffectiveMaxMessageSizeis the one place they combine.Receiver.HttpHandleFuncresolves 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 byrefuseUnreadwith 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 throughlimitedBody(bodylimit.go:http.MaxBytesReader, refusal byContent-Length, per-read stall deadline and a whole-body deadline on the connection, no serverReadTimeout); over the limit isFailureReasonMessageTooLarge(HTTP 413 when no MDN was requested). The decompression limit ofunpackCompressedDatais the same effective size.MDNReceiveridentifies the partner before reading and bounds the body bymaxMDNResponseBytes. - A partner profile names certificates by whose they are:
Our...are ours, with a private key (picker sourceOurCertificates),Partner...are the partner’s (PartnerCertificates); Go field, JSON, column and label follow the same name.PartnerCertificatedoes both jobs: messages are encrypted to it only whenOutgoingMessageEncryptionAlgorithmis 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,PartnerMdnCertificateswhen set; elsePartnerCertificate∪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_messagesrecords the algorithms, not booleans:MessageDetails.SignatureAlgorithm(MICAlgorithm, the signature’s digest algorithm; notMICAlgorithm, the receipt’s) andEncryptionAlgorithm(EncryptionAlgorithm),""for none, withSigned()/Encrypted()the yes/no;Compressedstays 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,encryptionAlgorithmOIDsincrypto.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 inprotocols.as2.- A new partner profile starts secure (
as2.Default...constants inmodels.go): compressed, signedsha256, encryptedAES-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’sLocalProfile.MdnNotificationTo, whichSender.Sendchecks per send. Sender.Deliveroutcomes (the sender is adelivery.Delivererbehinddelivery.Worker,Sender.WorkerOptions(maxConcurrent)withAttemptTimeoutof 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 throughfailSendand isdelivery.Failed(reason). One that may pass (payload storage, POST, TLS) goes throughretryOrFail:delivery.Retry(now+delay, reason)whileattempts-1 < maxRetries(delay: Retry Base Delay doubling, or the partner’s Resend Interval), elsedelivery.Failed("max retries exceeded: ..."). A synchronous MDN that is good isDelivered, a bad oneFailed("synchronous MDN: ..."); when an asynchronous MDN was requested the outcome isdelivery.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.recordOutgoingstoresas2_messages.delivery_id;MDNReceiverfinds the delivery of an MDN withlocateSentand judges it withSettleDelivery. 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, onetlsClientscache keyed bytrustKeyfor the sender and the async MDN sender).AcceptPinnedTLSCertificateAnyHost(off by default, refused without a pinned certificate inValidateForm) waives the host name check for a leaf that is byte for byte one of the pinned certificates and valid now; that config setsInsecureSkipVerifyandverifyPinnedOrNamed(itsVerifyConnection) 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 throughwebui.ProtocolPanel.Links; RESTPOST /api/v1/as2/remote-profiles/{id}/check-connectionand.../test-message). The connection check isSender.CheckConnection(resolve, connect, TLS with the profile’s trust over the sametlsClients.forProfileas a send but keeping no connection, one GET; stepsConnectionStep/ConnectionStepStatusare domain strings), asked of the running sender instance over the bus (RequestConnectionCheckonConnectionCheckSubject(sender), answered bySender.AnswerConnectionCheck, which the sender plugin registers withRuntimeDependencies.Responderin Start; no answer isErrSenderNotRunning, REST 503SENDER_NOT_RUNNING). The test message isSendTestMessage: fixed content,microb2b-test-<id>.txt, sourcepersistence.OperatorSource, stored bystorage.Payloads.InsertAndDeliverwith one delivery to the chosen sender; it is a normal message.TestChoicesFor/TestRequest.Resolvechoose 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 byReceiverConfig.MaxConcurrentRequests(defaultas2.DefaultMaxConcurrentRequests= 50, 1 toMaxConcurrentRequestsCeiling), 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 beforeresolvePartiesorlimitedBodyread anything, and given back by a deferred release (panics included). A request that finds no slot waits up toRequestSlotWait(2 s) and is then answered 503 withRetry-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_flightandmicrob2b_inbound_requests_refused_total(telemetry.RecordInboundInFlight/RecordInboundRefused, attributesprotocol,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 bytest/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 onPort) andHTTPSEnabled(HTTPS onHTTPSPortwithTLSCertificate, a stored certificate with a key), either or both (ValidateForm: at least one, HTTPS needs the certificate, two listeners two ports), served by onehttp.Serveron the Path and the MDN Callback Path alike (receiverListenersbinds both before serving and closes the first when the second fails).receiver_tls.go,ReceiverTLSConfig: the key opened withCertificate.Signer,TLSIntermediateCertificatesafter it,MinTLSVersiona domain stringTLSVersion1.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 inReceiverConfig.CertificateFields, soas2/bun.CertificateReferenceskeeps 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’sHTTP_PORTdefault (8080); a test keeps them apart. - Asynchronous MDNs we owe a partner are jobs of the receiver instance’s own
delivery.Worker[as2.PendingAsyncMDN]overas2.AsyncMDNStore(as2_pending_async_mdns:Pending,Deliveringunder a claim token with the lease innext_attempt_at,Retrying,Delivered,Failed;ClaimPendingAsyncMDN/CompletePendingAsyncMDN/ReleasePendingAsyncMDN/ListDuePendingAsyncMDNs), not deliveries: their outcome never touches the message’s status.AsyncMDNSenderis theDeliverer(a fresh connection per MDN;Retrywith jittered back-off fromMDNRetryBaseDelaywhileMDNMaxRetriesallows, elseFailed; reasons never name the URL). The worker polls everyMDNPollIntervalandReceiver.OnAsyncMDNQueuedpokes it, so a queued MDN is attempted at once;CompletePendingAsyncMDNraisesasync_mdn_failedafter a failure commits. The receiver plugin implementsplugin.Quiescerfor it. - Inbound Content-Transfer-Encoding is decoded at every layer (
mime.DecodeTransferEncoding, throughSealed.Content): the request body per the HTTP header (entity,requestEntityinunpack.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 isErrTransferEncodingand fails the MDN withunexpected-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):featureTableis the one list of the optional features we know and which we implement;OurFeaturesandannounceFeaturesmake 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 isParseFeatures(Feature/Features,ParseFeature: known names in their registered spelling, others kept as written, junk dropped, at mostMaxPartnerFeatures), recorded asMessageDetails.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” andprotocols.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 toMaxAS2IDLengthprintable 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.TypedTextwithParseAS2ID).ParseAS2IDHeaderreads 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 400ErrInvalidAS2IDbefore 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’sExtensionContentTypeschoose 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 withMkdirAlland fails Start naming the directory and cause when that is impossible or the path is no directory; the processed andunroutable/directories are made lazily, when the first file needs them; nothing else creates directories (no partner folders).Pollerderives sender and recipient from the file’s path:PollerConfig.PathPattern(default{to}/{filename},path_pattern.go) is matched against the path underbase_directorywith the placeholders of one table ({to},{from},{filename}); the scan goes exactly as deep as the pattern, never follows symlinks, and skipsunroutable/and the processed directory.from/toare each a fixed field or a placeholder, never both; nofrommeans 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.Writernames its output withWriterConfig.FilenamePattern(filename_pattern.go): one table of placeholders ({message_id},{from},{to},{original_filename},{original_basename},{original_extension};{a|b}falls back tob) 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 throughpersistence.SafeFileName(the one rule set for Windows-safe names: invalid characters<>:"|?*become_, trailing dots/spaces are removed, reserved device names such asCON.txtget a_prefix); the result must stay under the base directory; an existing file is replaced only withOverwrite.Writeris adelivery.Deliverer(Writer.WorkerOptionsfromWriterConfig.MaxConcurrentDeliveries, default 1, 1 to 100): a failure that retrying cannot fix (stage not recorded, filename pattern error, name exists withoutOverwrite) isFailedat once, any otherRetrys after thefile.WriteRetryDelays(30 s, 2 min, 10 min) and thenFailed; a re-attempt that finds the target file holding exactly the payload’s bytes isDelivered.
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.Modulevalue (name, persistence plugin, plugin types with discoverer and factory, optional UI and REST factories) registered with onemodule.Registercall from theinit()of the module’smodulepackage (internal/protocol/{as2,file,httppost}/module,internal/notification/module; the core persistence, the API server and certmonitor register from their ownpluginpackages).internal/modulevalidates it and adds nothing unless all of it is valid; startup logs every module and what it contributed. Theplugin,uiandrestsubpackages export their pieces and register nothing. cmd/microb2band test setups import only module packages (_ "microb2b/internal/protocol/as2/module"); leaving one out leaves the module out entirely.plugin.Register,RegisterDiscovererandRegisterPersistencePluginare called only byinternal/module;plugin.RegisterCommandalso adds the application’s own subcommands.- An instance name is lower case (
[a-z0-9_-]+, thepatternof every instanceNamefield) 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 isdelivery.<name>). Eachmodule.Pluginlists the names of every instance of its type, enabled or not (Names, built withplugin.AllNamesover the module persistence’s list call), andplugin.NewInstanceNamesasks them all; the singletons (apiserver,certificate-monitor) hold their own name. Every create goes through one rule,plugin.InstanceNames.Check(type)(awebui.InstanceNameCheck): a name another type holds is a failure on thenamefield naming that type and instance.webui.CollectionScreenandrest.ConfigResourcerequire it (NameCheck, withNameon the screen), so the form and the REST create answer alike (TestEndToEnd_AnInstanceNameIsUniqueAcrossPluginTypes); tests that build an extension without a database of every module passplugintest.NoConflicts. - A
plugin.Typeis declared by its module (as2.SenderPluginType = "as2-sender"; no central list) and spelled the same everywhere: registry, CLI subcommand, RESTtype,/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/configandinternal/telemetrymention no module: the protocol is an attribute ofmicrob2b_messages_total, filled with the module’s ownModuleNameconstant (as2.ModuleName,file.ModuleName,httppost.ModuleName, which the module registers under), the status atelemetry.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.goenforces (production and test code): onlyinternal/moduleregisters 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 answersEnabledEndpoints; vocabulary ininternal/plugin/endpoints).from/tomay bepersistence.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.MatchRoutesfinds the routes whose source instance andfrom/tomatch (*matches any party) and the message gets one delivery per distinct target, so overlapping routes to one target deliver once. At save timeresource.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 awebui.Host; a module never importsinternal/api, and the server never imports a module’s UI. webui.Extension.MessagePaneladds a section to the message detail page (webui.MessagePanel,webui.Absencefor “not known” / “not applicable”);persistence.Messageand 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’sreceiptpayload 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.ErrNoRowsis 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_APageOfAMissingRecordIs404covers every record page. - Template names are one namespace: prefix a module’s with its name (
as2_<page>.html);Installrefuses 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, theEndpointsroutes may name, theInstancesstats report, itsOpenAPIfragment andMessageDetail(marshalled into the message detail response underprotocols.<module>). /api/v1/plugins/configs/...dispatches to therest.PluginConfigregistered for the type (unknown type: 400). Modules build them withrest.ConfigResource[T]:rest.Decodedecodes property by property (an undecodable domain value is reported against its property) overNew<Type>()or the stored config, then the generatedValidate. A failure isresponse.WriteFieldErrors: 400INVALID_INPUTwithdetailskeyed by field name (_formfor 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).Tis the struct with a form spec a client sends,Rwhat is stored and shown (the same type for a profile); the resource namesNew,Edit(what an update decodes onto, without write-only values),Validate(the generatedValidate/ValidateUpdate), an optionalCheckhook for rules beyond the record (route redundancy, the party an AS2 profile belongs to),View(redaction) and the persistence callsFind/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}withrest.ParsePagination(default 20, at mostrest.MaxLimit, an unusablelimit/offsetis 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, timestampstime.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.ErrNoRows404NOT_FOUND,*persistence.CertificateExistsError409CERTIFICATE_EXISTS,*persistence.CertificateInUseError409CERTIFICATE_IN_USE,persistence.ErrDuplicate409CONFLICT, a*listfilter.Error400 on its parameter, anything else 500INTERNAL_ERRORlogged once withslog.ErrorContext(r.Context(), ...). REST handlers log through the default logger with the request’s context (the one logger source; nothing injects a*slog.Loggerinto them, andrest.Factorytakes only the runtime dependencies). A body that is not a JSON object isrest.WriteUndecodable(400INVALID_INPUT,details.body). - The OpenAPI document is assembled at startup (
internal/api/openapi, served atGET /api/v1/openapi.yaml): the core’sinternal/api/openapi/openapi.yamlplus each module’s embedded fragment, with the plugin type enums andPluginConfigschema computed from what registered; an unsound result (duplicate, dangling$ref, reused operation id) is refused.internal/api/openapi_test.gochecks that every mounted route is described and every described operation is mounted. - Modules use
internal/api/response(WriteJSON,WriteError,WriteFieldErrors) and never importinternal/api/handlersorinternal/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
:rootblock; dark is declared twice, in theprefers-color-scheme: darkblock (system) and:root[data-theme="dark"](the header’s theme switch), kept identical byTestChosenDarkIsTheSameAsSystemDark. A new colour used as text or fill gets its pair inpairsininternal/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
:rootblock (--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-targetstays px).htmlis a percentage of the browser’s own default (75%, so 12px at the usual 16px), fluid up to 87.5% (14px) at 3840px wide throughclamp(), 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 thesameInBothlist instyle_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.Iconis awebui.Icon(typed name;Extension.Validaterefuses an empty or unknown one), drawn by the oneicontemplate from the host’s spritestatic/img/icons.svg(<svg class="icon"><use href="/static/img/icons.svg#icon-<name>">, strokecurrentColor, 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 inwebui.Icons, thenNavItem.Icon(host entries incomponents/nav.htmluse{{template "icon" "<name>"}}); tests fail if the sprite andwebui.Iconsdiffer or a symbol is shown nowhere (the menu or an icon button). The Deliveries table of the message page uses therestarticon button (named “Redeliver to” / “Send again to ” by aria-labelandtitle), and its Redispatch button is an icon-and-label button (button.button-with-icon, iconredispatch) in a wrapping.toolbar-wrapbeside its explanation. An icon button (button.icon-button, in an.icon-actionsrow that never wraps; the Services page’sstart/stop/restart) shows only an icon, carriesaria-labelandtitlenaming the action and its target, is a--control-height-smsquare (--touch-targetunder 900px) and tells actions apart by shape;icon-button-dangeradds the danger tone, never as the only signal - Templates never use an inline
styleattribute, 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, likealert-{{.FlashType}}, is on itscomposedClasseslist): 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 failsTestNoRuleCentresABlockunless 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
@mediablocks 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 adanger-zonesection on the record’s page (delete_section, orTarget.DeleteURLinwebui_form.html): onehx-deletebutton withhx-confirm, answered bywebui.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 onehx-postbutton withhx-confirmper redeliverable delivery;TestEndToEnd_ListRowsAreTheLinkand the list-template scans cover the list pages only. Enabled/disabled is a read-only pill in lists.TestEndToEnd_ListRowsAreTheLinkenforces it. - Wrapping is by role:
.nowrapon columns that stay on one line (ids, status, dates, pills),.wrap-anywhereon 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 HTMLrequiredattribute andaria-requiredon a picker’s combobox, with no second list. The marker is therequired_markpartial (.required-mark, token--requiredin all three token blocks, paired instyle_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 withrequired;TestHandWrittenFormsMarkRequiredFieldsfails when they disagree. Server-side validation stays authoritative. - Message status is
{{template "status_badge" .Status}}; label and class come frompersistence.MessageStatus(Label,BadgeClass), andstyle_test.gofails on a status without a contrast-checked badge. Other pills are.badgeplusbadge-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 asPager, the table ends in{{template "table_footer" ...}}. No hand-written pagination (TestNoTemplateWritesItsOwnPager). - Breadcrumbs are typed data (
webui.Trail(webui.Link(...), webui.Here(...))onwebui.Page,webui.FormPageor the host’sPage[T]) rendered bylayout.htmlin the header; the generic screens build their own, the host’s are ininternal/api/ui/breadcrumbs.go. A page never writes a<nav>(TestNoTemplateWritesItsOwnBreadcrumbs,TestEndToEnd_PagesShowTheirTrail). - List filters (
q, and for certificatesstatus/key) are parsed once for UI and REST byinternal/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.CSRFProtectionover/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 withwebui.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, neverhttp.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.Bindkeeps the submission; write-only fields are never echoed), andwebuitest.Runasserts it. An element withhx-post/hx-put/hx-patch/hx-deletenames itshx-targetor sayshx-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’salert-{{.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 withwebui.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 everyhref,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,CodeandSubjectKindare 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/WithSeverityrefine 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), andnotification_test(info,Test: true: raised only by a notifier’s test button, never offered as a filter byalert.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 ofctx(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.
Raisereplaces anyscheme://...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_failedby the persistence’s status-change path (bunPersistence.logStateChange), one place for every protocol, for Error only:Refusedraises nothing, and a target never raises for a failed delivery. A message that matches no route raisesmessage_not_routedfrom the dispatch. The async MDN sender, the file poller (once per file moved aside), the plugin manager andcertmonitorraise their own.certmonitorremembers in memory when it alerted for a certificate in a state and does not repeat insideWithReAlertInterval(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...)andaudit.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_atignored, so a new field is audited with no change. A write function isdb.RunInTx(ctx, nil, func(ctx, tx bun.Tx) error {...})and uses onlytxinside (SQLite has one connection). Modules import this package from their own bun package (an architecture test allows nothing else outsidecmd/microb2band test support); it names no module. The actor islogctx.User(ctx)(both auth middlewares set it, the persistence RPC carries it),systemwhen 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 asAuditChange{Secret: true}with neither value.webuitest.AssertWriteOnlyalso 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 bymodule.Register; the core’s own arepersistence.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,enabledis one of the fields); the poster and the notifier passaudit.Redact(T.WithoutSecrets). - Completeness: each persistence interface has an
AuditCoveragetable (persistence.CoreAuditCoverage,as2.AuditCoverage, …) listing itsCreate*/Update*/Delete*/Save*methods asaudited(with its kind),runtime dataorpending(with a reason);audittest.CheckCoveragein 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;RecordGonesays a deletion is at or after the event). Filters are parsed once for the page and REST bylistfilter.Audit(an unknown value is an*Errornaming the parameter). RESTGET /api/v1/audit(unknown parameters are a 400) andPOST /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));Encodeis documented on the function, defined over the event’s own fields only (v=persistence.AuditFormatVersion,id,atin Unix microseconds, actor, action, kind, record id and name, ordered changes) and pinned by a golden test; a different encoding is a new version, andVerifyrefuses 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, unknownAUDIT_*is an error);audit.UseKeysets it per process (configureAuditincmd/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 ispersistence.ErrAuditKeyRequired.audit.VerifyEvents(Source, key, Anchor)is the one verification, overDatabaseSourceorFileSource(an export);Persistence.VerifyAuditLogruns it in the process that has the key.microb2b audit verify [--file f --after-id N --after-hash H]andmicrob2b audit export [--out f] [--after-id N](JSON lines:audit.ExportLine, documented indocs/deployment.md). The chain is tamper-evident, not tamper-proof; see “Audit log” indocs/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 isFormat(domain string withParseFormat, JSON,FormatOptionsfor the form), label,Render func(alert.Alert, RenderContext) (Rendered{Body, ContentType, Headers}, error),Signs(the format’s receivers verifyX-MicroB2B-Signature) and an optionalCheck(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>.jsonfor the cases ofgoldenCases;TestFormatsRenderTheirGoldenFilesiterates the table and fails for a row without them;go test ./internal/notification -run Formats -updateaccepts a change, review the diff).generic(format_generic.go: schema documented ongenericDocument,schema_versionraised on any change that is not additive) andslack(format_slack.go: top-leveltextfallback withmrkdwn:falseand&<>as entities, Block Kit in an attachment for the colour; untrusted text goes inplain_textblocks, nevermrkdwn) exist, and so doteams(Adaptive Card 1.4 in a Workflows message),discord(one embed,allowed_mentions.parsealways empty),googlechat(cardsV2plus a text fallback) andntfy(plain-text body,Title/Priority/Tags/Clickheaders 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 sharefitLines(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’sPUBLIC_URL. - Secrets: the webhook
url(SecretURL),headers(SecretLines, scoped to the URL) andsigning_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 arehttpout.Errors, fixed phrases, andhttpout.TransportErrorleaves out the URL and the address of a dial or DNS error.signing_secretis dropped for a format withSignsfalse. - Delivery (
notifier.go,jobs.go): a kept alert is aqueuedrow of the notification log (Persistence.QueueNotification, with thenotification.Groupa summary stands for), and that row is the job of the instance’sdelivery.Worker[notification.Job](storejobStoreoverClaimNotification/CompleteNotification/ReleaseNotification/ListDueNotifications, job id the alert id, scoped to the instance; one attempt at a time, polling everyPollIntervaland poked when a row is queued; the clock is the notifier’sClock). TheNotifieris theDeliverer: it sharesinternal/httpoutwith the HTTP poster, 2xxDelivered, 429/5xx/408/425/timeouts/transport errorsRetrywith doubling backoff capped atMaxRetryWaitup tomax_attempts(a test: 1), other 4xx and redirectsFailed, and a row older thanMaxPendingAge(24 h)Failedwithout a request. A queued row survives a stop:Quiesce/Stopstop 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 callsalert.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 injectableClock: filter (min_severity,codes; none = all), cooldown perdedupe_key(cooldown_minutes, default 15, 0 = off; a held-back alert is asuppressed_cooldownrow), and a rate limit (rate_limitalerts perrate_window_seconds, defaults 5 and 60: the rest of the window is rows with outcomegroupedand goes out as ONE summary alert, the most urgent held alert’s code and severity withRenderContext.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
notificationsof 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 refusesqueued) are written best effort; aqueuedrow also holdsnext_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 (RetentionMaxAge30 days bycreated_at,RetentionMaxRows10,000) is pruned by every running notifier at start and hourly (PruneNow,Persistence.PruneNotifications). The list for UI and REST isPersistence.ListNotifications(Filter) (*Page, error)(severity, code, instance, outcome, alert time range, limit/offset, newest first,Total). - Test send:
notification.SendTestRequestpublishes anotification_testinfo alert onnotification.TestSubject(name)(notification.test.<name>) and pollsGetNotificationuntil the row is final orErrNoAnswer(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 markedtest. RESTPOST /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 pagenotification_test.html) both call it, so it works when the notifier is another process. - Tests:
notificationtestis the module’s test support (Setup,FakeClock, an in-memoryBusdispatcher, a scriptedReceiver).internal/notification/notifier_test.gocovers 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, templatesnotification_*.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/toas RFC 3339 or a bare UTC date, a bare date astomeaning that whole day,include_tests; every value checked, a failure names the parameter),ParsePagingfor REST,Filter.Matchesfor the live page (a test keeps it equal to the stored list). Test notifications are left out unlessIncludeTests. Severity and outcome badges arewebui.Tones (severityTones,outcomeTones, a test fails for a constant without one), so no new color token was needed. An alert’slinkis 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 anotification.Changeonnotification.ChangesSubject(events.notification;PublishChanges/DecodeChange, wired asWithChangeNotifierinnotification/pluginfor in-process and for the persistence process’s RPC server). The page’s stream (GET /ui/events/notifications, first page only) is aneventstream.ServeovernewFeed: one dispatcher subscription per stream, ids are change ids,Last-Event-ID/afterresume throughListNotificationChangesAfter(the row as it is now), more thaneventstream.DefaultResumeLimitbehind is areloadevent. 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.DashboardCardsreturnswebui.DashboardCarddata (the host renders it instat_cards, so the live dashboard and its poll show it): its figure is a tile among the counters in the group of itsWindow(CardNow,CardLast24h,CardLast7d;ui.statGroupslays the groups out), and a card withItemsalso gets a row of its own below them; a figure is colored only when it wants attention; the Alerts card isSummarizeCriticalNotifications(distinct critical alerts of the last 24 h, tests excluded, latest three).Extension.DashboardChangesis a channel the module signals (a critical real notification was written), merged by the host intoeventstream.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.Addputs an instance under control (what discovery uses; a later reload would add instances the same way).GetPluginslists 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/Stoppingmean an action is in progress and another is refused (ErrActionInProgress: 409 or a flash), not queued. Reads never wait for an action.StopAllkeeps 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.Controllableinterface returning false (apiserverdoes); every other plugin is controllable. The manager refuses actions on it (ErrNotControllable: 403 in UI and REST) andPluginInfo.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.startTimeoutoverrides it in tests),StartAlland the start half of a restart included (StartAllstill stops at the first failure). On timeout the instance is Error (“start timed out”),plugin_failedis 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, withaction,type,name) and, when carried out, is an audit event (Manager.DocallsPersistence.AuditOperationthroughManager.AuditOperationsWith, whichapi.NewServersets to its persistence, so distributed mode goes over RPC): action started/stopped/restarted, kindservice(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 asWithMessageHistoryNotifier). The kinds arestatus_changed,redispatch_requested,redeliver_requested(the operator’s actions),delivery_changed(every delivery transition, with the target’s text; thestatus_changedline a transition causes carries no text of its own, while a refusal’s and the dispatch’s “No route matched” do),mdn_received,mdn_refusedandduplicate_received. Each open stream is one dispatcher subscription with a bounded buffer; a client that overflows is disconnected and resumes fromLast-Event-IDviaListMessageHistoryAfter, or gets areloadevent beyondevents.DefaultResumeLimit. UI:GET /ui/events/messages,GET /ui/messages/{id}/events; REST:GET /api/v1/events. - Logs:
internal/logstreamwraps each process’sslog.Handlerand publishes every record (also standard librarylogoutput) onevents.logwithout ever blocking or logging (bounded queue, drop count reported, text truncated); each process attaches with its source name (logstream.Attach).LOG_STREAM_LEVELsets what is published. The API process keeps oneevents.LogRingwhose 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 aslog.LogValuer(a masked secret type) shows its masked form: anything sensitive must be aLogValueror must not be logged. WithOTEL_ENABLEDthe 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
ctxdown: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.WithAttrsadds other per-message attributes. A goroutine that outlives its request keeps them withcontext.WithoutCancel(ctx). A line about a delivery also carriesdelivery_id(logctx.WithDelivery; the delivery worker and the persistence’s delivery methods set it,logctx.Headerscarries it over the persistence RPC, and sloglint forbids writing it by hand likemessage_id). - Log with the context:
slog.InfoContext(ctx, ...), neverslog.Info(...)where a context exists, never the standard librarylog, 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) andforbidigo(log.Print*/Fatal*/Panic*), scoped by thepath-exceptrule in.golangci.ymlto the packages that process messages or answer requests (the modules’ message code and theirrest/ui,internal/api,internal/webui,internal/notification,internal/alert,internal/eventstream,internal/dispatcher,internal/delivery), so a request handler’s lines carryuser(andmessage_idwhere 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.MessageIDsreports 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) andtelemetry.HTTPTransportrecord a span only when the context already has one, and so dodelivery.claim,delivery.settleanddelivery.sweep(telemetry.StartTraced), so background traffic (log tail, alerts, live updates, polls, sweep passes) makes no root traces; an asynchronous receipt’sdelivery.settlelinks to the delivery’s trace; publish withcontext.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), anddelivery.Workerstartsdelivery.attemptunderClaim.Tracewhen it came to the job without a trace (catch-up, poll;TraceParent.Into). A new kind of stored job storesTraceParentOf(ctx)when it is queued and returns it in itsClaim. - 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 incomingtraceparentis a link, not the parent; static,/healthand 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 intelemetry.Route, because the middleware between them clones the request and its pattern would otherwise be lost, leaving every page/ui/; the metric’shttp.routeis the same), clients bytelemetry.HTTPTransport(own span<method> <host[:port]>: method, origin (also asurl.template), status, never path or query; propagates nothing to the remote side). Tests capture spans withtelemetrytest.CaptureSpans. - Metrics beyond the
microb2b_*instruments: Go runtime (contrib/instrumentation/runtime, started byInitMetrics),http.server/client.request.duration, and the connection pool (telemetry.ObserveDatabase, frominitializeDatabase).
Key Environment Variables
| Variable | Default | Description |
|---|---|---|
DB_TYPE | sqlite3 | sqlite3 or postgres |
DB_URL | file:microb2b.db | Database connection string |
DB_MAX_OPEN_CONNS | 25 | Postgres only: maximum open connections per process (0 = unlimited) |
DB_MAX_IDLE_CONNS | 10 | Postgres only: idle connections kept; at most DB_MAX_OPEN_CONNS when that is above 0 |
DB_CONN_MAX_LIFETIME | 30m | Postgres only: maximum connection age, a Go duration (0 = forever) |
DB_CONN_MAX_IDLE_TIME | 5m | Postgres only: maximum idle time of a connection (0 = forever) |
BUNDEBUG | - | 1 or 2 logs every SQL query |
HTTP_PORT | 8080 | API 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_LEVEL | info | debug, info, warn, error; anything else is info |
LOG_STREAM_LEVEL | info | Lowest 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_FILE | unset (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_FILE | unset | Earlier master keys of a rotation (commas, spaces or lines between them): they open, never seal |
AUDIT_KEY, AUDIT_KEY_FILE | unset (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_TTL | 8h | Length of a login, 5m to 72h |
NATS_ALLOW_UNAUTHENTICATED | false | Distributed 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_ENABLED | false | true enables tracing, metrics and logs export |
OTEL_EXPORTER_OTLP_ENDPOINT | localhost:4317 | OTLP endpoint (TLS) |
OTEL_EXPORTER_OTLP_INSECURE | false | true = 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_NAME | microb2b | Service name |
OTEL_SERVICE_VERSION | 1.0.0 | Service 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 ininternal/api/uiuse fake extensions (fakeExtension,installPluginCounts,installRouteInstances); a module’s screens are tested in the module throughinternal/api/ui/uitest, which mounts the extension in the real page chrome. The exceptions are the end-to-end tests ininternal/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 ine2e_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
NewServeranauthtestauthenticator, andinternal/authtests the releaseSelect(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 onlySelect, the cookie names anddeveloper.godepend on the tag. Rungo test -short ./...with and without-tags developer. - Browser tests (
test/playwright,just test-playwright; not part ofjust test): Playwright tests (TypeScript,playwright/test) of the web UI against a real standalone developer build.run.shstarts it with--reset-db, a temporary database and payload directory, free ports andPUBLIC_URLset to its own address (so setup links are whole links);HTTP_PORTandPW_RECEIVER_PORToverride the ports. The seeded AS2 receiver’s fixed port may be taken, so the first start only seeds, the receiver is moved toPW_RECEIVER_PORTin the database, and the second start is the server the tests use;run.shthen runs the suite and stops the server. Every/ui/page needs a session: the projectdeveloper-session(tests/developer-session.setup.ts) signs in with the login page’s “Sign in as developer” button and saves the storage state the projectchromium(tests/) runs with; the projectlocal-auth(tests/local-auth/) starts every test without a session and covers the sign-in itself (the developer button, and users it creates withmicrob2b users createover the server’s database and sets up with a password and TOTP,accounts.tscomputing the codes). Playwright comes fromtest/playwright/node_modules(npm install) or else the global installation, and the browser from the Playwright cache. Every test runs with the page guard offixtures.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 ago test, sogo 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),/procpeak RSS and CPU and the WARN/ERROR lines.-profilestarts 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 printsgo tool pprof -toplists in the report; seedocs/deployment.md“Load testing”. -shortskips 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/workflowsinterop jobs. Mocks are inmocks_test.gofiles; AS2 tests use real certificates and captured partner traffic (test/samples,internal/protocol/as2/testdata).
Common Gotchas
- Generated Code: RPC client/server files and UI form files are generated and not committed. Always run
just generateafter modifying an interface definition, a form’s spec or its struct; a checkout without it does not compile. - Schema changes: a table is created from its Bun model (
bun/models.go) by its persistence plugin’sSchema.Create(create-if-not-exists, never alters). Change the model and reset the database (--reset-db, whichjust runpasses; 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. Usegraphify path "<A>" "<B>"for relationships andgraphify 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).