New ATProto account stub + at.domain vanity plumbing (pending UX)

closed
#3a8659e opened by agent Sep 11

New ATProto account stub + <user>.at.<domain> vanity plumbing (UX first, PDS later)

Parent: bug 3726817. Depends on: Issues 1+2. Decision locked: admin checks “provision new ATProto account”, UI previews <user>.at.<domain>; mailbox created pending until real PDS wiring (Issue 4). Intent is to lock UX now, wire real DIDs once UX is not confusing.

Goal: Admin onboarding for users without an ATProto account works end-to-end in UX (preview → create pending → DNS instruction → well-known resolves locally), behind a PDSProvisioner interface with fake/manual backend. Zero per-user DNS records.

Architecture: *.at.<domain> CNAME -> pds.sovrn.at (1 record/domain). sovrnd/Caddy serves /.well-known/atproto-did per Host from app-DB mapping. PreviewHandle(address,domain) = <local>.at.<domain>. Stub provisioner returns pending + preview DID; production impl lands in Issue 4.

Tech Stack: Go (internal/pdsprovisioner), router.go, Caddy (deployment/roles/caddy/templates/Caddyfile.j2), templ UI, docs.


Task 1: PDSProvisioner interface + fake

Files: - Create: internal/pdsprovisioner/provisioner.go - Create: internal/pdsprovisioner/fake.go - Create: internal/pdsprovisioner/provisioner_test.go - Modify: config.go (flag, e.g. PDSMode: fake|manual|live)

  • [ ] Step 1: Write failing test
func TestPreviewHandle(t *testing.T) {
    // PreviewHandle("[email protected]", "companyx.com") == "alice.at.companyx.com"
}
func TestFakeProvision_Pending(t *testing.T) {
    // Fake.Provision(ctx, "alice.at.companyx.com") -> status pending, DID placeholder non-empty
}
  • [ ] Step 2: Run to verify it fails

Run: go test ./internal/pdsprovisioner/ -v Expected: FAIL (package undefined)

  • [ ] Step 3: Implement
// internal/pdsprovisioner/provisioner.go
package pdsprovisioner
type Result struct { DID, Handle, Status string } // Status: pending|active
type Provisioner interface {
    PreviewHandle(address, domain string) string
    Provision(ctx context.Context, handle, address string) (Result, error)
}
func PreviewHandle(address, domain string) string {
    // local-part before @, lowercase, sanitize [a-z0-9-]; return local + ".at." + domain
}

Fake returns Result{DID:"did:plc:pending:"+handle, Handle:handle, Status:"pending"}. Manual mode returns instructions string for operator.

  • [ ] Step 4: Run tests

Run: go test ./internal/pdsprovisioner/ -v Expected: PASS

  • [ ] Step 5: Commit
jj commit -m "feat(pds): PDSProvisioner interface + fake backend" internal/pdsprovisioner/provisioner.go internal/pdsprovisioner/fake.go internal/pdsprovisioner/provisioner_test.go config.go

Task 2: Creation path with new-account checkbox (pending)

Files: - Modify: internal/appview/provision.go (allow pending status passthrough) - Modify: internal/appview/mail_create_account.go, internal/appview/ui/handler.go - Modify: internal/appview/ui/domain.templ (checkbox + live preview), account.templ (pending banner) - Test: internal/appview/mail_create_account_test.go, internal/appview/ui/create_account_test.go

  • [ ] Step 1: Write failing test
func TestCreateAccount_NewAccountCheckbox_Pending(t *testing.T) {
    // tenant-owner posts {address:"bob", new_account:true} -> mailbox Status==pending, Handle==bob.at.domain
}
  • [ ] Step 2: Run to verify it fails

Run: go test ./internal/appview/ ./internal/appview/ui/ -run TestCreateAccount_NewAccount -v Expected: FAIL

  • [ ] Step 3: Implement
// handler/XRPC: if newAccount { handle := provisioner.PreviewHandle(addr, dom.Domain); res := provisioner.Provision(...); did = res.DID; status = pending }
// ProvisionAccount gains status param (default active for handle path, pending for new-account path)

UI: checkbox toggles handle input disabled + preview line New ATProto handle will be bob.at.companyx.com; pending account page shows “ATProto account pending — complete PDS setup (Issue 4)” banner + DNS hint.

  • [ ] Step 4: Run tests

Run: go test ./internal/appview/ ./internal/appview/ui/ -v Expected: PASS

  • [ ] Step 5: Commit
jj commit -m "feat(mail): new-account checkbox creates pending mailbox with preview handle" internal/appview/provision.go internal/appview/mail_create_account.go internal/appview/ui/handler.go internal/appview/ui/domain.templ internal/appview/ui/account.templ

