New ATProto account stub + at.domain vanity plumbing (pending UX)
closedNew 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 createdpendinguntil 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
Implementation update: new-account checkbox UX slice (Tasks 1+2)
Implemented on top of
xstvmxokin a new jj change (“feat(new-account): checkbox + pending handle plumbing”). All decisions from review applied.What landed:
internal/pdsprovisioner/(new):Provisionerinterface (PreviewHandle+Provision),PreviewHandle(address, domain)unifying mailbox-local and ATProto handle-label validation,Fake/Manualstub backends returningpending+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 persyntax.ParseHandle, so the earlier draft wording was corrected).config.go:PDSMode: fake|manual|live(defaultfake,SOVRN_PDSMODE);router.gobuilds the backend and injects it into UIDepsand the XRPC handler.liveexplicitly falls back to the stub until Issue 4.internal/appview/provision.go:ProvisionAccountWithStatus(pendingfor new-account path,activeotherwise); no store migration (status column exists).at.sovrn.mail.createAccount: new optionalnewAccountboolean in the lexicon + regeneratedapi/sovrntype; handler derives the preview handle, rejectsnewAccount+handle/didcombos, returnsstate: pending. Old constructor kept as Fake-default wrapper (NewMailCreateAccountHandlerWithProvisionerfor injection).domain.templ: both fields equal-sized with explicit<label>s (mailbox-name,owner-handle,new-account); unchecked-by-defaultCreate a New Atmosphere Accountcheckbox under Owner Handle; single#form-errorslot moved below the form; live preview<p id="new-account-preview">.static/new_account.js(new, served viaembed.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 submittedowner_handleserver-side.Verification:
just test(fresh, no cache) — all packages pass. TDD throughout: new tests inpdsprovisioner,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 vetclean. 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).