webhook

package
v1.1.2-proof.20261029.... Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

README

webhook — signed outbound events from the outbox

A tenant registers an HTTPS address and the event types it wants. Every event of those types is POSTed to that address, signed the way Standard Webhooks specifies, retried for three days, and written to a log the tenant's administrator can read.

outbox ──▶ Fanout (kit/events handler, one subscription per emitted event)
              └─ one queued row per matching endpoint of this tenant
        ──▶ Deliver (kit/jobs, per tenant, every 5 s)
              claim ─ short tenant tx, FOR UPDATE SKIP LOCKED, commits
              re-ask ─ short tenant tx per row: still enabled? and what
                       address and what signing key does the row say now?
              send  ─ no transaction: the socket opens with no lock held
              outcome ─ new tenant tx: state, retry row, fault clock,
                        disablement + its event + its notice, one commit

The receiver's check, in Go

The secret is whsec_ + base64 of 32 random bytes, answered once by the create and once by each rotation — Standard Webhooks' own spelling, which is what their library expects as a key.

webhook-signature is a space-delimited list of <version>,<base64> entries, and the comma is not decoration: the reference library splits the header on spaces and then requires a comma in each part, so a header written v1 <base64> is one no standard receiver accepts however correct its HMAC. This module answers one entry, v1,<base64>; the list form is what leaves room for a second — a rotated-out key, a v2 — without renaming the header.

func verify(secret string, id, timestamp, signature string, body []byte) error {
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return err
	}
	want := hmac.New(sha256.New, key) // HMAC-SHA256
	_, _ = want.Write([]byte(id + "." + timestamp + "."))
	_, _ = want.Write(body)
	at, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-at)) > 300 {
		return errors.New("stale timestamp")
	}
	for _, entry := range strings.Split(signature, " ") {
		parts := strings.SplitN(entry, ",", 2)
		if len(parts) == 2 && parts[0] == "v1" && hmac.Equal(mustBase64(parts[1]), want.Sum(nil)) {
			return nil // and: deduplicate on webhook-id — delivery is at-least-once
		}
	}
	return errors.New("bad signature")
}

modules/webhook/internal/signature_header_test.go runs that algorithm against a delivery this module actually sent, rather than against this module's own header builder: a test that verified a signature with the function that signed it would prove nothing.

Deduplicate on webhook-id. The module is at-least-once on the wire by construction: a crash between the send and the transaction that records its outcome loses the record of an attempt that physically happened, the lease expires, and the row goes out again. attempt and seq make the duplicate visible in the log.

What is where

contracts/ Endpoint, Delivery, the five events, the two permissions, Service, and the two ports: Catalogue and Alerts
internal/service.go create, target, rotate, enable, disable, replay; the secret wrap
internal/fanout.go the outbox handler: one event in, one queued row per matching endpoint
internal/deliver.go the job: claim, send, sign, record, and the three-day fault rule
internal/retry.go the ladder as a table: 1 m, 5 m, 30 m, 2 h, 6 h, 12 h, then a day
internal/address.go the SSRF gate, one value read by the write and by the dial
migrations/ 000044 endpoints + secrets, 000045 the delivery log

Two resources, and what a caller may change

GET/PATCH/DELETE /endpoints/{id}, GET /endpoints and GET /deliveries{,/{id}} come from rest.Spec; the delivery log offers no CRUD write at all, because a delivery is a fact. Four routes answer with something other than the entity:

Route Why it is not a PATCH field
POST /endpoints the answer is the signing secret, and a Spec answers with the row
POST /endpoints/{id}/rotate same, and the entity must never hold the secret
POST /endpoints/{id}/target both fields re-check the address policy and the catalogue
POST /endpoints/{id}/enable, /disable the lifecycle: a status a PATCH could set is a flow of events nobody chose to stop
POST /deliveries/{id}/replay queues a new row, cause='replay', and answers with it

The last three are rest.Command, which mounts the route and records the door on the resource in one declaration. The first two are mounted by hand, because the answer is a value the entity must never hold, and they record their own door afterwards — httpx.Surfaces.AddCommand, the seam rest.Command itself calls. A route that is in the API and not in Resources() is a door no generated screen can draw and no native shell can find, which is how registration and rotation read until the second review. Neither carries a Run: a derived screen performs a write and redirects, and it has nowhere to show a value that exists in one response and in no read, so ui/screens draws no button behind them and the two doors stay JSON doors. See Interface.

