Lexicon: at.sovrn.mail.service — endpoint inventory (webmail + appview)
closedGoal
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
Transparent cell redirect on OAuth login — Implementation Plan
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 duringPOST /oauth/login, and a first-time user gets anat.sovrn.mail.servicerecord written pointing at the cell that served them.Architecture:
POST /oauth/loginreads the public service record from the user’s PDS (unauthenticatedgetRecord), matches the record’sappviewhost against the cell-host rule, and emits307tohttps://<cell>/oauth/loginso the browser re-POSTs to the owning cell, which then runs the unchanged indigo OAuth flow. No proxying: the cell that runsStartLoginmust ownoauth.dbstate and the callback URL.Tech Stack: Go (stdlib
net/http,net/url), indigoatproto/identity(Directory.LookupDID) +atproto/auth/oauth, generated types inapi/sovrn,jjfor 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
RecordNotFoundis the ONLY path to local login; PDS trouble renders a 503 retry page; malformed records or non-cellappviewhosts 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.goRule: redirect target must be
https, no explicit port, host is*.sovrn.at(baresovrn.atitself is the single landing root — never a redirect target, avoids self-loops), host differs from self, or be in the explicitextraAllowedlist (dev/test override). Anything else → support page (fail closed, never local login).Run:
go test ./internal/authbroker/ -run TestIsCellRedirectTarget -vExpected: FAIL withundefined: IsCellRedirectTargetRun:
go test ./internal/authbroker/ -run TestIsCellRedirectTarget -vExpected: PASS (all subtests)Task 2: Config plumbing (self host + extra hosts)
Files: - Modify:
config.go- Test:config_test.goDerive the cell’s own hostname from the already-configured
appview.url; add an optionalcell.extrahostslist for dev/test. No behavior change yet.(Append to
config_test.go; keep the file’s existing package/imports.)Run:
go test . -run TestCellSelfHost -vExpected: FAIL withundefined: CellSelfHost(method on*Config)Add
Cell CellConfig \mapstructure:“cell”`to theConfigstruct, bind“cell.extrahosts”inbindEnv`, and add:Add
"net/url"toconfig.goimports (stringsis already imported).Run:
go test . -run TestCellSelfHost -v && go build ./...Expected: PASS, build cleanTask 3: DID → PDS endpoint resolution
Files: - Create:
internal/identity/pds.go- Test:internal/identity/pds_test.goidentity.Resolveronly maps handle↔DID today. The record reader needs the PDS base URL from the DID document’satproto_pdsservice endpoint, via indigo’sDirectory.LookupDID(interface method — same seamdirectoryResolveralready uses).Run:
go test ./internal/identity/ -run TestResolvePDSURL -vExpected: FAIL withundefined: resolvePDSURLNote: 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.
Run:
go test ./internal/identity/ -vExpected: PASS (new + all existing tests)Task 4: Public service-record reader (with cache)
Files: - Create:
internal/authbroker/servicerecord.go- Test:internal/authbroker/servicerecord_test.goUnauthenticated
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=selfcomes 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 asErrPDSUnavailable.Run:
go test ./internal/authbroker/ -run TestRecordReader -vExpected: FAIL withundefined: NewRecordReaderRun:
go test ./internal/authbroker/ -run TestRecordReader -vExpected: PASSTask 5: 307 redirect hook in POST /oauth/login
Files: - Modify:
internal/authbroker/authbroker.go(Broker gets an optionalCellsfield),internal/authbroker/handler.go(hook + callback order in Task 7),router.go(wiring) - Create:internal/authbroker/loginerror.go(self-contained 503⁄500 pages — NOT inui, which importsauthbroker) - Test:internal/authbroker/cellredirect_test.go,internal/authbroker/loginerror_test.goCells == nilpreserves today’s behavior exactly (all existing handler tests keep passing unchanged).Decidehas 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 incomingcell_hop >= 2renders the support page (a looping chain is itself anomalous).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).Run:
go test ./internal/authbroker/ -run 'TestLoginRedirects|TestLoginStaysLocal|TestLoginPDSDown|TestLoginUnknownHost' -vExpected: FAIL withundefined: CellRedirector(field on*Broker)In
authbroker.go, add the field + type (same package):(
authbroker.goneedscontext,fmt,net/url,strconv,stringsimports for the above — add what’s missing.)Add
Cells *CellRedirectorto theBrokerstruct.In
handler.go, at the top ofPOST /oauth/login(before the return-to cookie block):handler.goneedsnet/urlimport (fmttoo if not present — check; handler.go currently imports encoding/json, net/http, strings).New file
internal/authbroker/loginerror.go— self-contained error pages (NOT inui:uiimportsauthbrokerforSessionAuth.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:loginerror_test.gopins the mapping without HTTP-level tests (the handler tests above cover status + copy end-to-end):In
router.go, afterbroker = b(inside thecfg.OAuth.ClientID != ""block):(
router.goalready importsidentity; addcontext— check: router.go imports context already for applyCtx. Yes.)Clean up: the helper above is final (no scaffolding to remove).
Run:
go test ./internal/authbroker/ -v && go build ./...Expected: all PASS, build cleanTask 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 walkslexicons/at/sovrn/**/*.json, extracts every recordid, and requires coverage. Exactrepo:action syntax is verified against the vendored indigo in Step 3’s spike.Run:
go test ./internal/authbroker/ -run TestLoginScopesCoverLexicons -vExpected: FAIL withundefined: LoginScopesRun:
rg -n "action=|permission" /home/btburke/go/pkg/mod/github.com/bluesky-social/[email protected]/atproto/auth/oauth/ | head -20Expected: 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:(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.)
Run:
go test ./internal/authbroker/ -run TestLoginScopesCoverLexicons -vExpected: PASSThen in
router.gochangeScopes: []string{"atproto"}→Scopes: authbroker.LoginScopes()and rungo build ./... && go test ./internal/authbroker/ .Task 7: First-login create-only service-record write
Files: - Modify:
internal/authbroker/servicerecord.go(addEnsure),internal/authbroker/handler.go(call from callback) - Test: extendinternal/authbroker/servicerecord_test.goCreate-only: GET first;
putRecordonly when absent. Write-then-issue: the callback runsEnsureBEFOREIssueSession; 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.The callback has
*oauth.ClientSessionData(tokens) viaCompleteLogin. 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 -20Expected: 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, implementEnsurewith explicitputRecordovernet/httpusing the session’s access JWT + DPoP proof per theatproto/auth/oauthpackage’s exported helpers found in the spike.Fake PDS:
getRecord→ 400 (absent);putRecord→ capture body, return{"uri":...,"cid":...}. SecondEnsurecall:getRecord→ 200 with a record →putRecordmust NOT be called again (create-only).(The exact authed-caller construction follows the spike; the assertions — one PUT for missing, zero PUTs for present, body fields — are fixed.)
Run:
go test ./internal/authbroker/ -run TestEnsureServiceRecord -vExpected: FAIL withundefined: Ensure(or equivalent)Ensure(ctx, authedCaller, did, appviewURL, webmailURL):Invalidate(did)thenLookup(fresh read, no stale positive); ifErrNoServiceRecord→putRecord; else return nil.putRecordtransport/non-200 failures wrap asErrPDSUnavailable(fail closed like the read path).In
handler.goGET /oauth/callback, reorder to write-then-issue:(
EnsureServiceRecordis a Broker method wrappingEnsurewith the broker’sAppViewURL/WebmailURLfields, set inrouter.gofromcfg.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 cleanTask 8: Two-cell integration + deployment cutover
Files: - Create:
internal/integration/cellredirect_test.go- Modify:deployment/roles/caddy/templates/Caddyfile.j2, per-celldeployment/inventory/host_vars/<cell>/vars.yml(new),docs/runbooks/provision-cell.mdUse two
authbroker.Brokerinstances viatestBroker-equivalent (integration package has its own helpers — followinternal/integration/helpers_test.goconventions): cell ASelfHost: mx1..., cell BSelfHost: mx2..., shared stubLookupreturningAppview: https://mx2.eu.sovrn.at. Assert A → 307 withLocation: https://mx2.eu.sovrn.at/oauth/login?cell_hop=1, B → no 307.Run:
go test ./internal/integration/ -run TestTwoCellRedirect -vExpected: FAIL (test file references behavior not yet shell-tested at this level — if it passes immediately against Task 5 code, extend with thenext-preservation assertion: POST with?next=%2Fdomains%2Fxand assert the 307 Location carriesnext)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:Caddy: gate the existing
sovrn.atsite block on the root ({% if inventory_hostname == sovrn_root_host %}), and add the per-cell hostname vhost (skipped when the cell hostname ISsovrn.at, i.e. the smoke host —{% if sovrn_cell_hostname != "sovrn.at" %}).did.json+ ACME-challenge special cases stay inside thesovrn.atblock; the cell host gets a normal auto-HTTPS cert via the globalacme_ca. New varsovrn_cell_hostnamedefaults to{{ inventory_hostname }}.Caddy role assert (fail fast on typos — otherwise the root is served nowhere):
sovrn.toml.j2[landing]section (sole Go-visible surface of the root):Per-cell
host_vars/mxN.<region>.sovrn.at/vars.yml(OAuth identity must be per-cell;sovrn.atfleet defaults stay for the root only):Runbook (
docs/runbooks/provision-cell.md): singlesovrn.atA record pointing at the root (NOT round-robin); rotation procedure (change the var, converge old+new root, move the A, verify withcurl --resolve sovrn.at:443:<new-ip> https://sovrn.at/healthz).TLS-expiry healthz leg (
healthz.go+config.go):health.tlsminexpiry(default 336h) failstls-expirywhen 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 ifflanding.enabled. Dial is a seam (nil= productiontls.Dialerwith SNI) so tests stay off-network. Follows the existing check conventions (skip-ok on empty config, fail-closed otherwise).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 okPlus the Caddy render matrix (python3 + jinja2): smoke host (
inventory_hostname=sovrn.at) renders thesovrn.atblock and no cell block; a cell renders its cell block and nosovrn.atblock; a rotated root renders both. Plusgo test . -run 'TestTLSExpiryCheck|TestTLSServedNames|TestLandingDefaults'PASS.Self-review
at.sovrn.*permission-set scope → Task 6; long cache TTL → Task 4 (recordCacheTTL+Invalidate);mailEndpointsdeferred → explicitly out of scope (reader keeps onlyAppview/Webmail); single landing root (sovrn_root_host) + rotation + TLS-expiry leg → Task 8; matcher security question → Task 1 rule + design doc rationale.rgcommands against the vendored indigo instead of assumed APIs.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.