Lexicon: at.sovrn.mail.service — endpoint inventory (webmail + appview)

closed
#a472ea3 opened by agent Aug 28

Goal

Define the public at.sovrn.mail.service record (ADR-0006 D12): endpoints only, no domain, no address. Authored into the user’s public repo at first login.

Constraint

Contains exactly what the two surfaces need to connect to the correct API endpoints, nothing else:

  • app view (admins): XRPC base URL for domain/user management
  • webmail (users): webmail URL + (if needed) the JMAP/IMAP/SMTP hostnames the client talks to directly

Deliverable

  • Lexicon schema + Go types
  • Final endpoint field list, coordinated with the forked webmail (bug ac83db8) and the app-view implementation
  • The first-login write path that publishes the record with the user’s repo-write scope

Depends on: webmail fork evaluation (ac83db8), app-view XRPC shape. Basis: docs/adr/0006-data-placement-and-space-topology.md §D13 + “Design possibilities” D-G.

1 Comment

agent a54a742 Sep 16

Transparent cell redirect on OAuth login — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A user whose home cell differs from the single landing root (sovrn.at, which serves first-contact logins) is transparently 307-redirected to their home cell during POST /oauth/login, and a first-time user gets an at.sovrn.mail.service record written pointing at the cell that served them.

Architecture: POST /oauth/login reads the public service record from the user’s PDS (unauthenticated getRecord), matches the record’s appview host against the cell-host rule, and emits 307 to https://<cell>/oauth/login so the browser re-POSTs to the owning cell, which then runs the unchanged indigo OAuth flow. No proxying: the cell that runs StartLogin must own oauth.db state and the callback URL.

Tech Stack: Go (stdlib net/http, net/url), indigo atproto/identity (Directory.LookupDID) + atproto/auth/oauth, generated types in api/sovrn, jj for commits.

Design doc: docs/plans/2026-09-16-cell-redirect-login-design.md (committed).

Revision (2026-09-16, fail-closed): the lookup has three typed outcomes — authoritative RecordNotFound is the ONLY path to local login; PDS trouble renders a 503 retry page; malformed records or non-cell appview hosts render a 500 support page ([email protected]). Invariant: never serve a session on a cell when the user’s home is determinable-but-unreachable or indeterminate-due-to-error. First-login write happens before session issuance; write failure renders the 503 page with no session.


Task 1: Cell host matcher (pure function)

Files: - Create: internal/authbroker/cellmatch.go - Test: internal/authbroker/cellmatch_test.go

Rule: redirect target must be https, no explicit port, host is *.sovrn.at (bare sovrn.at itself is the single landing root — never a redirect target, avoids self-loops), host differs from self, or be in the explicit extraAllowed list (dev/test override). Anything else → support page (fail closed, never local login).

  • [ ] Step 1: Write the failing test
package authbroker

import "testing"

func TestIsCellRedirectTarget(t *testing.T) {
	cases := []struct {
		name                  string
		rawAppview, self      string
		extra                 []string
		wantTarget            string
		wantOK                bool
	}{
		{"cell host", "https://mx1.eu.sovrn.at", "mx2.eu.sovrn.at", nil, "https://mx1.eu.sovrn.at/oauth/login", true},
		{"case insensitive", "HTTPS://MX1.EU.SOVRN.AT", "mx2.eu.sovrn.at", nil, "https://mx1.eu.sovrn.at/oauth/login", true},
		{"self", "https://mx1.eu.sovrn.at", "mx1.eu.sovrn.at", nil, "", false},
		{"bare sovrn.at never redirects", "https://sovrn.at", "mx1.eu.sovrn.at", nil, "", false},
		{"evil host", "https://evil.example/oauth/login", "mx1.eu.sovrn.at", nil, "", false},
		{"http rejected", "http://mx1.eu.sovrn.at", "mx2.eu.sovrn.at", nil, "", false},
		{"explicit port rejected", "https://mx1.eu.sovrn.at:8443", "mx2.eu.sovrn.at", nil, "", false},
		{"suffix trick", "https://mx1.eu.sovrn.at.evil.example", "mx2.eu.sovrn.at", nil, "", false},
		{"garbage", "not a url", "mx1.eu.sovrn.at", nil, "", false},
		{"extra allowlist", "https://cell1.sovrn.test", "mx1.eu.sovrn.at", []string{"cell1.sovrn.test"}, "https://cell1.sovrn.test/oauth/login", true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, ok := IsCellRedirectTarget(tc.rawAppview, tc.self, tc.extra)
			if ok != tc.wantOK || got != tc.wantTarget {
				t.Errorf("IsCellRedirectTarget(%q) = (%q, %v), want (%q, %v)", tc.rawAppview, got, ok, tc.wantTarget, tc.wantOK)
			}
		})
	}
}
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/authbroker/ -run TestIsCellRedirectTarget -v Expected: FAIL with undefined: IsCellRedirectTarget

  • [ ] Step 3: Write minimal implementation
package authbroker

import (
	"net/url"
	"strings"
)

// IsCellRedirectTarget reports whether rawAppview (the appview URL from a
// user's at.sovrn.mail.service record) names a cell login POSTs may be sent
// to. It returns the target https://<host>/oauth/login URL. Bare sovrn.at is
// Bare sovrn.at is the single landing root, never a redirect target; self
// never redirects; anything outside *.sovrn.at (plus extraAllowed) fails
// closed.
func IsCellRedirectTarget(rawAppview, selfHost string, extraAllowed []string) (string, bool) {
	u, err := url.Parse(strings.TrimSpace(rawAppview))
	if err != nil || u == nil {
		return "", false
	}
	if !strings.EqualFold(u.Scheme, "https") {
		return "", false
	}
	host := strings.ToLower(u.Hostname())
	if host == "" || u.Port() != "" {
		return "", false
	}
	self := strings.ToLower(strings.TrimSpace(selfHost))
	if host == self || host == "sovrn.at" {
		return "", false
	}
	if strings.HasSuffix(host, ".sovrn.at") {
		return "https://" + host + "/oauth/login", true
	}
	for _, e := range extraAllowed {
		if host == strings.ToLower(strings.TrimSpace(e)) && e != "" {
			return "https://" + host + "/oauth/login", true
		}
	}
	return "", false
}
  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/authbroker/ -run TestIsCellRedirectTarget -v Expected: PASS (all subtests)

  • [ ] Step 5: Commit
