Mailbox <-> existing handle association (resolve + bind)
closedMailbox <-> existing ATProto handle association
Parent: bug 3726817. Depends on: Permissions issue (tenant-owner gates). Decision locked: no mailbox without a DID. Admin supplies the user’s existing handle; server resolves handle -> DID and binds it.
Goal: Tenant admin creates a mailbox by typing the user’s existing handle (with typeahead); server resolves + validates to a DID before ProvisionAccount. Account page shows handle + DID. No spoofing, no orphan mailboxes.
Architecture: New internal/identity wrapper around indigo identity.Directory (handle -> DID -> DID doc, cache ≤1h per docs/02 §8). Reuse typeahead from commit 749bbae3. XRPC keeps did field but adds handle input; UI adds owner_handle field.
Tech Stack: Go, bluesky-social/indigo identity, internal/appview, internal/authbroker (check existing resolve helpers), templ UI.
Task 1: internal/identity resolve wrapper
Files:
- Create: internal/identity/identity.go
- Create: internal/identity/identity_test.go
- Modify: go.mod (only if new indigo import needed — prefer existing)
- [ ] Step 1: Write failing test
func TestResolveHandle_OK(t *testing.T) {
// stub directory returns did:plc:test for "alice.test"
did, err := ResolveHandle(ctx, "alice.test")
// expect did:plc:test, nil
}
func TestResolveHandle_BadHandle(t *testing.T) {
// expect error, no DID
}
- [ ] Step 2: Run to verify it fails
Run: go test ./internal/identity/ -v
Expected: FAIL (package undefined)
- [ ] Step 3: Minimal implementation
// internal/identity/identity.go
package identity
type Resolver interface { ResolveHandle(ctx context.Context, handle string) (string, error) }
func ResolveHandle(ctx context.Context, handle string) (string, error) {
// normalize lowercase/trim; delegate to indigo identity.DefaultDirectory();
// standard chain: DNS TXT _atproto.<handle> then HTTPS well-known;
// cache successes ≤1h (small in-memory LRU); return DID string
}
Keep a RequireDNSTXT bool option stubbed (for future apex-ownership gating per ADR-0005) but default off for login-grade resolution.
- [ ] Step 4: Run tests
Run: go test ./internal/identity/ -v
Expected: PASS
- [ ] Step 5: Commit
jj commit -m "feat(identity): handle->DID resolve wrapper with cache" internal/identity/identity.go internal/identity/identity_test.go
Task 2: Wire handle into creation (XRPC + provision)
Files:
- Modify: internal/appview/provision.go (validate DID, reject empty)
- Modify: internal/appview/mail_create_account.go
- Modify: lexicons/at/sovrn/mail/createAccount.json + api/sovrn/mailcreateAccount.go (add handle? field)
- Test: internal/appview/mail_create_account_test.go, provision_test.go
- [ ] Step 1: Write failing test
func TestCreateAccount_WithHandleResolvesDID(t *testing.T) {
// in.Handle="alice.test" (stubbed resolver) -> mailbox DID == did:plc:test
}
func TestCreateAccount_BadHandleRejected(t *testing.T) {
// in.Handle="not a handle!!" -> 400, no mailbox row
}
func TestCreateAccount_EmptyDIDRejected(t *testing.T) {
// tenant-owner creates with neither did nor handle -> 400
}
- [ ] Step 2: Run to verify it fails
Run: go test ./internal/appview/ -run 'TestCreateAccount_WithHandle|TestCreateAccount_BadHandle|TestCreateAccount_EmptyDID' -v
Expected: FAIL
- [ ] Step 3: Implement
// mail_create_account.go — after authz gate (Issue 1):
did := in.Did
if in.Handle != "" {
resolved, err := identity.ResolveHandle(ctx, in.Handle)
if err != nil { return badRequest("unknown handle") }
did = resolved
}
if did == "" { return badRequest("did or handle required") }
// validate did: prefix (did:plc:|did:web:) before ProvisionAccount
- [ ] Step 4: Run tests
Run: go test ./internal/appview/ -v
Expected: PASS
- [ ] Step 5: Commit
jj commit -m "feat(mail): createAccount binds existing handle->DID" internal/appview/provision.go internal/appview/mail_create_account.go lexicons/at/sovrn/mail/createAccount.json api/sovrn/mailcreateAccount.go internal/appview/mail_create_account_test.go
Task 3: Admin UI handle input + account display
Files:
- Modify: internal/appview/ui/handler.go (createAccount)
- Modify: internal/appview/ui/domain.templ (owner_handle input + typeahead), account.templ (show handle + DID)
- Modify: internal/appview/ui/view.go
- Test: internal/appview/ui/create_account_test.go, ui_test.go
- [ ] Step 1: Write failing test
func TestCreateAccountUI_HandleRequired(t *testing.T) {
// POST /domains/{id}/accounts {address, owner_handle:"bad!!"} -> 400 + formError, no mailbox
}
- [ ] Step 2: Run to verify it fails
Run: go test ./internal/appview/ui/ -run TestCreateAccountUI_HandleRequired -v
Expected: FAIL
- [ ] Step 3: Implement form + display
<!-- domain.templ Add-mailbox form: address input + owner_handle input (typeahead) -->
<!-- account.templ: Owner DID + handle (cached reverse lookup, best-effort) -->
// handler.go createAccount: read owner_handle, ResolveHandle, pass DID to ProvisionAccount
- [ ] Step 4: Run tests
Run: go test ./internal/appview/ui/ -v
Expected: PASS
- [ ] Step 5: Commit
jj commit -m "feat(ui): admin handle input + owner DID/handle display" internal/appview/ui/handler.go internal/appview/ui/domain.templ internal/appview/ui/account.templ internal/appview/ui/view.go internal/appview/ui/create_account_test.go
Acceptance:
- Admin types existing handle → mailbox bound to correct DID; bad handle → error, no row.
- Account page shows owner handle + DID.
- Non-owner did spoof still 403 (Issue 1 gates intact).
2 Comments
Implementation Plan (approved, revised per review)
Goal: Every mailbox is explicitly bound to an owner DID at creation — via existing handle (resolved server-side) or explicit DID — with handle+DID shown on the account page.
Architecture: New
internal/identitypackage (Resolverinterface, directory-backed impl over indigo’sDirectory.LookupHandle/LookupDID,MapResolvermock). Handlers take it by constructor injection. Lexicon gainshandle?(mutually exclusive withdid; one required). UI form requiresowner_handle. Reverse lookup is best-effort display only.Tech Stack: Go, indigo
atproto/identity+atproto/syntax, XRPC appview, templ UI,cmd/sovrn-lexgen.Task 1:
internal/identityresolver packageFiles: Create
internal/identity/identity.go,internal/identity/identity_test.go.ResolveHandleok/normalization, invalid →ErrInvalidHandle, unknown →ErrUnknownHandle;ResolveDIDok/unknown;MapResolverboth directions. Tests use indigoNewMockDirectory+Insert.go test ./internal/identity/ -v→ FAIL (no such package).Resolverinterface (string-based, two methods),directoryResolveroveridentity.DirectoryusingLookupHandle/LookupDID(NOTResolveHandle— absent from the interface),Default()overidentity.DefaultDirectory()(already cached),MapResolver.jj commit -m "feat(identity): handle<->DID resolver with mock" ...Task 2: Lexicon
handle+ strict XRPC wiringFiles:
lexicons/at/sovrn/mail/createAccount.json(+ regenapi/sovrn/mailcreateAccount.goviago run ./cmd/sovrn-lexgen),internal/appview/mail_create_account.go(newresolver identity.Resolverparam; exactly-one-of enforcement; resolve;syntax.ParseDIDvalidation; single tenant-owner gate on the target DID),internal/appview/provision.go(empty-DID guard viaErrInvalidAddress),router.go(wireidentity.Default()). Tests: extendauthz_test.go, updatemail_create_account_test.gobodies (explicit owner) + constructor call sites,provision_test.goempty-DID test.Strict semantics (no compat default): -
handle+didboth → 400; neither → 400 “specify handle or did”. - Unknown/malformed handle → 400; malformed did → 400. - Target != caller → tenant-owner gate (403), unchanged.jj diff --statshows only the one generated file).go test ./internal/appview/ -v→ PASS.Task 3: Admin UI handle input + resolve
Files:
ui/handler.go(Deps.Resolver;createAccountrequiresowner_handle, resolves, passes DID — session DID no longer used),ui/domain.templ(requiredowner_handleinput + typeahead attrs mirroringlogin.templ),ui/hx.go(ErrUnknownHandle/ErrInvalidHandlemessages),router.go(wire). Tests: updatecreate_account_test.go(address-only → 422; add resolve happy-path + bad-handle inline tests), wireMapResolverinto UIDepstest helpers.owner_handle→ 422; unknown handle → 422 inline + no row.*_templ.gochurn.go test ./internal/appview/ui/ -v→ PASS.Task 4: Account page handle display
Files:
ui/view.go(AccountPageData.OwnerHandle),ui/handler.go(accountDetail+issueAppPasswordrender: best-effortResolveDID, nil-Resolver-safe, error → DID-only),ui/account.templ(handle line when non-empty). Tests: owner handle shown; unknown → DID-only fallback.bob.teston page.go test ./...→ PASS.Acceptance: no mailbox creatable without explicit owner (XRPC 400 / UI 422); handle resolves to correct DID; spoofing still 403; account page shows handle + DID.
Implemented per revised strict-API plan (4 jj commits, fresh full suite green):
Notes: UI test Stalwart stub upgraded to a method-dispatching fake (tenant/domain/account-create) so the resolve-to-create happy path is tested end to end; login_templ.go regen churn reverted as unrelated. Acceptance holds: no mailbox without explicit owner (XRPC 400 / UI 422), spoofing still 403. Leaving open for your review (incl. exact UX wording in QA).