The secret is not a field of Endpoint. It lives in webhook_endpoint_secrets, encrypted with a deployment key a tenant can neither read nor set, and the plaintext exists only inside the process that signs. A whsec_ value on the entity would be a whsec_ value on the generated form and in webhook.endpoint.created, which the audit trail keeps for a year.

webhook.endpoint.updated carries changes — the events.Change list the trail is written from — because the question a year-old trail row is asked is not "what does this endpoint point at now" but "what did it point at, and when did it stop". A PATCH of the name gets that diff from the kernel's own write door; target is a command and no door runs over it, so it diffs the two fields it owns itself, after the write and before the publish. changes is no column and no REST property (gorm:"-", hidden:"true"), so the member exists in the event and in no response body — the same shape task, content, user, billing and site carry.

Refusals worth naming

  • An address that is not https, or carries a user name, a query or a fragment.
  • A private or loopback address, unless AllowLocalEndpoints — a deployment flag with no column, field or route that can carry it. It never reaches the cloud metadata address, and the gate reads the resolved address, so a name that resolves to 127.0.0.1 is refused too. Such an attempt is dropped, not retried: retrying an address this deployment will never dial spends three days of somebody's queue to learn nothing.
  • An event type this composition does not emit, named in the refusal. The list comes from the Catalogue port the composition writes.
  • A row in another tenant: row level security answers 404, which says nothing about whether the row exists.
  • A replay against a disabled endpoint: 409, and nothing is written.
  • An endpoint that has failed for three days: disabled, its queued rows dropped with a reason, its history kept, its administrator told through the Alerts port — which a deployment may compose as nothing. A notice that fails is the same shape as a notice that is absent: it runs behind its own savepoint inside the outcome transaction, so the notifier's own half is undone and the endpoint, its outbox event and the log still commit. An error from that port would otherwise roll the outcome back, leave the attempt in_flight, and have the lease re-send it about every minute for as long as the notifier stayed down.
  • A replay whose pair has no attempt waiting writes a new row; a pair with one already queued or in_flight is that command having been performed, and answers with that row. A replay that has been answered is a finished attempt, and asking again queues another: the partial unique index covers the two waiting states and no others.

Authorization

Permissions

The manifest in modules/webhook/module.go (permissions) declares two keys, whose constants are in modules/webhook/contracts/permissions.go.

  • webhook:read guards the four generated read routes of the two Specs (spec.Read), the two generated screens webhook/endpoints and webhook/deliveries, and both nav entries (Nav in Module). It never reaches a secret: no readable field carries one.
  • webhook:update guards every write. endpointSpec.Write covers the generated PATCH and DELETE; httpx.Register(s.App, …, write, …) guards the create and the rotate; and rest.Command(…, rest.CommandOptions{Auth: write}) guards target, enable, disable and replay. A replay sends, so it is a write and never a read. The same Auth is what the two recorded doors are filtered by in the catalogue, so a caller who cannot call a route is not told the door exists.

The module declares no export. Its one subscription (SubscribeAll: true, svc.Fanout) and its two jobs (webhook-deliver, the retention sweep) are not requests and are guarded by nothing but the tenant transaction they open.

Object scope

None. No command asks tenancy.Policy which row the caller may see: every read and write is inside the caller's own tenant transaction, and row level security on all three tables — ENABLE and FORCE, with the platformkit_tenant_match policy — is what answers "another tenant's row" with ErrNotFound. A row this tenant cannot see is a 404 on every surface, including the one whose body would have said whether it exists.

Duties the module enforces itself