jj commit -m "feat(authbroker): add cell redirect target matcher" internal/authbroker/cellmatch.go internal/authbroker/cellmatch_test.go

Task 2: Config plumbing (self host + extra hosts)

Files: - Modify: config.go - Test: config_test.go

Derive the cell’s own hostname from the already-configured appview.url; add an optional cell.extrahosts list for dev/test. No behavior change yet.

  • [ ] Step 1: Write the failing test
func TestCellSelfHost(t *testing.T) {
	c := &Config{AppView: URLConfig{URL: "https://mx1.eu.sovrn.at"}}
	if got := c.CellSelfHost(); got != "mx1.eu.sovrn.at" {
		t.Fatalf("CellSelfHost = %q, want mx1.eu.sovrn.at", got)
	}
	empty := &Config{}
	if got := empty.CellSelfHost(); got != "" {
		t.Fatalf("CellSelfHost (unset) = %q, want empty", got)
	}
}

(Append to config_test.go; keep the file’s existing package/imports.)

  • [ ] Step 2: Run test to verify it fails

Run: go test . -run TestCellSelfHost -v Expected: FAIL with undefined: CellSelfHost (method on *Config)

  • [ ] Step 3: Write minimal implementation
// CellConfig tunes the transparent cell redirect (bug a472ea3). SelfHost is
// derived from appview.url, never configured twice. ExtraHosts allows
// non-sovrn.at cell names in dev/test (empty in prod).
type CellConfig struct {
	ExtraHosts []string `mapstructure:"extrahosts"`
}

Add Cell CellConfig \mapstructure:“cell”`to theConfigstruct, bind“cell.extrahosts”inbindEnv`, and add:

// CellSelfHost returns the lower-cased hostname of appview.url — the cell's
// own name used by the redirect matcher. Empty when appview.url is unset or
// unparseable (redirect disabled).
func (c *Config) CellSelfHost() string {
	u, err := url.Parse(strings.TrimSpace(c.AppView.URL))
	if err != nil || u == nil {
		return ""
	}
	return strings.ToLower(u.Hostname())
}

Add "net/url" to config.go imports (strings is already imported).

  • [ ] Step 4: Run test to verify it passes

Run: go test . -run TestCellSelfHost -v && go build ./... Expected: PASS, build clean

  • [ ] Step 5: Commit
jj commit -m "feat(config): add cell self-host derivation and extra hosts" config.go config_test.go

Task 3: DID → PDS endpoint resolution

Files: - Create: internal/identity/pds.go - Test: internal/identity/pds_test.go

identity.Resolver only maps handle↔DID today. The record reader needs the PDS base URL from the DID document’s atproto_pds service endpoint, via indigo’s Directory.LookupDID (interface method — same seam directoryResolver already uses).

  • [ ] Step 1: Write the failing test
package identity

import (
	"context"
	"testing"

	indigo "github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"
)

type stubPDSDir struct {
	ident *indigo.Identity
	err   error
}

func (s stubPDSDir) LookupDID(_ context.Context, _ syntax.DID) (*indigo.Identity, error) {
	return s.ident, s.err
}

func TestResolvePDSURL(t *testing.T) {
	did, _ := syntax.ParseDID("did:plc:xyz123")
	dir := stubPDSDir{ident: &indigo.Identity{
		DID: did,
		Services: map[string]indigo.ServiceEndpoint{
			"atproto_pds": {Type: "AtprotoPersonalDataServer", URL: "https://pds.example/"},
		},
	}}
	got, err := resolvePDSURL(context.Background(), dir, "did:plc:xyz123")
	if err != nil {
		t.Fatalf("resolvePDSURL: %v", err)
	}
	if got != "https://pds.example" {
		t.Fatalf("got %q, want https://pds.example", got)
	}

	if _, err := resolvePDSURL(context.Background(), stubPDSDir{ident: &indigo.Identity{DID: did}}, "did:plc:xyz123"); err == nil {
		t.Fatal("missing atproto_pds service must error")
	}
	if _, err := resolvePDSURL(context.Background(), dir, "not-a-did"); err == nil {
		t.Fatal("non-DID input must error")
	}
}
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/identity/ -run TestResolvePDSURL -v Expected: FAIL with undefined: resolvePDSURL

  • [ ] Step 3: Write minimal implementation
package identity

import (
	"context"
	"fmt"
	"net/url"
	"strings"

	indigo "github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"
)

// pdsDirectory is the LookupDID half of indigo's Directory interface, kept
// narrow so tests can stub it. indigo's Directory satisfies it.
type pdsDirectory interface {
	LookupDID(ctx context.Context, did syntax.DID) (*indigo.Identity, error)
}

// ResolvePDSURL returns the https base URL of the PDS hosting rawDID's repo,
// taken from the atproto_pds service endpoint in the DID document.
func ResolvePDSURL(ctx context.Context, rawDID string) (string, error) {
	return resolvePDSURL(ctx, indigo.DefaultDirectory(), rawDID)
}

func resolvePDSURL(ctx context.Context, dir pdsDirectory, rawDID string) (string, error) {
	did, err := syntax.ParseDID(strings.TrimSpace(rawDID))
	if err != nil {
		return "", fmt.Errorf("%w: %q", ErrInvalidHandle, rawDID)
	}
	ident, err := dir.LookupDID(ctx, did)
	if err != nil {
		return "", fmt.Errorf("%w: %s: %v", ErrUnknownHandle, did.String(), err)
	}
	svc, ok := ident.Services["atproto_pds"]
	if !ok || strings.TrimSpace(svc.URL) == "" {
		return "", fmt.Errorf("identity: no PDS endpoint for %s", did.String())
	}
	u, err := url.Parse(strings.TrimSpace(svc.URL))
	if err != nil || !strings.EqualFold(u.Scheme, "https") || u.Hostname() == "" {
		return "", fmt.Errorf("identity: bad PDS endpoint %q", svc.URL)
	}
	return "https://" + strings.ToLower(u.Hostname()), nil
}

Note: port is dropped deliberately — PDS endpoints are standard-https in this design; explicit ports fail closed here and the login falls through to local handling.

  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/identity/ -v Expected: PASS (new + all existing tests)

  • [ ] Step 5: Commit
jj commit -m "feat(identity): resolve DID to hosting PDS base URL" internal/identity/pds.go internal/identity/pds_test.go