Task 3: Host-based /.well-known/atproto-did + DNS guide

Files: - Modify: router.go (well-known route) - Create: internal/wellknown/wellknown.go + test (or inline handler if codebase prefers) - Modify: deployment/roles/caddy/templates/Caddyfile.j2 (route /.well-known/atproto-did to sovrnd, preserve Host) - Modify: deployment/inventory/group_vars/all/sovrn.yml (add pds_hostname: pds.sovrn.at var if missing) - Modify: docs/03-provisioning.md (vanity section: 1 CNAME instruction) - Test: router_test.go or internal/wellknown/wellknown_test.go

  • [ ] Step 1: Write failing test
func TestWellKnownATProtoDID_ByHost(t *testing.T) {
    // seed mailbox bob.at.companyx.com -> did:plc:xyz
    // GET /.well-known/atproto-did Host: bob.at.companyx.com -> body == did:plc:xyz
    // unknown host -> 404
}
  • [ ] Step 2: Run to verify it fails

Run: go test ./internal/wellknown/ . -run TestWellKnownATProtoDID -v Expected: FAIL

  • [ ] Step 3: Implement
// lookup Host (strip port, lowercase) -> strip to handle -> store lookup handle->DID
// serve text/plain body DID; Cache-Control: max-age=60

DNS guide (docs + account page hint):

*.at.companyx.com.  300  IN  CNAME  pds.sovrn.at.
  • [ ] Step 4: Run tests

Run: go test ./internal/wellknown/ . -v Expected: PASS

  • [ ] Step 5: Commit
jj commit -m "feat(wellknown): serve atproto-did per Host for at.domain handles" router.go internal/wellknown/wellknown.go deployment/roles/caddy/templates/Caddyfile.j2 docs/03-provisioning.md

Acceptance: - Admin previews bob.at.companyx.com, creates pending mailbox, sees single-CNAME instruction. - curl -H 'Host: bob.at.companyx.com' /​.well-known/atproto-did returns bound DID locally. - <user>.sovrn.at documented as fallback only (no code dependency). - No real PLC/PDS calls in this issue (that’s Issue 4).

1 Comment

agent 30ab876 Sep 12

Implementation update: new-account checkbox UX slice (Tasks 1+2)

Implemented on top of xstvmxok in a new jj change (“feat(new-account): checkbox + pending handle plumbing”). All decisions from review applied.

What landed:

  • internal/pdsprovisioner/ (new): Provisioner interface (PreviewHandle + Provision), PreviewHandle(address, domain) unifying mailbox-local and ATProto handle-label validation, Fake/Manual stub backends returning pending + did:plc:pending:<handle>. Error help text: “This is not a valid handle. Use only letters (A-Z, a-z), numbers (0-9), and hyphens (-)…” (no underscore — underscores are invalid per syntax.ParseHandle, so the earlier draft wording was corrected).
  • config.go: PDSMode: fake|manual|live (default fake, SOVRN_PDSMODE); router.go builds the backend and injects it into UI Deps and the XRPC handler. live explicitly falls back to the stub until Issue 4.
  • internal/appview/provision.go: ProvisionAccountWithStatus (pending for new-account path, active otherwise); no store migration (status column exists).
  • XRPC at.sovrn.mail.createAccount: new optional newAccount boolean in the lexicon + regenerated api/sovrn type; handler derives the preview handle, rejects newAccount+handle/did combos, returns state: pending. Old constructor kept as Fake-default wrapper (NewMailCreateAccountHandlerWithProvisioner for injection).
  • UI domain.templ: both fields equal-sized with explicit <label>s (mailbox-name, owner-handle, new-account); unchecked-by-default Create a New Atmosphere Account checkbox under Owner Handle; single #form-error slot moved below the form; live preview <p id="new-account-preview">.
  • static/new_account.js (new, served via embed.go): disables/fills Owner Handle with <local>.at.<domain> on check, live-updates on mailbox input, re-enables on uncheck/form-reset. No-JS fallback: checkbox overrides any submitted owner_handle server-side.

Verification: just test (fresh, no cache) — all packages pass. TDD throughout: new tests in pdsprovisioner, mail_create_account_test.go (incl. JMAP stub for the happy path), ui/create_account_test.go (template elements, error-slot position, pending no-JS, handle override, bad-name 422), ui/ui_test.go (static route). gofmt/go vet clean. No JS runtime in the environment, so the shim was verified by review + serving test (not executed in a browser).

Left for later (unchanged scope): host-based /.well-known/atproto-did + CNAME/DNS guide (Task 3), real PLC/PDS provisioning (Issue 4).