Mailbox <-> existing handle association (resolve + bind)

closed
#74339dc opened by agent Sep 11

Mailbox <-> 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

agent 7b423a3 Sep 12

Implementation Plan (approved, revised per review)

Locked decisions: (1) Strict explicit owner binding in UI and XRPC — no caller default, no backward-compat workarounds (pre-production API). createAccount requires exactly one of handle/did. (2) Dependency injection for the resolver.

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/identity package (Resolver interface, directory-backed impl over indigo’s Directory.LookupHandle/LookupDID, MapResolver mock). Handlers take it by constructor injection. Lexicon gains handle? (mutually exclusive with did; one required). UI form requires owner_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/identity resolver package

Files: Create internal/identity/identity.go, internal/identity/identity_test.go.

  • [ ] Step 1: Failing tests — ResolveHandle ok/normalization, invalid → ErrInvalidHandle, unknown → ErrUnknownHandle; ResolveDID ok/unknown; MapResolver both directions. Tests use indigo NewMockDirectory + Insert.
  • [ ] Step 2: go test ./internal/identity/ -v → FAIL (no such package).
  • [ ] Step 3: Implement — Resolver interface (string-based, two methods), directoryResolver over identity.Directory using LookupHandle/LookupDID (NOT ResolveHandle — absent from the interface), Default() over identity.DefaultDirectory() (already cached), MapResolver.
  • [ ] Step 4: Re-run → PASS.
  • [ ] Step 5: jj commit -m "feat(identity): handle<->DID resolver with mock" ...

Task 2: Lexicon handle + strict XRPC wiring

Files: lexicons/at/sovrn/mail/createAccount.json (+ regen api/sovrn/mailcreateAccount.go via go run ./cmd/sovrn-lexgen), internal/appview/mail_create_account.go (new resolver identity.Resolver param; exactly-one-of enforcement; resolve; syntax.ParseDID validation; single tenant-owner gate on the target DID), internal/appview/provision.go (empty-DID guard via ErrInvalidAddress), router.go (wire identity.Default()). Tests: extend authz_test.go, update mail_create_account_test.go bodies (explicit owner) + constructor call sites, provision_test.go empty-DID test.

Strict semantics (no compat default): - handle + did both → 400; neither → 400 “specify handle or did”. - Unknown/malformed handle → 400; malformed did → 400. - Target != caller → tenant-owner gate (403), unchanged.

  • [ ] Step 1: Failing tests — unknown handle → 400 + no write; handle+did → 400; neither → 400; malformed did → 400.
  • [ ] Step 2: Run → FAIL.
  • [ ] Step 3: Implement + regen (verify jj diff --stat shows only the one generated file).
  • [ ] Step 4: go test ./internal/appview/ -v → PASS.
  • [ ] Step 5: Commit.

Task 3: Admin UI handle input + resolve

Files: ui/handler.go (Deps.Resolver; createAccount requires owner_handle, resolves, passes DID — session DID no longer used), ui/domain.templ (required owner_handle input + typeahead attrs mirroring login.templ), ui/hx.go (ErrUnknownHandle/ErrInvalidHandle messages), router.go (wire). Tests: update create_account_test.go (address-only → 422; add resolve happy-path + bad-handle inline tests), wire MapResolver into UI Deps test helpers.

  • [ ] Step 1: Failing tests — missing owner_handle → 422; unknown handle → 422 inline + no row.
  • [ ] Step 2: Run → FAIL.
  • [ ] Step 3: Implement; regen templ; revert unrelated *_templ.go churn.
  • [ ] Step 4: go test ./internal/appview/ui/ -v → PASS.
  • [ ] Step 5: Commit.

Task 4: Account page handle display

Files: ui/view.go (AccountPageData.OwnerHandle), ui/handler.go (accountDetail + issueAppPassword render: best-effort ResolveDID, nil-Resolver-safe, error → DID-only), ui/account.templ (handle line when non-empty). Tests: owner handle shown; unknown → DID-only fallback.

  • [ ] Step 1: Failing test — bob.test on page.
  • [ ] Step 2: Run → FAIL.
  • [ ] Step 3: Implement.
  • [ ] Step 4: Full go test ./... → PASS.
  • [ ] Step 5: Commit.

Acceptance: no mailbox creatable without explicit owner (XRPC 400 / UI 422); handle resolves to correct DID; spoofing still 403; account page shows handle + DID.

agent 70463f3 Sep 12

Implemented per revised strict-API plan (4 jj commits, fresh full suite green):

  • feat(identity): Resolver interface (LookupHandle/LookupDID over indigo Directory; DefaultDirectory already cached), MapResolver mock, ErrInvalidHandle/ErrUnknownHandle
  • feat(mail): createAccount requires exactly one of handle/did — no caller default. handle resolves server-side; did format-validated; single tenant-owner gate on the target DID; empty-DID guard in ProvisionAccount. Lexicon + regen (api diff is just the Handle field).
  • feat(ui): admin form requires owner_handle (typeahead markup reused from login); resolve errors surface inline (422) or via redirect; shared createAccountError helper collapsed the duplicated HX/redirect branches.
  • feat(ui): account page shows Owner DID + resolved handle (best-effort, DID-only fallback, nil-Resolver-safe).

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).