Task 4: Public service-record reader (with cache)

Files: - Create: internal/authbroker/servicerecord.go - Test: internal/authbroker/servicerecord_test.go

Unauthenticated GET {pds}/xrpc/com.atproto.repo.getRecord?repo={did}&collection=at.sovrn.mail.service&rkey=self. Positive results cached 24h per DID (in-memory, mutex-guarded — one cell, one process); errors never cached. rkey=self comes from the lexicon (key: literal:self).

The lookup has three typed outcomes (fail-closed — see Revision note above). Only an authoritative RecordNotFound (parsed from the PDS error body, not the status alone) means “new user”. Handle-resolution errors pass through unwrapped (the handler keeps today’s 400 for unknown identifiers); DID→PDS resolution failures wrap as ErrPDSUnavailable.

  • [ ] Step 1: Write the failing test
package authbroker

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"
)

func TestRecordReaderLookup(t *testing.T) {
	var hits atomic.Int64
	pds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		hits.Add(1)
		if r.URL.Path != "/xrpc/com.atproto.repo.getRecord" {
			http.NotFound(w, r)
			return
		}
		q := r.URL.Query()
		if q.Get("collection") != "at.sovrn.mail.service" || q.Get("rkey") != "self" || q.Get("repo") != "did:plc:alice" {
			http.Error(w, "bad query", http.StatusBadRequest)
			return
		}
		fmt.Fprint(w, `{"uri":"at://did:plc:alice/at.sovrn.mail.service/self","cid":"bafy","value":{"$type":"at.sovrn.mail.service","appview":"https://mx1.eu.sovrn.at","webmail":"https://mx1.eu.sovrn.at/webmail"}}`)
	}))
	defer pds.Close()

	rd := NewRecordReader(WithPDSBase("did:plc:alice", pds.URL))
	rec, err := rd.Lookup(context.Background(), "did:plc:alice")
	if err != nil {
		t.Fatalf("Lookup: %v", err)
	}
	if rec.Appview != "https://mx1.eu.sovrn.at" || rec.Webmail != "https://mx1.eu.sovrn.at/webmail" {
		t.Fatalf("record = %+v", rec)
	}
	// Second lookup must come from cache: exactly one server hit total.
	if _, err := rd.Lookup(context.Background(), "did:plc:alice"); err != nil {
		t.Fatalf("cached Lookup: %v", err)
	}
	if hits.Load() != 1 {
		t.Fatalf("hits = %d, want 1 (cache)", hits.Load())
	}
}

func TestRecordReaderNotFound(t *testing.T) {
	pds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusBadRequest)
		fmt.Fprint(w, `{"error":"RecordNotFound","message":"record not found"}`)
	}))
	defer pds.Close()
	rd := NewRecordReader(WithPDSBase("did:plc:bob", pds.URL))
	if _, err := rd.Lookup(context.Background(), "did:plc:bob"); !errors.Is(err, ErrNoServiceRecord) {
		t.Fatalf("err = %v, want ErrNoServiceRecord", err)
	}
}

func TestRecordReaderErrorClasses(t *testing.T) {
	pds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Query().Get("repo") {
		case "did:plc:broken":
			fmt.Fprint(w, `{"uri":"x","cid":"y","value":{"$type":"at.sovrn.mail.service"}}`)
		default:
			http.Error(w, `{"error":"InternalError","message":"boom"}`, http.StatusInternalServerError)
		}
	}))
	defer pds.Close()
	rd := NewRecordReader(
		WithPDSBase("did:plc:broken", pds.URL),
		WithPDSBase("did:plc:down", pds.URL),
	)
	if _, err := rd.Lookup(context.Background(), "did:plc:broken"); !errors.Is(err, ErrMalformedRecord) {
		t.Fatalf("malformed err = %v, want ErrMalformedRecord", err)
	}
	if _, err := rd.Lookup(context.Background(), "did:plc:down"); !errors.Is(err, ErrPDSUnavailable) {
		t.Fatalf("down err = %v, want ErrPDSUnavailable", err)
	}
}
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/authbroker/ -run TestRecordReader -v Expected: FAIL with undefined: NewRecordReader

  • [ ] Step 3: Write minimal implementation
package authbroker

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	sovrn "git.kilimanjaro.io/sovrn/api/sovrn"
)

// ErrNoServiceRecord: the PDS authoritatively answered that the user has no
// at.sovrn.mail.service record (getRecord RecordNotFound). ONLY this error
// falls through to local login — the user is genuinely new.
var ErrNoServiceRecord = errors.New("authbroker: no at.sovrn.mail.service record")

// ErrPDSUnavailable: the PDS could not answer (transport error, timeout,
// non-RecordNotFound status, DID-resolution failure). Callers fail closed to
// the 503 retry page — never serve a wrong-cell session.
var ErrPDSUnavailable = errors.New("authbroker: PDS unavailable")

// ErrMalformedRecord: the PDS answered but the record is unusable (bad
// envelope, missing appview). Callers fail closed to the 500 support page.
var ErrMalformedRecord = errors.New("authbroker: malformed at.sovrn.mail.service record")

// ServiceRecord is the endpoint subset of at.sovrn.mail.service the login
// path needs. mailEndpoints stays deferred to the webmail fork (bug ac83db8).
type ServiceRecord struct {
	Appview string
	Webmail string
}

const serviceCollection = "at.sovrn.mail.service"

// recordCacheTTL: records ~never change (cell moves are rare); our own write
// invalidates explicitly via Invalidate.
const recordCacheTTL = 24 * time.Hour

type cachedRecord struct {
	rec     *ServiceRecord
	expires time.Time
}

// RecordReader fetches public service records. ResolvePDS maps a DID to its
// PDS base URL (default: identity.ResolvePDSURL); HTTP is the transport
// (default: 5s timeout); PDSBases is a test seam mapping DID→fake PDS.
type RecordReader struct {
	ResolvePDS func(ctx context.Context, did string) (string, error)
	HTTP       *http.Client
	Resolve    func(ctx context.Context, handle string) (string, error)

	mu    sync.Mutex
	bases map[string]string
	cache map[string]cachedRecord
}

type readerOption func(*RecordReader)

// WithPDSBase pins a DID to a PDS base URL (tests).
func WithPDSBase(did, base string) readerOption {
	return func(r *RecordReader) { r.bases[did] = base }
}

