Multi-tenant daemon skeleton #1

Closed
opened 2026-05-09 23:23:22 +02:00 by arne · 1 comment
Owner

What to build

Refactor posta-server serve from a single-tenant daemon into a multi-tenant one. Introduce a Daemon type with an identityRunner registry; existing api.Server, inbox.Inbox, outbox.Loop, sse.Hub, *store.SQLite types stay verbatim, instantiated once per identity. Top-level mux dispatches requests by r.Host to the right runner.

Manifest at /etc/posta/identities.toml is the only source of identity configuration:

[[identity]]
slug = "arne"
url  = "https://arne.posta.no"

Per-identity files live by convention at /var/lib/posta/<slug>/{keys.json,inbox.db}. Empty manifest = daemon starts but 404s every Host.

The serve command stops accepting --url, --keys, --db, and --name flags. Manifest is the only path. Clean break, no backcompat.

Acceptance criteria

  • New internal/daemon/ package with Daemon type + identityRunner and identity registry keyed by Host
  • Manifest reader/parser using BurntSushi/toml
  • cmd/posta-server/serve.go reads the manifest and instantiates one runner per identity
  • serve flags reduced to --listen and --manifest (default /etc/posta/identities.toml)
  • Top-level HTTP handler dispatches by r.Host to the right runner; unknown Host returns 404
  • Existing tests pass; new test covering 2-identity routing
  • Verifiable locally: 2-identity manifest with arne.local and marcus.local URLs serves both via /etc/hosts aliases

Blocked by

None - can start immediately

## What to build Refactor `posta-server serve` from a single-tenant daemon into a multi-tenant one. Introduce a `Daemon` type with an `identityRunner` registry; existing `api.Server`, `inbox.Inbox`, `outbox.Loop`, `sse.Hub`, `*store.SQLite` types stay verbatim, instantiated once per identity. Top-level mux dispatches requests by `r.Host` to the right runner. Manifest at `/etc/posta/identities.toml` is the only source of identity configuration: ```toml [[identity]] slug = "arne" url = "https://arne.posta.no" ``` Per-identity files live by convention at `/var/lib/posta/<slug>/{keys.json,inbox.db}`. Empty manifest = daemon starts but 404s every Host. The `serve` command stops accepting `--url`, `--keys`, `--db`, and `--name` flags. Manifest is the only path. Clean break, no backcompat. ## Acceptance criteria - [ ] New `internal/daemon/` package with `Daemon` type + `identityRunner` and identity registry keyed by Host - [ ] Manifest reader/parser using BurntSushi/toml - [ ] `cmd/posta-server/serve.go` reads the manifest and instantiates one runner per identity - [ ] `serve` flags reduced to `--listen` and `--manifest` (default `/etc/posta/identities.toml`) - [ ] Top-level HTTP handler dispatches by `r.Host` to the right runner; unknown Host returns 404 - [ ] Existing tests pass; new test covering 2-identity routing - [ ] Verifiable locally: 2-identity manifest with `arne.local` and `marcus.local` URLs serves both via `/etc/hosts` aliases ## Blocked by None - can start immediately
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Refactor the serve command into a multi-tenant daemon that hosts one runner per identity, dispatched by HTTP Host header.

Current behavior:
The serve command runs a single-tenant daemon. Identity is supplied via flags (--url, --keys, --db, --name) and a single set of api.Server, inbox.Inbox, outbox.Loop, sse.Hub, and *store.SQLite instances handles every request. There is no concept of multiple identities sharing one process.

Desired behavior:
A single serve invocation hosts an arbitrary number of identities. The set of identities is defined entirely by a TOML manifest (default location /etc/posta/identities.toml). Each identity is fully isolated at the application layer: its own keypair, its own SQLite database, and its own instances of the existing per-identity types — no cross-identity state sharing. Incoming HTTP requests are routed to the right identity by the Host header. An empty manifest must boot cleanly and reply 404 to every request.

Manifest format:

[[identity]]
slug = "arne"
url  = "https://arne.posta.no"

Per-identity files live by convention at /var/lib/posta/<slug>/{keys.json,inbox.db}. The convention is hard-coded — paths are derived from slug, not configured per-identity.

Key interfaces:

  • A new Daemon type in a new internal/daemon/ package owns an identityRunner registry keyed by canonical Host. Each identityRunner wraps the existing per-identity types (api.Server, inbox.Inbox, outbox.Loop, sse.Hub, *store.SQLite) verbatim — they are instantiated once per identity, not refactored.
  • A manifest reader/parser using github.com/BurntSushi/toml lives alongside the daemon and produces the registry input.
  • The serve cobra command's flag surface is reduced: --url, --keys, --db, --name are removed. --listen and --manifest are the canonical knobs (--manifest defaults to /etc/posta/identities.toml). The --acl, --rate-per-minute, and --config flags remain as global, daemon-wide flags — they apply uniformly across all identities. Per-identity ACL/rate/config is explicitly out of scope.
  • The top-level HTTP handler dispatches on r.Host. If no identity matches, return 404.