Rule 9, in every command, inside the transaction that writes:

  • Create, Target re-check the address against AddressPolicy (with the deployment's flag) and every event name against the Catalogue port; both refuse with crud.ErrInvalid naming the value, and a Create with no actor is refused, because a disablement notice has to have somebody to address.
  • Rotate, Enable, Disable, Replay take crud.GetForUpdate on the endpoint — the aggregate root — before reading any state they decide on, so two commands on one row settle in lock order and a stale read decides nothing.
  • Disable and Enable are no-ops on a row already in that state: they write nothing and publish nothing, which is what makes two concurrent disablements publish exactly one event.
  • Replay refuses a disabled endpoint with crud.ErrConflict (409) and queues nothing; a pair with a replay already queued or in_flight answers with that row and writes nothing; the partial unique index is the backstop for a caller that took no lock.
  • endpointSpec.Immutable refuses url, events, status, disabledAt, disabledReason, lastSuccessAt and createdBy on the generic PATCH: the two fields that decide where events go are the target command's, and the rest belong to the lifecycle.
Public faces

None. No route in this module is httpx.Public(), no route is on s.Ops, and no path literal names a surface: every route is on s.App, the tenant surface, and a request without a session is refused by the surface before this module's handler is reached. The module uses no kit/limit.

The operator boundary

None. No entry in permissions sets Operator: true, and neither Spec sets OperatorRead or OperatorWrite: an installation's operators do not read a customer's endpoint list, and the delivery log holds that customer's own event bodies. AllowLocalEndpoints is deployment configuration rather than a permission — no column, field or route carries it — and the instance-metadata address stays refused whatever it says.

Provisioning

The module declares no role. A composition grants the two keys through the tenant's roles (see modules/auth/README.md); in the reference application the seeded administrator role satisfies both, and neither of apps/platformkit/roles.go's two task personas holds either — a module's doors are not a reason to widen somebody else's persona. The signing key and the two other deployment keys are read by webhook.Module's provider out of config.Webhook.

Composition

It is a kernel module: apps/platformkit/app.go names webhook.Module in Use, beside audit and task, so its two screens, its routes, its subscription and its two jobs are in the reference application. The provider reads three deployment keys (webhook.secret_key, overridden by PLATFORMKIT_WEBHOOK_SECRET_KEY, webhook.allow_local_endpoints, webhook.retention_days) and needs three answers:

webhook.New(webhook.Deps{
	SecretKey: key,            // 32 bytes, read once from deployment config
	Catalogue: eventTypes,     // the names this composition emits
	Alerts:    notices,        // or nil: see the refusal above
	Tenants:   tenants,        // modules/tenant
})

Catalogue is answered by the composition as a literal — in the reference application, apps/platformkit/webhook_catalogue.go, assembled from the emitting modules' own constants, with TestTheWebhookCatalogueNamesWhatTheCompositionEmits asking the resolved composition whether the list still matches what it emits. Alerts is Optional and the reference application composes nothing: it has no notice template for a machine fault, and a deployment that needs the notice durable subscribes to webhook.endpoint.disabled, which commits with the disablement.

Neither of apps/platformkit/roles.go's two personas is granted either door: they are the task module's, and roles.go says so — a module's doors are not a reason to widen somebody else's persona. The tenant's admin role holds every tenant-scoped permission, which is what lets an administrator register an endpoint on day one; apps/platformkit/persona_test.go is the table of what each persona may do and is refused. The signing key and the two other deployment keys are read by webhook.Module's provider out of config.Webhook.

Reused

kit/events' Subscription, Consume and its claim row, mounted the way modules/audit mounts its SubscribeAll handler; kit/jobs.Job with jobs.PerTenant and jobs.TenantLister, the way modules/task takes its sweep; rest.Spec for both resources' screens, routes, permissions and schema; kit/crud's GetForUpdate, Create, Update and Classify for every write; kit/httpx.Register and rest.Command for the routes a Spec cannot answer with; the ledger shape of modules/notification's 000027 — a bigint identity for the log's order, clock_timestamp(), RLS enabled and FORCEd; entity.Base, db.MigrationSource, and crypto/aes, crypto/hmac, crypto/sha256, net/netip and net/http from the standard library.

Added

A subscriber registry, because nothing in the tree let a tenant name an address outside the platform. HMAC signing of an outbound body, because the only hmac use in the tree is modules/auth's TOTP, which hashes rather than signs. An address policy and a dial gate, because the inventory found no outbound HTTP client outside kit/flags and no address rule anywhere. A three-day ladder, because kit/events' own is five rungs and thirty seconds, terminal and internal on purpose, and a customer's web server may be down for a weekend. The disable-on-a-fault-clock rule, because nothing here disabled a tenant resource on a clock. The Catalogue port, because pkit.Composition is reserved for the one after-everything module and modules/admin holds it. A shown-once secret in its own encrypted table, because HMAC needs the plaintext back and kit/secrets does not exist yet.

Made reusable

Three things. internal.AddressPolicy is a deployment-level SSRF gate that reads the resolved address and is a single value shared by the write and the dial — the shape any module that ever POSTs somewhere a tenant named would want, and today only this one has, because internal/ is this module's and a neighbour would have to take it from contracts/ first. The claim is the shape, not that the type is importable: nothing outside modules/webhook can read it as it stands. The claim/send/ record split in internal/deliver.go, with the advisory lock named for the cross-row critical section and the lease that doubts an in_flight row, is the shape for any module that must talk to somebody else's server on this platform's clock without holding a lock open. And the Catalogue port with a literal in the composition plus one drift test is the answer for any module that needs to offer "what this application can do" as a list of names, which nothing else in the tree could express while modules/admin holds the after-everything declaration.

Limits

Stated because each one is a promise someone could otherwise read into the module.

  • At-least-once, never exactly-once. A crash between the send and the outcome transaction loses the record of an attempt that physically happened; the lease expires and the row goes out again. webhook-id is what a receiver deduplicates on.
  • "Disable stops delivery now" is a re-ask, not a socket pulled back. A tick claims its batch in one short transaction and sends the rows after it commits, so the sender asks the endpoint's own row again before each POST: a disable that lands while the first attempt of a batch is on the wire ends every row of that batch that had not started, dropped with the endpoint is disabled, and the one already open is answered as it was going. A disable that commits a microsecond after the re-check is one request; there is no protocol that promises less.
  • A re-target and a rotation are as immediate as a disable, and no more. The same re-ask reads the address to dial and the secret to sign with from the endpoint's row, so a Target or a Rotate that committed while an earlier member of the batch was on the wire is obeyed by every request that had not started: the previous address hears nothing further, and no unsent member carries a signature the current key does not verify — which a receiver checking only the secret it holds would answer 401, and 401 is terminal, so the event would be lost. The request already on the wire keeps the address it connected to and the key it signed with: those bytes are signed and that socket is open.
  • One waiting replay per pair, and no more. The command is idempotent while an attempt is queued or in_flight; a pair whose replay has been answered can be replayed again, and that second replay is a new row with the next attempt number.
  • The event list gates what fans out, not what was already promised. A target that drops a name stops every later event of that type reaching the address, and leaves the rows the fan-out already wrote to go out: the receiver was given those promises while the name was subscribed, Target's own contract is about where events go rather than whether this one goes, and delete — which ends every queued row by name — is the command that stops work already promised.
  • The window is counted from the current claim, not the first attempt. The ladder's 12 rungs span about 4 days 20 hours, so an endpoint that fails one event while succeeding others (which resets failing_since) can retry that event for roughly 4.9 days. Closing the gap means carrying MIN(attempted_at) for the pair.
  • A redirect is followed only when it can still carry the event, and never off TLS. AddressPolicy reads every hop, and two refusals live in that one call. A hop that steps down to plain HTTP is refused before the socket opens. So is a hop answering 301, 302 or 303 to a POST: those three tell a client to ask the new address with a GET and no body, so following them posts an empty request that carries no CloudEvent and no signature over one, and the destination's 204 would be logged as sent for an event that never arrived. A 307 or 308 — which keep the method and replay the signed bytes — is followed to any host the dial gate will open, up to three hops. Either refusal ends the attempt dropped, with the address in its reason, and the signed body goes nowhere. AllowLocalEndpoints opens neither: the flag says this deployment dials a local destination, not that it carries a signature in the clear.
  • A delete cannot undo a request that reached its receiver. The delete ends every queued row and hides the endpoint, but an attempt already on the wire gets its outcome written from the socket's own answer — sent and the status the receiver gave — in the same transaction that would otherwise have updated the endpoint's fault clock. An endpoint row that has gone is no reason to call a completed exchange dropped with status 0; the log says what happened and, for a non-success, that the endpoint was deleted, which is why no retry follows.
  • The SSRF gate refuses what a laptop and a cloud metadata endpoint look like, not every reserved block. Loopback, link-local, private, unique-local and 0.0.0.0 are refused, resolved rather than textual; the metadata addresses stay refused under AllowLocalEndpoints. Not yet refused: NAT64 and 6to4 forms of those same addresses, benchmark, broadcast and multicast ranges.
  • A rotated secret is kept for a day and never signs with. previous_* is stored so a support question can be answered; the space-delimited signature list would let the module sign with both keys during the window, and it does not, so a receiver mid-rotation must pick up the new secret. Every request that starts after the rotation commits already uses it.
  • The stored refusal sentences are English. contracts.Reason* are written into rows. The two permission labels have pt-PT words; a reason on a delivery row is the language it was written in. Reason keys, translated at render, is open work.
  • The Catalogue port has one implementation, in the reference application, and no exported fake for a deployment writing its own.

Interface

Every route is on the tenant surface (s.App); there is no public and no operator route, and no surface or prefix literal in this module.

Door Route
webhook:read GET /endpoints, GET /endpoints/{id}, GET /deliveries, GET /deliveries/{id}
webhook:update POST /endpoints, PATCH /endpoints/{id}, DELETE /endpoints/{id}, POST /endpoints/{id}/rotate, /target, /enable, /disable, POST /deliveries/{id}/replay

Both resources are rest.Spec resources, so GET /api/v1/app/resources lists them, the generated screens are webhook/endpoints and webhook/deliveries, and the nav entries are read-guarded. The catalogue names every write this module has, not only the CRUD ones: register and rotate on the endpoint, target, enable and disable beside them, and replay on the delivery log — the last two of the first pair named without a screen button, because a screen cannot show a secret shown once and an administrator who cannot see the key has an endpoint they cannot wire. The delivery log's Spec offers no CRUD write, and its one verb is a command on the log that answers with a new row. Nothing in this module renders HTML: internal/ui does not exist, because no page here is this module's own.

Verification

go test ./modules/webhook/... -count=1                  # acceptance, isolation, ladder, fault rule
go test ./modules/webhook/internal/ -count=3 -race -run TestTwoDisablesOfOneEndpointPublishOnce
go test ./apps/platformkit/ -count=1 -run TestTheReferenceCompositionPlacesTheWebhookModule
go test ./apps/platformkit/ -count=1 -run TestTheWebhookCatalogueNamesWhatTheCompositionEmits

TestASubscriberReceivesASignedEvent, TestARetryAfterAFiveHundredIsDelivered and TestAnotherTenantsEndpointNeverHearsTheEvent are the brief's three acceptance cases. Not run: make e2e (no journey writes an endpoint), the mobile journey (the two screens are generated, and no flow names them), and every browser check — no page here is this module's own to open.

Documentation

Overview

Package webhook is the module manifest: what the kernel needs to know about outbound webhooks that it cannot learn from a function call.

A webhook endpoint is an HTTPS address a tenant registered so that a machine of its own hears what happened here. The platform already had the hard half: every event leaves through the transactional outbox and is described in the composition's AsyncAPI document. What was missing was a way for a customer to subscribe from outside, and a signature a receiver could check.

Delivery consumes the outbox stream the way the audit trail does — one SubscribeAll subscription the kernel expands per emitted event, so a module composed after this one is delivered by having emitted an event — and the handler writes rows rather than opening sockets. A second job is what sends: it claims rows in a short tenant transaction, commits, then talks HTTP with no lock held, and records the outcome in a transaction of its own. The retry ladder, the three-day window, and the disablement that follows them live in those rows and this module's constants, not in the broker's five rungs.

Signing follows Standard Webhooks: webhook-id, webhook-timestamp and webhook-signature (v1, HMAC-SHA256) over the stored CloudEvent bytes, with a whsec_ secret shown once at the create and the rotate and readable by nothing afterwards. README.md carries the receiver's check in twelve lines of Go.

Index

Constants

This section is empty.

Variables

View Source
var Migrations = db.MigrationSource{
	Owner: "webhook",
	Files: db.Sub(schema, "migrations"),
}

Migrations is this capability's SQL as one source — the endpoint, its secret, the delivery log — owned as "webhook".

The version numbers are new and adopted by nobody: no installation ever applied these bytes as anything but tables nobody had, because nothing here existed before this module. They sit above the highest file the foundation itself shipped (migrations/000043_tenant_app.up.sql), which is the condition apps/platformkit's legacy-layout fixture checks — see modules/change/migrations.go for the reasoning, which is the same reasoning and is written down there once.

No RulesFrom either: every file this source carries is guarded by the rule table, which arrived before any of it.

Module is outbound webhooks as the resolver sees it, for an application that names it in Use rather than building the manifest by hand: `Use(webhook.Module)`.

It hands out no contract: nothing calls this module, it subscribes. What it cannot decide for itself is answered by three things. Which tenant to walk with its two jobs is the tenant module's answer to jobs.TenantLister, the same one audit and task take. Which event names may be put on an endpoint is the composition's own fact — the Catalogue port, written as a literal beside the literals the message catalogues already are — because no module that is not built last can enumerate what an application emits. Whether anybody is told when an endpoint stops is the deployment's, and Optional because a deployment that tells people through its own subscription to webhook.endpoint.disabled composes nothing here and loses nothing.

The signing key is the deployment's, read from webhook.secret_key (which PLATFORMKIT_WEBHOOK_SECRET_KEY overrides) and never from a tenant's word. An installation that names no key is refused at the composition rather than at the first endpoint it registers: without the key no secret can be wrapped, and an endpoint nobody can sign for is an address a customer gave away for nothing.

Functions

func Catalogue

func Catalogue() xtext.Source

Catalogue is this module's copy, ready for the application's one xtext.Load.

func New deprecated

func New(deps Deps) module.Module

New is the manifest.

Deprecated: use NewChecked, which answers a missing key or catalogue as the same sentence. Both are read by every write, and a module that started anyway would be a module whose first create failed at runtime.

func NewChecked

func NewChecked(deps Deps) (module.Module, error)

NewChecked builds the manifest and the delivery service, and answers as an error the wiring every write depends on: a secret key that is not the 32 bytes one read hands it, or no catalogue to name the events an endpoint may receive. A zero RetentionDays or Every keeps the schedule beside it. It opens nothing, runs no query and is safe to call twice.

Types

type Deps

type Deps struct {
	// SecretKey is the 32 bytes the signing secrets are wrapped with —
	// deployment configuration read once at composition, which no tenant can
	// read or set. A deployment that never composes an endpoint needs none, and
	// every deployment that does must have one: see the module README.
	SecretKey []byte

	// Catalogue is what this composition can emit. Required: every write checks
	// an endpoint's event list against it, and no module that is not built last
	// can enumerate it. The composition writes the implementation as a literal
	// beside the literal its message catalogues already are.
	Catalogue contracts.Catalogue

	// Alerts optionally tells somebody when an endpoint stopped. nil is legal
	// and means the disablement happens, publishes and is logged with nobody
	// notified.
	Alerts contracts.Alerts

	// Tenants is how the delivery job and the retention sweep reach every
	// tenant, answered by modules/tenant the way modules/task and modules/audit
	// take it.
	Tenants jobs.TenantLister

	// AllowLocalEndpoints permits loopback and private ranges to be dialled. It
	// is deployment-level: there is no column, field or route that can carry it,
	// and it never reaches the instance-metadata address. It exists for a
	// developer's laptop and this module's own tests.
	AllowLocalEndpoints bool

	// RetentionDays is how long a finished attempt is kept; zero means the
	// default below. Every delivery row keeps the whole event it signed, so
	// forgetting is the other half of storing it.
	RetentionDays int

	// Every is how often the delivery job runs; zero means five seconds, which
	// is the lag an event has before it leaves. A test sets it lower.
	Every time.Duration

	// Now is the clock the sender computes with: the ladder's arithmetic and the
	// webhook-timestamp header. nil means the wall clock. Row timestamps are the
	// database's.
	Now func() time.Time
}

Deps is what this module cannot make for itself: a struct rather than a parameter list, so adding one is a named field at every call site.

Directories

Path Synopsis
Package contracts is everything another module, an app or a test may know about outbound webhooks: the two entities, the events, the permissions, the Service interface and the two ports this module needs from the composition.
Package contracts is everything another module, an app or a test may know about outbound webhooks: the two entities, the events, the permissions, the Service interface and the two ports this module needs from the composition.
This file is this module's answer to one contract: personaldata.Data (ADR 0020).
This file is this module's answer to one contract: personaldata.Data (ADR 0020).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL