Multi-tenant rollout: daemon, identity CLI, invite flow, setup page #7

Merged
arne merged 2 commits from multi-tenant-rollout into main 2026-05-10 01:19:49 +02:00
Owner

Summary

  • Collapses the single-tenant daemon into a multi-tenant one. One process now hosts an arbitrary number of identities, dispatched by HTTP Host header. Identities are defined entirely by /etc/posta/identities.toml; per-identity files live by convention at /var/lib/posta/<slug>/{keys.json,inbox.db}. (Closes #1)
  • Adds the operator CLI: posta-server identity add/list/remove/purge and slug-scoped token create/list/revoke. Manifest writes are atomic (tmpfile + fsync + rename); slug regex [a-z0-9-]+ is enforced at write time; mutating commands print a "restart the daemon" hint and never auto-restart. (Closes #2)
  • Adds the invite-link onboarding backend: migration v3 (invites table), posta-server invite create/list/revoke, plus GET /api/v1/invite/info and POST /api/v1/invite/redeem. Tokens are 32 random bytes, base64url-encoded with pinv_ prefix; only sha256 hashes persist; default TTL 24h. The redeem path mints + consumes atomically; failure cases (unknown / expired / consumed) all return 410, symmetric with /info. (Closes #4)
  • Adds the embedded /setup HTML page that drives the user-facing half of the invite flow. Vanilla JS reads #invite= from window.location.hash, calls /info on load, and /redeem on submit; success view shows the mst_… plaintext once with a copy button and a posta://pair?token=…&url=… deep link. Visual treatment matches the landing card precedent. (Closes #5)

Auth middleware grows a path-skip variant for the public carve-out (invite endpoints + setup page); a per-IP rate limiter dampens brute-force noise on /api/v1/invite/*. The flag surface on serve shrinks to --listen and --manifest; --acl, --rate-per-minute, --config remain as global daemon-wide flags (per Lenient choice during triage); per-identity flags --url, --keys, --db, --name are gone.

#3 (production cutover) and #6 (legacy container cleanup) stay open as ready-for-human — runbooks the operator executes after this lands.

Test plan

  • go test ./... clean
  • go vet ./... clean
  • Manifest reader: happy path, empty manifest, missing file, bad slug regex, duplicate slug, duplicate host, unparseable URL, default-port stripping
  • Atomic manifest write: round-trip, validation rejects before write, no leftover tmpfiles, original survives a failed write
  • Daemon: 2-identity Host routing, empty manifest 404s, request with explicit default port, Start/Cancel cleanly
  • Invite store: create/find/redeem round-trip, double-redeem fails, expired fails, list/revoke
  • End-to-end invite over httptest: /setup HTML, /info 200/410, /redeem 200/410, second redeem 410, minted token authenticates against /api/v1/identity, auth still rejects without bearer
  • Real-binary smoke: identity add --print-tokeninvite createservecurl /setupcurl /infocurl /redeemcurl -X POST /redeem (second time, expects 410) → curl -H "Authorization: Bearer …" /api/v1/identitycurl -H "Host: unknown.example" /api/v1/identity (expects 404)

🤖 Generated with Claude Code

## Summary - Collapses the single-tenant daemon into a multi-tenant one. One process now hosts an arbitrary number of identities, dispatched by HTTP `Host` header. Identities are defined entirely by `/etc/posta/identities.toml`; per-identity files live by convention at `/var/lib/posta/<slug>/{keys.json,inbox.db}`. (Closes #1) - Adds the operator CLI: `posta-server identity add/list/remove/purge` and slug-scoped `token create/list/revoke`. Manifest writes are atomic (tmpfile + fsync + rename); slug regex `[a-z0-9-]+` is enforced at write time; mutating commands print a "restart the daemon" hint and never auto-restart. (Closes #2) - Adds the invite-link onboarding backend: migration v3 (`invites` table), `posta-server invite create/list/revoke`, plus `GET /api/v1/invite/info` and `POST /api/v1/invite/redeem`. Tokens are 32 random bytes, base64url-encoded with `pinv_` prefix; only sha256 hashes persist; default TTL 24h. The redeem path mints + consumes atomically; failure cases (unknown / expired / consumed) all return 410, symmetric with `/info`. (Closes #4) - Adds the embedded `/setup` HTML page that drives the user-facing half of the invite flow. Vanilla JS reads `#invite=` from `window.location.hash`, calls `/info` on load, and `/redeem` on submit; success view shows the `mst_…` plaintext once with a copy button and a `posta://pair?token=…&url=…` deep link. Visual treatment matches the landing card precedent. (Closes #5) Auth middleware grows a path-skip variant for the public carve-out (invite endpoints + setup page); a per-IP rate limiter dampens brute-force noise on `/api/v1/invite/*`. The flag surface on `serve` shrinks to `--listen` and `--manifest`; `--acl`, `--rate-per-minute`, `--config` remain as global daemon-wide flags (per Lenient choice during triage); per-identity flags `--url`, `--keys`, `--db`, `--name` are gone. #3 (production cutover) and #6 (legacy container cleanup) stay open as `ready-for-human` — runbooks the operator executes after this lands. ## Test plan - [x] `go test ./...` clean - [x] `go vet ./...` clean - [x] Manifest reader: happy path, empty manifest, missing file, bad slug regex, duplicate slug, duplicate host, unparseable URL, default-port stripping - [x] Atomic manifest write: round-trip, validation rejects before write, no leftover tmpfiles, original survives a failed write - [x] Daemon: 2-identity Host routing, empty manifest 404s, request with explicit default port, Start/Cancel cleanly - [x] Invite store: create/find/redeem round-trip, double-redeem fails, expired fails, list/revoke - [x] End-to-end invite over httptest: `/setup` HTML, `/info` 200/410, `/redeem` 200/410, second redeem 410, minted token authenticates against `/api/v1/identity`, auth still rejects without bearer - [x] Real-binary smoke: `identity add --print-token` → `invite create` → `serve` → `curl /setup` → `curl /info` → `curl /redeem` → `curl -X POST /redeem` (second time, expects 410) → `curl -H "Authorization: Bearer …" /api/v1/identity` → `curl -H "Host: unknown.example" /api/v1/identity` (expects 404) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Collapse the single-tenant serve into a multi-tenant daemon that hosts
one isolated stack per identity, dispatched by HTTP Host header.
Identities are defined by a TOML manifest at /etc/posta/identities.toml;
per-identity files live by convention at /var/lib/posta/<slug>/.

Adds the operator surface this requires (`identity add/list/remove/purge`,
`token *` slug-scoped, atomic manifest rewrites) plus the invite-link
onboarding flow (`invite create/list/revoke`, /api/v1/invite/info+redeem,
embedded /setup HTML page). Auth middleware grows a path-skip variant for
the public carve-out; a per-IP rate limiter dampens brute-force noise on
invite endpoints.

Closes #1, #2, #4, #5. (#3 production cutover and #6 legacy cleanup are
ready-for-human runbooks executed after this lands.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- /api/v1/invite/info: GET ?invite= → POST {invite} so the plaintext
  never reaches access logs (setup.html and invite_test updated)
- Auth-bypass prefix match now requires a path boundary, so
  /setupanything no longer skips authentication; mirrored into the
  invite IP rate-limiter for defense-in-depth
- Token create/revoke no longer print the manifest restart hint —
  per-identity DB writes are picked up live by the running daemon;
  revoke help reverts to "stop authenticating immediately"
- Sort Runners() output (the doc claimed sorted iteration but the
  body iterated a map)
- RedeemInvite checks RowsAffected on the consume UPDATE, so a
  concurrent redeem race loser rolls back the freshly minted token
- atomicWriteFile now fsyncs the parent dir after rename so a fresh
  identity add survives a power loss; matches WriteManifest
- runIdentityAdd error path tells the operator how to clean up
  orphan files when WriteManifest fails after keys/DB are created
- Smaller cleanups: rename IPRateLimiter.cap → maxEntries (shadowed
  builtin), drop the dbPathForSlug shim, stop capturing+discarding
  the unused Identity in openIdentityStore, gofmt across the tree

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
arne merged commit 8574bdaeb0 into main 2026-05-10 01:19:49 +02:00
arne deleted branch multi-tenant-rollout 2026-05-10 01:19:49 +02:00
Sign in to join this conversation.
No reviewers
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!7
No description provided.