// NewRecordReader builds a reader; production wiring sets ResolvePDS and
// Resolve at construction in router.go (Task 5).
func NewRecordReader(opts ...readerOption) *RecordReader {
	r := &RecordReader{
		HTTP:  &http.Client{Timeout: 5 * time.Second},
		bases: map[string]string{},
		cache: map[string]cachedRecord{},
	}
	for _, o := range opts {
		o(r)
	}
	return r
}

// Invalidate drops the cached record for did (called after our own write).
func (r *RecordReader) Invalidate(did string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	delete(r.cache, did)
}

// Lookup returns the service record for identifier (handle or DID).
func (r *RecordReader) Lookup(ctx context.Context, identifier string) (*ServiceRecord, error) {
	did := strings.TrimSpace(identifier)
	if !strings.HasPrefix(did, "did:") {
		if r.Resolve == nil {
			return nil, fmt.Errorf("authbroker: cannot resolve handle without resolver")
		}
		var err error
		did, err = r.Resolve(ctx, did)
		if err != nil {
			return nil, err
		}
	}
	r.mu.Lock()
	if c, ok := r.cache[did]; ok && time.Now().Before(c.expires) {
		r.mu.Unlock()
		return c.rec, nil
	}
	r.mu.Unlock()

	base := r.bases[did]
	if base == "" {
		if r.ResolvePDS == nil {
			return nil, fmt.Errorf("authbroker: cannot resolve PDS without resolver")
		}
		var err error
		base, err = r.ResolvePDS(ctx, did)
		if err != nil {
			return nil, fmt.Errorf("%w: %v", ErrPDSUnavailable, err)
		}
	}
	endpoint := base + "/xrpc/com.atproto.repo.getRecord?repo=" + url.QueryEscape(did) +
		"&collection=" + url.QueryEscape(serviceCollection) + "&rkey=self"
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrPDSUnavailable, err)
	}
	resp, err := r.HTTP.Do(req)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrPDSUnavailable, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, classifyGetRecordError(resp)
	}
	var envelope struct {
		Value sovrn.MailService `json:"value"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
		return nil, fmt.Errorf("%w: decode: %v", ErrMalformedRecord, err)
	}
	if strings.TrimSpace(envelope.Value.Appview) == "" {
		return nil, fmt.Errorf("%w: missing appview", ErrMalformedRecord)
	}
	rec := &ServiceRecord{Appview: envelope.Value.Appview, Webmail: envelope.Value.Webmail}
	r.mu.Lock()
	r.cache[did] = cachedRecord{rec: rec, expires: time.Now().Add(recordCacheTTL)}
	r.mu.Unlock()
	return rec, nil
}

// classifyGetRecordError maps a non-200 getRecord response: only an
// authoritative RecordNotFound means "new user" (the sole fall-through to
// local login). Everything else is PDS trouble (fail closed to retry).
func classifyGetRecordError(resp *http.Response) error {
	var body struct {
		Error string `json:"error"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Error == "RecordNotFound" {
		return ErrNoServiceRecord
	}
	return fmt.Errorf("%w: getRecord status %d", ErrPDSUnavailable, resp.StatusCode)
}
  • [ ] Step 4: Run test to verify it passes

Run: go test ./internal/authbroker/ -run TestRecordReader -v Expected: PASS

  • [ ] Step 5: Commit
jj commit -m "feat(authbroker): public service-record reader with cache" internal/authbroker/servicerecord.go internal/authbroker/servicerecord_test.go

Task 5: 307 redirect hook in POST /oauth/login

Files: - Modify: internal/authbroker/authbroker.go (Broker gets an optional Cells field), internal/authbroker/handler.go (hook + callback order in Task 7), router.go (wiring) - Create: internal/authbroker/loginerror.go (self-contained 503⁄500 pages — NOT in ui, which imports authbroker) - Test: internal/authbroker/cellredirect_test.go, internal/authbroker/loginerror_test.go

Cells == nil preserves today’s behavior exactly (all existing handler tests keep passing unchanged). Decide has three outcomes: redirect target, fall-through (new user / self — empty target, nil error), or a typed error rendering an error page. Hop counter bounds chains at 2: the redirect appends ?cell_hop=N; an incoming cell_hop >= 2 renders the support page (a looping chain is itself anomalous).

  • [ ] Step 1: Write the failing test
package authbroker

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func redirectingBroker(t *testing.T, appview string) *Broker {
	t.Helper()
	b := testBroker(t)
	b.Cells = &CellRedirector{
		SelfHost: "mx2.eu.sovrn.at",
		Lookup:   func(_ context.Context, _ string) (*ServiceRecord, error) { return &ServiceRecord{Appview: appview}, nil },
	}
	return b
}

func TestLoginRedirectsToHomeCell(t *testing.T) {
	b := redirectingBroker(t, "https://mx1.eu.sovrn.at")
	form := "handle=alice.test&did=did%3Aplc%3Aalice"
	req := httptest.NewRequest(http.MethodPost, "/oauth/login", strings.NewReader(form))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	b.Handler().ServeHTTP(rec, req)

	if rec.Code != http.StatusTemporaryRedirect {
		t.Fatalf("status = %d, want 307", rec.Code)
	}
	loc := rec.Header().Get("Location")
	if !strings.HasPrefix(loc, "https://mx1.eu.sovrn.at/oauth/login") {
		t.Fatalf("Location = %q", loc)
	}
	for _, c := range rec.Result().Cookies() {
		if c.Name == returnToCookieName {
			t.Fatal("redirecting cell must not set the return-to cookie")
		}
	}
}

func TestLoginStaysLocalWhenSelf(t *testing.T) {
	b := redirectingBroker(t, "https://mx2.eu.sovrn.at")
	form := "handle=alice.test&did=did%3Aplc%3Aalice"
	req := httptest.NewRequest(http.MethodPost, "/oauth/login", strings.NewReader(form))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	b.Handler().ServeHTTP(rec, req)

	// Falls through to StartLogin, which fails for the unknown DID only after
	// network — it must NOT be a 307.
	if rec.Code == http.StatusTemporaryRedirect {
		t.Fatalf("self-pointing record must not redirect, got Location %q", rec.Header().Get("Location"))
	}
}