Acceptance criteria:

  • A new internal/daemon/ package defines Daemon and identityRunner with a Host-keyed registry.
  • A manifest reader parses the TOML schema above using BurntSushi/toml.
  • The serve command reads the manifest and instantiates exactly one runner per identity, wiring the existing per-identity types verbatim.
  • serve no longer accepts --url, --keys, --db, --name. It accepts --listen, --manifest (default /etc/posta/identities.toml), and continues to accept --acl, --rate-per-minute, --config as global flags.
  • An empty manifest boots successfully; every request returns 404.
  • The top-level HTTP handler routes by Host to the correct runner; unmatched Host returns 404.
  • All existing tests pass.
  • A new test exercises 2-identity routing (two distinct Host values reach two distinct runners and stay isolated).
  • Local verification works: a 2-identity manifest pointing at arne.local and marcus.local URLs serves both via /etc/hosts aliases.

Out of scope:

  • Per-identity ACL, rate limiting, or config — those remain global daemon-wide flags for this milestone.
  • Backwards-compatibility shims for the removed flags. Clean break — no deprecation path.
  • Onboarding flows, invite redemption, or any mutation of the manifest at runtime. The manifest is read once at startup.
  • Identity creation/management commands. This issue only adds the runtime dispatch; identity bootstrap is a separate issue.
  • Renaming or refactoring api.Server, inbox.Inbox, outbox.Loop, sse.Hub, *store.SQLite. They are reused as-is.
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Refactor the `serve` command into a multi-tenant daemon that hosts one runner per identity, dispatched by HTTP `Host` header. **Current behavior:** The `serve` command runs a single-tenant daemon. Identity is supplied via flags (`--url`, `--keys`, `--db`, `--name`) and a single set of `api.Server`, `inbox.Inbox`, `outbox.Loop`, `sse.Hub`, and `*store.SQLite` instances handles every request. There is no concept of multiple identities sharing one process. **Desired behavior:** A single `serve` invocation hosts an arbitrary number of identities. The set of identities is defined entirely by a TOML manifest (default location `/etc/posta/identities.toml`). Each identity is fully isolated at the application layer: its own keypair, its own SQLite database, and its own instances of the existing per-identity types — no cross-identity state sharing. Incoming HTTP requests are routed to the right identity by the `Host` header. An empty manifest must boot cleanly and reply 404 to every request. **Manifest format:** ```toml [[identity]] slug = "arne" url = "https://arne.posta.no" ``` Per-identity files live by convention at `/var/lib/posta/<slug>/{keys.json,inbox.db}`. The convention is hard-coded — paths are derived from `slug`, not configured per-identity. **Key interfaces:** - A new `Daemon` type in a new `internal/daemon/` package owns an `identityRunner` registry keyed by canonical Host. Each `identityRunner` wraps the existing per-identity types (`api.Server`, `inbox.Inbox`, `outbox.Loop`, `sse.Hub`, `*store.SQLite`) verbatim — they are instantiated once per identity, not refactored. - A manifest reader/parser using `github.com/BurntSushi/toml` lives alongside the daemon and produces the registry input. - The `serve` cobra command's flag surface is reduced: `--url`, `--keys`, `--db`, `--name` are removed. `--listen` and `--manifest` are the canonical knobs (`--manifest` defaults to `/etc/posta/identities.toml`). **The `--acl`, `--rate-per-minute`, and `--config` flags remain as global, daemon-wide flags** — they apply uniformly across all identities. Per-identity ACL/rate/config is explicitly out of scope. - The top-level HTTP handler dispatches on `r.Host`. If no identity matches, return 404. **Acceptance criteria:** - [ ] A new `internal/daemon/` package defines `Daemon` and `identityRunner` with a Host-keyed registry. - [ ] A manifest reader parses the TOML schema above using `BurntSushi/toml`. - [ ] The `serve` command reads the manifest and instantiates exactly one runner per identity, wiring the existing per-identity types verbatim. - [ ] `serve` no longer accepts `--url`, `--keys`, `--db`, `--name`. It accepts `--listen`, `--manifest` (default `/etc/posta/identities.toml`), and continues to accept `--acl`, `--rate-per-minute`, `--config` as global flags. - [ ] An empty manifest boots successfully; every request returns 404. - [ ] The top-level HTTP handler routes by `Host` to the correct runner; unmatched Host returns 404. - [ ] All existing tests pass. - [ ] A new test exercises 2-identity routing (two distinct `Host` values reach two distinct runners and stay isolated). - [ ] Local verification works: a 2-identity manifest pointing at `arne.local` and `marcus.local` URLs serves both via `/etc/hosts` aliases. **Out of scope:** - Per-identity ACL, rate limiting, or config — those remain global daemon-wide flags for this milestone. - Backwards-compatibility shims for the removed flags. Clean break — no deprecation path. - Onboarding flows, invite redemption, or any mutation of the manifest at runtime. The manifest is read once at startup. - Identity creation/management commands. This issue only adds the runtime dispatch; identity bootstrap is a separate issue. - Renaming or refactoring `api.Server`, `inbox.Inbox`, `outbox.Loop`, `sse.Hub`, `*store.SQLite`. They are reused as-is.
arne closed this issue 2026-05-10 01:19:49 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
posta/server#1
No description provided.