Extending hookinlet

The hookinlet program is a thin assembly of parts. Each part sits behind an interface in pkg/inbox, and server.New takes them as options, so an application can replace any of them, or add behaviour around them, without forking. This is also how the hosted product is built on this code: it changes nothing here, it supplies its own implementations. The same door is open to you.

The packages are pre-1.0: the interfaces can still change, and every change to them is listed in CHANGELOG.md. After 1.0.0 semantic versioning applies.

The parts

Interface (pkg/inbox) What it does Default Conformance suite (pkg/inboxtest)
Store Persists inboxes and messages; assigns each message its per-inbox Seq. pkg/store/sqlite StoreSuite
Notifier Tells readers an inbox may have new messages. Carries no payload. pkg/notify/local (in process) NotifierSuite
Authenticator, Principal Says who a request is and what it may read or administer. pkg/auth/token AuthenticatorSuite
SecretBox Seals and opens verifier secrets, bound to a context: seal a verifier secret with inbox.VerifierSecretContext(slug), or the server cannot open it. pkg/secretbox SecretBoxSuite
Hooks Runs before and after ingest: reject a delivery, count it. inbox.NopHooks none: the default does nothing
Backupper (optional) A consistent snapshot of the Store, for GET /v1/admin/backup; the size may be -1 if the Store streams it. the SQLite store BackupperSuite
ScopedPrincipal (optional) A Principal that can read a known list of inboxes, so listing does not have to search. the agent principal checked by AuthenticatorSuite
verify.Registry Maps verifier names to signature checks. verify.Default() the package's own tests

The optional ones are found with a type assertion, and the server copes when they are absent: no Backupper means GET /v1/admin/backup answers 501.

Putting them together

server.New takes an Options. Only Store is required (and Addr, if you call Run), but the read API, the admin API, SSE and MCP are served only when there is an Authenticator, since there is no safe default for who may read.

store, err := sqlite.Open(ctx, "./data", sqlite.WithExclusiveLock()) // or your own inbox.Store; the lock also lets it migrate
box, err := secretbox.New(masterKey)            // 32 bytes

srv, err := server.New(server.Options{
	Addr:          ":8080",
	Store:         store,
	SecretBox:     box,
	Notifier:      local.New(),
	Authenticator: token.NewAuthenticator(store, adminToken),
	Hooks:         myHooks,                     // optional
})
err = srv.Run(ctx)                              // returns after a clean shutdown

examples/custom-hooks is a complete program built exactly this way, with a per-inbox rate limit as the hook. It is compiled and tested in CI; copy it as a starting point. Options has the rest (limits, timeouts, TrustedProxies, PublicBaseURL, a Verifiers registry); each field says what it does and what its default is.

srv.Handler() returns the whole http.Handler, if you would rather mount it in a server of your own than call Run (no Addr is needed then). Run and Serve do two things the handler alone does not: they start the retention schedule, and on shutdown they end open streams and MCP waits first. If you serve the handler yourself, run srv.Maintain(ctx) in a goroutine for the schedule (it also finishes any delete that was cut short), and call srv.Shutdown(ctx) to end the streams.

Every numeric and duration option follows one rule, written in Options' comment and in Stability: zero is the default, and a negative number is an error unless it turns a bound off (RetentionInterval, IngestMemoryBudget).

Writing hooks

Hooks has two methods; embed inbox.NopHooks to implement only one.

type limiter struct{ inbox.NopHooks }

func (limiter) BeforeIngest(ctx context.Context, req *inbox.IngestRequest) error {
	if tooMany(req.Inbox, req.SourceIP) {
		return fmt.Errorf("%w: slow down", inbox.ErrRateLimited) // 429
	}
	return nil
}

BeforeIngest gets an *inbox.IngestRequest: the inbox, the body size, the source address (as recorded, honoring trusted proxies), the method, the path after the inbox key, and the request headers (read-only). It runs after the inbox is found and the body is read, before the signature is checked, so a refusal is cheap. That has a cost to weigh: it sees unverified requests too, so a limit that counts every request lets anyone who knows an inbox's URL use up its allowance with unsigned posts, and the real sender, which may not retry, is then refused. Limit by something the stranger cannot spend (the source address of unverified traffic, say), or meter what was accepted in AfterIngest, or accept the trade-off knowingly, as the example does for brevity. Return an error wrapping ErrRateLimited (429), ErrQuotaExceeded (402), or ErrForbidden (403); any other error is a 500. It is called concurrently, from every request.

AfterIngest runs after the message is committed (or recognized as a duplicate, with created == false, so a meter does not count it twice). It must not block, and its context may be cancelled as soon as the response is sent, so work that outlives the call needs context.WithoutCancel. A panic in it is logged and swallowed: the delivery is already stored, and the sender must not be told it failed.

Writing a Store

The Store documentation in pkg/inbox/interfaces.go is the contract; the suite is what makes it testable. The points that are easy to get wrong:

Run the suite from an ordinary test:

func TestMyStore(t *testing.T) {
	inboxtest.StoreSuite(t, func(t *testing.T, now func() time.Time) inbox.Store {
		return openMyStore(t, now) // must use now for every timestamp it assigns
	})
}

The suite closes each Store itself. If your Store can take backups, add inboxtest.BackupperSuite(t, newStore, restore), where restore opens a Store from a snapshot's bytes.

Writing a Notifier

A notifier carries no data: it says "look again", and the reader asks the Store. Replay, reconnects, and slow readers therefore share one code path, and a missed signal costs latency and never data.

func TestMyNotifier(t *testing.T) {
	inboxtest.NotifierSuite(t, func(t *testing.T) inbox.Notifier { return newMyNotifier(t) })
}

An in-process notifier must not drop signals. One that crosses the network may (delivery at most once), and must then be run with a positive HOOKINLET_SSE_RECHECK_INTERVAL (Options.SSERecheckInterval), which makes every stream and MCP wait re-read the Store on that interval regardless. Publish is called with a context that may already be cancelled and must still deliver.

Writing an Authenticator

Authenticate runs on every request, before the server knows whether the caller may see anything, so it must not reveal why it refused: every failure looks the same and wraps inbox.ErrUnauthenticated. A different error is taken to mean the credential could not be checked and becomes a 500. A Principal decides by inbox ID, never by slug or name, and a credential for one inbox must not reveal whether another exists (the server answers 404 for both).

inboxtest.AuthenticatorSuite(t, func(t *testing.T) inboxtest.AuthFixture { … })

Adding a verifier

reg := verify.Default()
err := reg.Register("acme-v2", func(config json.RawMessage) (verify.Verifier, error) { … })
srv, _ := server.New(server.Options{ /* … */ Verifiers: reg })

See verifiers for what a Verifier must do: check the exact bytes received, compare in constant time, and say which headers carry its credential so they are not stored.

Rules the server keeps, and expects of you

From docs/extending.md at v0.1.1. Markdown · View on GitHub