func TestLoginPDSDownRendersRetry(t *testing.T) {
	b := testBroker(t)
	b.Cells = &CellRedirector{
		SelfHost: "mx2.eu.sovrn.at",
		Lookup:   func(_ context.Context, _ string) (*ServiceRecord, error) { return nil, ErrPDSUnavailable },
	}
	form := "handle=alice.test&did=did%3Aplc%3Aalice"
	req := httptest.NewRequest(http.MethodPost, "/oauth/login", strings.NewReader(form))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	b.Handler().ServeHTTP(rec, req)

	if rec.Code != http.StatusServiceUnavailable {
		t.Fatalf("status = %d, want 503", rec.Code)
	}
	if body := rec.Body.String(); !strings.Contains(body, "try logging in again") {
		t.Fatalf("body %q must carry the retry message", body)
	}
	for _, c := range rec.Result().Cookies() {
		if c.Name == returnToCookieName {
			t.Fatal("error path must not set the return-to cookie")
		}
	}
}

func TestLoginUnknownHostRendersSupport(t *testing.T) {
	b := redirectingBroker(t, "https://evil.example")
	form := "handle=alice.test&did=did%3Aplc%3Aalice"
	req := httptest.NewRequest(http.MethodPost, "/oauth/login", strings.NewReader(form))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rec := httptest.NewRecorder()
	b.Handler().ServeHTTP(rec, req)

	if rec.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500", rec.Code)
	}
	if body := rec.Body.String(); !strings.Contains(body, "[email protected]") {
		t.Fatalf("body %q must carry the support contact", body)
	}
}

Note: the fall-through path calls real StartLogin (network). The second test only asserts “not a 307” — it may 302 or 400 depending on network; both prove no redirect. Keep it that way (no network stubbing in this task).

  • [ ] Step 2: Run tests to verify they fail

Run: go test ./internal/authbroker/ -run 'TestLoginRedirects|TestLoginStaysLocal|TestLoginPDSDown|TestLoginUnknownHost' -v Expected: FAIL with undefined: CellRedirector (field on *Broker)

  • [ ] Step 3: Write minimal implementation

In authbroker.go, add the field + type (same package):

// CellRedirector decides whether an incoming login POST belongs to another
// cell. Nil (zero value) disables the redirect — the broker behaves exactly
// as before. Lookup returns the caller's service record (or an error, which
// falls through to local login).
type CellRedirector struct {
	SelfHost     string
	ExtraHosts   []string
	Lookup       func(ctx context.Context, identifier string) (*ServiceRecord, error)
	MaxHops      int // 0 means default 2
}

// Decide returns the redirect target for identifier's service record:
//   - ("", nil): log in locally — authoritative RecordNotFound (new user) or
//     the record points at this cell.
//   - (target, nil): 307 the POST to the home cell.
//   - ("", err): fail closed — render the matching error page. ErrPDSUnavailable
//     → 503 retry; ErrMalformedRecord (including well-formed records whose
//     appview host fails the cell rule, and hop-bound breaches) → 500 support.
//     Identifier-resolution errors pass through for the handler's 400.
func (c *CellRedirector) Decide(ctx context.Context, identifier string, hop int) (string, error) {
	rec, err := c.Lookup(ctx, identifier)
	if err != nil {
		return "", err
	}
	if rec == nil {
		return "", ErrNoServiceRecord
	}
	if u, uerr := url.Parse(strings.TrimSpace(rec.Appview)); uerr == nil &&
		strings.EqualFold(u.Hostname(), strings.ToLower(strings.TrimSpace(c.SelfHost))) {
		return "", nil // home cell: log in locally
	}
	target, ok := IsCellRedirectTarget(rec.Appview, c.SelfHost, c.ExtraHosts)
	if !ok {
		return "", fmt.Errorf("%w: appview %q", ErrMalformedRecord, rec.Appview)
	}
	max := c.MaxHops
	if max <= 0 {
		max = 2
	}
	if hop >= max {
		return "", fmt.Errorf("%w: redirect hop bound", ErrMalformedRecord)
	}
	if hop > 0 {
		return target + "?cell_hop=" + strconv.Itoa(hop+1), nil
	}
	return target + "?cell_hop=1", nil
}

(authbroker.go needs context, fmt, net/url, strconv, strings imports for the above — add what’s missing.)

Add Cells *CellRedirector to the Broker struct.

In handler.go, at the top of POST /oauth/login (before the return-to cookie block):

identifier := pickLoginIdentifier(r.FormValue("handle"), r.FormValue("did"))
if b.Cells != nil {
	hop := 0
	if h := r.URL.Query().Get("cell_hop"); h != "" {
		_, _ = fmt.Sscanf(h, "%d", &hop)
	}
	target, derr := b.Cells.Decide(r.Context(), identifier, hop)
	if derr != nil {
		writeLoginError(w, r, identifier, derr)
		return
	}
	if target != "" {
		// 307 preserves method + body so the browser re-POSTs
		// handle/did/next untouched. cell_hop rides the query string
		// because hop=0 has no query to preserve; the form body already
		// carries next when the form supplied it.
		if r.FormValue("next") == "" {
			if next := r.URL.Query().Get("next"); next != "" {
				target += "&next=" + url.QueryEscape(next)
			}
		}
		http.Redirect(w, r, target, http.StatusTemporaryRedirect)
		return
	}
	// ("", nil): new user or home cell — fall through to local login.
}
// ... existing cookie + StartLogin(identifier) path unchanged ...

handler.go needs net/url import (fmt too if not present — check; handler.go currently imports encoding/json, net/http, strings).

New file internal/authbroker/loginerror.go — self-contained error pages (NOT in ui: ui imports authbroker for SessionAuth.Broker, so the reverse import would cycle). No user input is echoed, so no escaping concerns; the identifier goes only to the server log for support triage:

package authbroker

import (
	"errors"
	"fmt"
	"log/slog"
	"net/http"
)

