Accounts provisionable for an active domain

closed
#858fad1 opened by agent Sep 20

Deliverable

Make accounts fully provisionable once a domain is active and its PDS instance exists: mail.createAccount provisions the Stalwart mailbox and persists the protocol-TID ↔ Stalwart-id mapping, plus app-password issue/list/remove. No space or permissioned records.

Current state

ProvisionAccountWithStatus (internal/appview/provision.go) and internal/account already do the Stalwart+DB work and map a UNIQUE collision to ErrAccountExists. This task verifies and hardens the path for an active saga-provisioned domain, including the pending-ATProto-account status handling that the account/login issues depend on.

Non-goals

  • Pending ATProto account activation / password setup (31b625f).
  • at.sovrn.mail.account space records (Spaces Stage B).

Acceptance

  • For an active domain: create/list/remove account succeeds; duplicates are idempotent/ErrAccountExists; app passwords work end-to-end.
  • An account created against a saga-provisioned domain is usable via the existing app-password/IMAP flows.

3 Comments

agent 8b558ff Sep 21

Implementation Plan — Initial RFC 9553 contact card on account creation + verify/harden active-domain provisioning

Investigation findings (Stalwart 0.16.19)

  • A contact card cannot be seeded at account-creation time. Account creation is x:Account/set; UserAccount has no card member. Contact data is a separate standard JMAP object, ContactCard/set, under capability urn:ietf:params:jmap:contacts, serialized as RFC 9553 JSContact (via the calcard 0.3.12 crate).
  • All requested properties are supported: name.components (kind: given|surname), titles, organizations, and onlineServices (service/user/uri). Stalwart indexes onlineServices for search.
  • A card must belong to at least one address book. A default book named default is auto-created lazily on first DAV fetch (AddressBook settings default defaultHrefName="default"), so the flow is AddressBook/get (triggers creation, returns id) → ContactCard/set with addressBookIds.
  • The request accountId is the target user’s numeric Stalwart account id (already stored as store.Account.StalwartRef); authorization needs Permission::Impersonate or a shared ACL. The control plane authenticates as the bootstrap admin (full admin), so this works today. The least-privilege service role (docs/05 T3) must grant contacts/impersonate.
  • The existing stalwart.Client can send it via NewRequest(CoreCapability, contactsURN) + Invoke("ContactCard/set", …) + Send.

Locked decisions

  • Backend in this issue; account-creation UI fields in a separate follow-up.
  • Card write is best-effort, non-fatal: mailbox creation succeeds; failure is logged as a warning.
  • Always create a card with an Atmosphere onlineServices entry when the owner handle is known; newAccount uses the placeholder DID (did:plc:pending:<handle>), later rewritten by 31b625f at activation.
  • Owner is a bare DID with no handle → omit the Atmosphere entry (other supplied fields still create a card).

Task 1 — internal/contact: address-book lookup + card create (TDD)

  • Create internal/contact/card.go, internal/contact/card_test.go.
  • Types: Card, OnlineService, Atmosphere(handle,did), EnsureAddressBook(ctx,c,accountID), CreateCard(ctx,c,accountID,card).
  • JMAP via stalwart.NewRequest(ContactsCapability) + Invoke + Send; payload @type:Card, addressBookIds:{id:true}, name.components given/surname, titles, organizations, onlineServices.
  • Test against an httptest JMAP double asserting request JSON.

Task 2 — Lexicon input + regenerate

  • lexicons/at/sovrn/mail/defs.json: add contactCard + onlineService defs.
  • lexicons/at/sovrn/mail/createAccount.json: add optional contact ref.
  • Run go generate ./api/sovrn; expect MailDefs_ContactCard, MailDefs_OnlineService, MailCreateAccount_Input.Contact.

Task 3 — Thread details through provisioning (TDD)

  • internal/appview/provision.go: add AccountContact, OnlineServiceInput, and ProvisionAccountWithDetails(...); ProvisionAccountWithStatus delegates with zero details.
  • After store.CreateAccount, build card (Atmosphere prepended when handle known), call contact.CreateCard; on error slog.Warn and still return success.
  • Tests: newAccount (Atmosphere + placeholder DID), did-only (entry omitted), card failure still yields account row.

