ATProto identity <-> mailbox mapping (app DB now, spaces record later)
closedProblem
Associating mailboxes with ATProto identities is the core purpose of this
program, but no such step exists: mailboxes.did is set to the creator
DID at creation and never displayed, edited, or enforced. Concretely:
- Account/app-password UI handlers perform NO ownership check — once real multi-user OAuth is live, any logged-in DID can manage any mailbox (single-DID dev masks this today).
- The XRPC
mail.createAccountdidoverride lets any caller claim any DID, unscoped. - There is no handle -> DID resolution step and no PDS-hosting path for users without an identity.
Decisions (locked in discussion 2026-09-11)
- Only the tenant owner may reassign a mailbox’s DID.
- Two association cases must be designed:
- mailbox -> EXISTING user handle (resolve handle -> DID, bind it);
- mailbox -> NEW DID with sovrn-hosted PDS (create DID + host PDS).
Design questions (case 2 needs careful thought before code)
- Handle strategy:
user.sovrn.athandles vsuser.<hosted>.<domain>handles. The latter requires per-name resolution (DNS TXT records the tenant must publish, or sovrn serving/.well-known/atproto-didper hosted name) — weigh tenant manageability against vanity. - Where PDS hosting lives (scope vs this issue / spaces delivery).
- Display: handles, DIDs, or both on the account page.
Proposal
- Stage A (app DB, pre-spaces): show owner DID/handle on the account
page; tenant-owner-only reassign via handle input (resolve via PDS
com.atproto.identity.resolveHandle; check whatauthbrokeralready offers); enforcecaller DID == account DID or tenant owneron account detail + app-password issue/revoke; scope the XRPCdidoverride to tenant owners. App DB stays source-of-truth. - Stage B (spaces): mirror the mapping as a user record in the space-permissioned repo; defines the “second ATProto account logs in → sees/creates own mailbox + app password, accesses via IMAP/JMAP” flow.
Files (stage A)
internal/appview/provision.go,mail_create_account.go,mail_create_app_password.go,mail_list_app_passwords.go,mail_remove_app_password.go,internal/appview/ui/handler.go,account.templ,internal/store(mailboxes.did already exists)
Acceptance
- Second ATProto login sees only its own mailboxes; can create mailbox + app password and read mail via IMAP/JMAP.
- Non-owner cannot manage another DID’s mailbox (tested).
1 Comment
Design plan approved — split into 5 issues
Locked decisions (discussion 2026-09-11 + follow-up): - Admin =
Tenant.OwnerDIDsole admin; multi-admin deferred. - No mailbox without DID: admin supplies existing handle OR checks “provision new ATProto account” with live<user>.at.<domain>preview. - Vanity default:<user>.at.<domain>via*.at.<domain> CNAME -> pds.sovrn.at+ Host-based/.well-known/atproto-did.<user>.sovrn.atfallback only. - Stage A UX-first withpendingstub; real PDS wiring after UX locked. - PDS eval covers atproto-crates + tranquil-pds + ZDS; Sept launch, spaces GA Nov upgrade path; enterprise 2FA/passkeys + S3/CDN blobs required. - Non-admin landing = “My mailboxes” + Host-new-domain CTA.Sub-issues
RequireTenantOwner+CanAccessMailbox, scope XRPCdidoverride,ListAccountsByDID+ index, role-gated templates.internal/identitywrapper (indigo Directory, cache ≤1h),handle?on createAccount, admin handle input + typeahead, account page handle+DID.internal/pdsprovisionerinterface + fake, new-account checkbox →pendingmailbox, Host-based/.well-known/atproto-did, 1-CNAME DNS guide.at.sovrn.mail.accountmirror, second-login e2e, reconciliation sweep. Post-launch, not a launch blocker.Sequencing
Issues 1 → 2 → 3 (each shippable, TDD,
jj commitper task). Issues 4–5 run in parallel as tracking; Issue 4 decision unblocksPDSMode=fake→livebackfill ofpendingmailboxes. App DB stays source-of-truth throughout Stage A.