// writeLoginError renders fail-closed login errors with no session and no
// cookies: PDS trouble → 503 retry page; malformed/unknown-host records → 500
// support page; anything else (bad identifier) → today's 400.
func writeLoginError(w http.ResponseWriter, r *http.Request, identifier string, err error) {
	slog.Warn("cell login refused", "identifier", identifier, "err", err)
	status := http.StatusBadRequest
	title := "Sign in failed"
	body := fmt.Sprintf("login: %v", err)
	link := `<a href="/login">Back to sign in</a>`
	switch {
	case errors.Is(err, ErrPDSUnavailable):
		status = http.StatusServiceUnavailable
		title = "Account info unavailable"
		body = "We couldn't reach your account info. Please try logging in again in a minute."
		link = `<a href="/login">Try again</a>`
	case errors.Is(err, ErrMalformedRecord):
		status = http.StatusInternalServerError
		title = "Account record problem"
		body = "Something is wrong with your account record. Please contact [email protected] and mention this page."
		link = `<a href="mailto:[email protected]">[email protected]</a>`
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(status)
	fmt.Fprintf(w, `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>%s — sovrn</title><link rel="stylesheet" href="/static/tailwind.css"/></head>
<body class="min-h-screen bg-slate-50 text-slate-900 antialiased">
<main class="mx-auto max-w-sm px-4 py-16">
<h1 class="text-2xl font-bold tracking-tight">%s</h1>
<p class="mt-2 text-sm text-slate-600">%s</p>
<p class="mt-6 text-sm font-semibold text-blue-700">%s</p>
</main>
</body>
</html>`, title, title, body, link)
}

loginerror_test.go pins the mapping without HTTP-level tests (the handler tests above cover status + copy end-to-end):

func TestWriteLoginErrorMapping(t *testing.T) {
	cases := []struct {
		err  error
		code int
		want string
	}{
		{ErrPDSUnavailable, http.StatusServiceUnavailable, "try logging in again"},
		{fmt.Errorf("%w: appview %q", ErrMalformedRecord, "https://evil.example"), http.StatusInternalServerError, "[email protected]"},
		{fmt.Errorf("identity: invalid handle"), http.StatusBadRequest, "login: identity"},
	}
	for _, tc := range cases {
		rec := httptest.NewRecorder()
		writeLoginError(rec, httptest.NewRequest(http.MethodPost, "/oauth/login", nil), "alice.test", tc.err)
		if rec.Code != tc.code {
			t.Errorf("err %v: status = %d, want %d", tc.err, rec.Code, tc.code)
		}
		if body := rec.Body.String(); !strings.Contains(body, tc.want) {
			t.Errorf("err %v: body %q missing %q", tc.err, body, tc.want)
		}
		if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
			t.Errorf("content-type = %q", ct)
		}
	}
}

In router.go, after broker = b (inside the cfg.OAuth.ClientID != "" block):

reader := authbroker.NewRecordReader()
reader.ResolvePDS = identity.ResolvePDSURL
reader.Resolve = func(ctx context.Context, handle string) (string, error) {
	return identity.Default().ResolveHandle(ctx, handle)
}
broker.Cells = &authbroker.CellRedirector{
	SelfHost:   cfg.CellSelfHost(),
	ExtraHosts: cfg.Cell.ExtraHosts,
	Lookup:     reader.Lookup,
}

(router.go already imports identity; add context — check: router.go imports context already for applyCtx. Yes.)

Clean up: the helper above is final (no scaffolding to remove).

  • [ ] Step 4: Run tests to verify

Run: go test ./internal/authbroker/ -v && go build ./... Expected: all PASS, build clean

  • [ ] Step 5: Commit
jj commit -m "feat(authbroker): 307 login POST to home cell from service record" internal/authbroker/authbroker.go internal/authbroker/handler.go internal/authbroker/loginerror.go internal/authbroker/cellredirect_test.go internal/authbroker/loginerror_test.go router.go

Task 6: Login scope set (atproto + at.sovrn.* repo write)

Files: - Create: internal/authbroker/scopes.go - Test: internal/authbroker/scopes_test.go - Modify: router.go (one line: Scopes: authbroker.LoginScopes())

Scope strings stay derived from lexicons/: the test walks lexicons/at/sovrn/**/*.json, extracts every record id, and requires coverage. Exact repo: action syntax is verified against the vendored indigo in Step 3’s spike.

  • [ ] Step 1: Write the failing test
package authbroker

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func TestLoginScopesCoverLexicons(t *testing.T) {
	scopes := strings.Join(LoginScopes(), " ")
	if !strings.Contains(scopes, "atproto") {
		t.Fatal("scopes must include atproto")
	}
	var ids []string
	root := filepath.Join("..", "..", "lexicons", "at", "sovrn")
	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() || !strings.HasSuffix(path, ".json") {
			return err
		}
		raw, err := os.ReadFile(path)
		if err != nil {
			return err
		}
		var doc struct {
			ID   string `json:"id"`
			Defs map[string]struct {
				Type string `json:"type"`
			} `json:"defs"`
		}
		if err := json.Unmarshal(raw, &doc); err != nil {
			return err
		}
		for _, d := range doc.Defs {
			if d.Type == "record" {
				ids = append(ids, doc.ID)
			}
		}
		return nil
	})
	if err != nil {
		t.Fatalf("walk lexicons: %v", err)
	}
	if len(ids) == 0 {
		t.Fatal("no record lexicons found — path wrong?")
	}
	for _, id := range ids {
		if !strings.Contains(scopes, id) {
			t.Errorf("scopes %q do not cover record %s", scopes, id)
		}
	}
}
  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/authbroker/ -run TestLoginScopesCoverLexicons -v Expected: FAIL with undefined: LoginScopes

  • [ ] Step 3: Spike — confirm scope syntax, then implement

Run: rg -n "action=|permission" /home/btburke/go/pkg/mod/github.com/bluesky-social/[email protected]/atproto/auth/oauth/ | head -20 Expected: indigo’s scope parsing/validation. Per docs/02 §2.3 indigo treats scopes as opaque strings — record the finding in a code comment, then implement per-collection repo scopes:

package authbroker

// SovrnRecordCollections lists every at.sovrn.* record collection the app may
// need repo access to. Test TestLoginScopesCoverLexicons enforces this stays
// in sync with lexicons/at/sovrn/**. (Spike finding: indigo <version> treats
// scopes as opaque strings at PAR time — see docs/02 §2.3 — so these pass
// through verbatim; the PDS enforces them. Revisit if upstream adds
// permission-set validation.)
var SovrnRecordCollections = []string{
	"at.sovrn.mail.service",
	"at.sovrn.mail.account",
	"at.sovrn.domain.detail",
	"at.sovrn.tenant.detail",
	"at.sovrn.space.tenant",
	"at.sovrn.space.domain",
}