Task 4 — Wire handlers

  • internal/appview/mail_create_account.go: pass handle on newAccount/handle branches; map in.Contact.
  • internal/appview/ui/handler.go: pass handle only (form fields deferred).
  • Update stubs/tests.

Task 5 — Verify/harden active-domain provisioning (acceptance)

  • Unit: duplicate → ErrAccountExists (409) with no second row; pending persisted + listed; AssignedError mapping.
  • Integration internal/integration/account_provision_test.go: domain.create (service→active) → create/list/remove → duplicate conflict → app-password IMAP usability.
  • Fix any gaps found.

Task 6 — Docs + bug hygiene

  • docs/05-stalwart-integration.md: contact-cards subsection.
  • Create the UI follow-up issue.

Verification

go build ./...
go test ./internal/contact ./internal/appview ./internal/store
SOVRN_INTEGRATION=1 go test ./internal/integration

Assumption

JSContact onlineServices[].user is @ + handle, matching the example (@alice.at.domain.com).

agent 8b5383f Sep 21

Implementation complete (backend) — ready for review

Backend for the initial RFC 9553 contact card + account-provisioning verification is implemented on jj change stack ending a6c274d49e0d. Not closing pending your review.

What shipped

internal/contact (new) — EnsureAddressBook (AddressBook/get, prefers isDefault, triggers Stalwart’s lazy default-book creation) and CreateCard (ContactCard/set, RFC 9553 JSContact). Atmosphere(handle,did) builds service:"Atmosphere", user:"@"+handle, uri:"at://"+did. Errors are contact:-wrapped; blank members are trimmed/omitted and Card.IsEmpty mirrors the renderer.

Lexicon — at.sovrn.mail.createAccount gains an optional contact object (MailDefs_ContactCard: givenName, surname, titles[], organizations[], onlineServices[]). Generated types regenerated via lexgen (idempotent).

Provisioning — internal/appview.ProvisionAccountWithDetails(...) writes the card best-effort after the mailbox + DB row: failures are slog.Error and never fail account creation. ProvisionAccountWithStatus unchanged for existing callers. newAccount uses did:plc:pending:<handle> (to be rewritten by 31b625f); bare-DID owners get no Atmosphere entry; explicit-handle paths are lowercased for the card.

Handlers — XRPC and UI map the owner handle (and, for XRPC, the full contact input) into AccountContact.

Hardening fix (found during review) — the XRPC newAccount branch authorized after calling the PDS provisioner; RequireTenantOwner now runs before any PreviewHandle/Provision call, with a non-owner test proving zero provisioner calls.

Tests — unit coverage in internal/contact and internal/appview (request-shape, nil/blank handling, non-fatal card failure, cross-tenant vs same-tenant duplicate). Integration TestIntegrationAccountProvisioning: active-domain create (newAccount→pending, DID→active)/list/remove, duplicate→HTTP 409 Exists, app-password→IMAP, and live ContactCard/get assertions for both cards (Atmosphere for the pending mailbox; names/titles/orgs/Mastodon for the explicit-contact mailbox).

Verification

  • go build ./..., go test ./internal/contact ./internal/appview ./internal/appview/ui — pass.
  • go generate ./api/sovrn idempotent; go vet clean.
  • Integration test compiles and skips here (SOVRN_INTEGRATION=0; harness not running) — needs a run against the live harness to exercise the live Stalwart/DKIM/IMAP assertions.

Decisions followed

Card best-effort/non-fatal; always create with Atmosphere when a handle is known; placeholder DID for newAccount; omit Atmosphere for bare-DID owners; user uses the @handle form from the example.

Deferred / notes

  • UI fields: follow-up bug c83864d (under this issue) adds the form inputs; today the UI passes only the handle.
  • No domain-status gate: accounts can still be created while a domain is verifying (matches “add mailboxes immediately”); not changed. Flag if you want creation rejected until active.
  • Pre-existing: same-tenant duplicate detection relies on the sqlite UNIQUE constraint string.
  • Unrelated generated churn: api/sovrn/domaindefs.go comment reflow (lexgen canonical output).
agent 8b5383f Sep 24

UI half landed in c83864d (jj change zurxussy): /domains/{id}/users/new creates the account and the initial card. Added appview.ProvisionAccountWithContact so the UI can warn when a card write fails; ProvisionAccountWithDetails behaviour is unchanged.