// LoginScopes is the OAuth scope set for sovrn login: identity plus repo
// create/update over every sovrn record collection, so first login can write
// the service record and future permissioned-space records need no re-consent.
func LoginScopes() []string {
	scopes := []string{"atproto"}
	for _, c := range SovrnRecordCollections {
		scopes = append(scopes, "repo:"+c+"?action=create&action=update")
	}
	return scopes
}

(Adjust the collection list to exactly what the lexicon walk finds — the test is the enforcer; if it names more/fewer records, update the list, not the test.)

  • [ ] Step 4: Run test + rewire router, verify

Run: go test ./internal/authbroker/ -run TestLoginScopesCoverLexicons -v Expected: PASS

Then in router.go change Scopes: []string{"atproto"} → Scopes: authbroker.LoginScopes() and run go build ./... && go test ./internal/authbroker/ .

  • [ ] Step 5: Commit
jj commit -m "feat(auth): request at.sovrn.* repo scope at login" internal/authbroker/scopes.go internal/authbroker/scopes_test.go router.go

Task 7: First-login create-only service-record write

Files: - Modify: internal/authbroker/servicerecord.go (add Ensure), internal/authbroker/handler.go (call from callback) - Test: extend internal/authbroker/servicerecord_test.go

Create-only: GET first; putRecord only when absent. Write-then-issue: the callback runs Ensure BEFORE IssueSession; write failure renders the 503 retry page with no session cookie (the unconsumed OAuth store row is harmless; the user retries with a fresh flow). rkey=self, collection=at.sovrn.mail.service.

  • [ ] Step 1: Spike — find the authenticated-request path

The callback has *oauth.ClientSessionData (tokens) via CompleteLogin. Find how to make an authenticated XRPC call with it:

Run: rg -n "func \(s \*ClientSession\)|func \(s \*ClientSessionData\)|type ClientSession" /home/btburke/go/pkg/mod/github.com/bluesky-social/[email protected]/atproto/auth/oauth/ | head -20

Expected: the session type’s methods. Record the chosen call path (e.g. resume + agent method, or raw DPoP HTTP) in a comment on Ensure, then implement against it. If the vendored indigo exposes no clean authenticated-XRPC helper, implement Ensure with explicit putRecord over net/http using the session’s access JWT + DPoP proof per the atproto/auth/oauth package’s exported helpers found in the spike.

  • [ ] Step 2: Write the failing test

Fake PDS: getRecord → 400 (absent); putRecord → capture body, return {"uri":...,"cid":...}. Second Ensure call: getRecord → 200 with a record → putRecord must NOT be called again (create-only).

func TestEnsureServiceRecordCreateOnly(t *testing.T) {
	var puts atomic.Int64
	var putBody atomic.Value
	pds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/xrpc/com.atproto.repo.getRecord":
			if r.URL.Query().Get("repo") == "did:plc:new" {
				http.Error(w, "not found", http.StatusBadRequest)
				return
			}
			fmt.Fprint(w, `{"uri":"at://did:plc:old/at.sovrn.mail.service/self","cid":"bafy","value":{"$type":"at.sovrn.mail.service","appview":"https://mx9.eu.sovrn.at","webmail":"https://mx9.eu.sovrn.at/webmail"}}`)
		case "/xrpc/com.atproto.repo.putRecord":
			puts.Add(1)
			raw, _ := io.ReadAll(r.Body)
			putBody.Store(string(raw))
			fmt.Fprint(w, `{"uri":"at://did:plc:new/at.sovrn.mail.service/self","cid":"bafy2"}`)
		default:
			http.NotFound(w, r)
		}
	}))
	defer pds.Close()

	// ... construct authed caller per spike findings, then:
	// Ensure("did:plc:new") → one putRecord with collection+rkey=self, $type, appview
	// Ensure("did:plc:old") → zero additional putRecords
}

func TestEnsureServiceRecordWriteFailure(t *testing.T) {
	pds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/xrpc/com.atproto.repo.getRecord":
			http.Error(w, `{"error":"RecordNotFound","message":"nope"}`, http.StatusBadRequest)
		case "/xrpc/com.atproto.repo.putRecord":
			http.Error(w, `{"error":"InternalError","message":"boom"}`, http.StatusInternalServerError)
		default:
			http.NotFound(w, r)
		}
	}))
	defer pds.Close()

	// ... construct authed caller per spike findings, then:
	// Ensure("did:plc:new") → error satisfying errors.Is(err, ErrPDSUnavailable).
	// The handler maps this to the 503 retry page with no session cookie
	// (asserted at the handler level by reusing writeLoginError's mapping).
}

(The exact authed-caller construction follows the spike; the assertions — one PUT for missing, zero PUTs for present, body fields — are fixed.)

  • [ ] Step 3: Run test to verify it fails

Run: go test ./internal/authbroker/ -run TestEnsureServiceRecord -v Expected: FAIL with undefined: Ensure (or equivalent)

  • [ ] Step 4: Implement + wire callback (write-then-issue), verify

Ensure(ctx, authedCaller, did, appviewURL, webmailURL): Invalidate(did) then Lookup (fresh read, no stale positive); if ErrNoServiceRecord → putRecord; else return nil. putRecord transport/non-200 failures wrap as ErrPDSUnavailable (fail closed like the read path).

In handler.go GET /oauth/callback, reorder to write-then-issue:

session, err := b.CompleteLogin(r.Context(), r.URL.Query())
if err != nil { ... 400 ... }
// Fail-closed write: no session cookie until the home record exists.
if werr := b.EnsureServiceRecord(r.Context(), session, ...); werr != nil {
	writeLoginError(w, r, session.AccountDID.String(), werr)
	return
}
token, err := b.IssueSession(r.Context(), session.AccountDID, session.SessionID)
...

(EnsureServiceRecord is a Broker method wrapping Ensure with the broker’s AppViewURL/WebmailURL fields, set in router.go from cfg.AppView.URL / cfg.Webmail.URL. Exact session field/type names per the Step-1 spike.)

Run: go test ./internal/authbroker/ -v && go build ./... Expected: PASS, build clean

  • [ ] Step 5: Commit
jj commit -m "feat(authbroker): create-only service-record write on first login" internal/authbroker/servicerecord.go internal/authbroker/servicerecord_test.go internal/authbroker/handler.go internal/authbroker/authbroker.go router.go

Task 8: Two-cell integration + deployment cutover

Files: - Create: internal/integration/cellredirect_test.go - Modify: deployment/roles/caddy/templates/Caddyfile.j2, per-cell deployment/inventory/host_vars/<cell>/vars.yml (new), docs/runbooks/provision-cell.md

  • [ ] Step 1: Write the failing integration test
// Two cells, one stub PDS record pointing at cell B. POST to cell A must 307
// to B; the same POST replayed to B must not redirect (TargetFor == "").

Use two authbroker.Broker instances via testBroker-equivalent (integration package has its own helpers — follow internal/integration/helpers_test.go conventions): cell A SelfHost: mx1..., cell B SelfHost: mx2..., shared stub Lookup returning Appview: https://mx2.eu.sovrn.at. Assert A → 307 with Location: https://mx2.eu.sovrn.at/oauth/login?cell_hop=1, B → no 307.

  • [ ] Step 2: Run test to verify it fails

Run: go test ./internal/integration/ -run TestTwoCellRedirect -v Expected: FAIL (test file references behavior not yet shell-tested at this level — if it passes immediately against Task 5 code, extend with the next-preservation assertion: POST with ?next=%2Fdomains%2Fx and assert the 307 Location carries next)

  • [ ] Step 3: Deployment changes

Single landing root (per-box LE issuance behind round-robin DNS cannot complete): exactly one host serves sovrn.at, named by a single string var (double-landing unrepresentable — never per-host booleans).

deployment/inventory/group_vars/all/sovrn.yml:

sovrn_root_host: "sovrn.at"

Caddy: gate the existing sovrn.at site block on the root ({% if inventory_hostname == sovrn_root_host %}), and add the per-cell hostname vhost (skipped when the cell hostname IS sovrn.at, i.e. the smoke host — {% if sovrn_cell_hostname != "sovrn.at" %}). did.json + ACME-challenge special cases stay inside the sovrn.at block; the cell host gets a normal auto-HTTPS cert via the global acme_ca. New var sovrn_cell_hostname defaults to {{ inventory_hostname }}.

Caddy role assert (fail fast on typos — otherwise the root is served nowhere):

- name: Guard landing-root singularity (bug a472ea3)
  ansible.builtin.assert:
    that:
      - sovrn_root_host in groups["all"]
    fail_msg: "sovrn_root_host={{ sovrn_root_host }} names no inventory host (typo?) — sovrn.at would be served nowhere and no cell would manage its cert"

sovrn.toml.j2 [landing] section (sole Go-visible surface of the root):

[landing]
enabled = {{ 'true' if inventory_hostname == sovrn_root_host else 'false' }}
roothost = "{{ sovrn_root_host }}"

Per-cell host_vars/mxN.<region>.sovrn.at/vars.yml (OAuth identity must be per-cell; sovrn.at fleet defaults stay for the root only):

sovrn_appview_url: "https://mxN.<region>.sovrn.at"
sovrn_oidc_issuer: "https://mxN.<region>.sovrn.at"
sovrn_oauth_clientid: "https://mxN.<region>.sovrn.at/oauth/client-metadata"
sovrn_oauth_callbackurl: "https://mxN.<region>.sovrn.at/oauth/callback"
sovrn_mail_hostname: "mxN.<region>.sovrn.at"

Runbook (docs/runbooks/provision-cell.md): single sovrn.at A record pointing at the root (NOT round-robin); rotation procedure (change the var, converge old+new root, move the A, verify with curl --resolve sovrn.at:443:<new-ip> https://sovrn.at/healthz).

TLS-expiry healthz leg (healthz.go + config.go): health.tlsminexpiry (default 336h) fails tls-expiry when any locally-served public hostname’s leaf cert expires within the window. Hostnames derived in Go (no new hostname config): appview URL host always + landing root iff landing.enabled. Dial is a seam (nil = production tls.Dialer with SNI) so tests stay off-network. Follows the existing check conventions (skip-ok on empty config, fail-closed otherwise).

  • [ ] Step 4: Verify

Run: go test ./internal/integration/ -run TestTwoCellRedirect -v && go build ./... && python3 -c "import yaml,glob; [yaml.safe_load(open(f)) for f in glob.glob('deployment/inventory/host_vars/*/vars.yml')]; print('yaml ok')" Expected: PASS, build clean, yaml ok

Plus the Caddy render matrix (python3 + jinja2): smoke host (inventory_hostname=sovrn.at) renders the sovrn.at block and no cell block; a cell renders its cell block and no sovrn.at block; a rotated root renders both. Plus go test . -run 'TestTLSExpiryCheck|TestTLSServedNames|TestLandingDefaults' PASS.

  • [ ] Step 5: Commit
jj commit -m "feat(deploy): single landing root for sovrn.at certs" deployment/inventory/group_vars/all/sovrn.yml deployment/roles/caddy/templates/Caddyfile.j2 deployment/roles/caddy/tasks/main.yml docs/runbooks/provision-cell.md docs/plans/2026-09-16-cell-redirect-login-design.md
jj commit -m "feat(healthz): TLS-expiry leg for served hostnames" config.go config_test.go healthz.go healthz_test.go deployment/roles/sovrnd/templates/sovrn.toml.j2
jj commit -m "feat(cells): two-cell redirect integration and per-cell deployment" internal/integration/cellredirect_test.go internal/authbroker/handler.go deployment/inventory/host_vars/mx99.eu.sovrn.at/vars.yml

Self-review

  • Spec coverage: transparent redirect (§flow steps 1–5) → Tasks 1–5; fail-closed errors (503 retry / 500 support, three typed lookup outcomes, unknown-host → support) → Tasks 4–5; create-only write on first login, write-then-issue → Task 7; at.sovrn.* permission-set scope → Task 6; long cache TTL → Task 4 (recordCacheTTL + Invalidate); mailEndpoints deferred → explicitly out of scope (reader keeps only Appview/Webmail); single landing root (sovrn_root_host) + rotation + TLS-expiry leg → Task 8; matcher security question → Task 1 rule + design doc rationale.
  • No placeholders: every step names exact files, complete code/commands, expected outputs. Tasks 6–7 contain explicit spike steps with exact rg commands against the vendored indigo instead of assumed APIs.
  • Type consistency: ServiceRecord{Appview, Webmail}, RecordReader.Lookup(ctx, identifier), CellRedirector{SelfHost, ExtraHosts, Lookup, MaxHops} + TargetFor(ctx, identifier, hop), LoginScopes() []string, Ensure(...) create-only — defined once, referenced identically everywhere.