diff --git a/deliberi/xrpc/account.go b/deliberi/xrpc/account.go
--- a/deliberi/xrpc/account.go
+++ b/deliberi/xrpc/account.go
@@ -12,6 +12,7 @@
tangled "tangled.org/core/api/org_tangled"
appviewemail "tangled.org/core/appview/email"
db "tangled.org/core/deliberi/db"
+ "tangled.org/core/deliberi/emailtemplate"
xrpcerr "tangled.org/core/xrpc/errors"
)
@@ -224,7 +225,12 @@
link := x.verifyLink(token)
text := "Verify your email address on Tangled: " + link
- html := "
Verify your email address on Tangled
\nor open: " + link + "
"
+ body := `Verify your email address on Tangled by clicking the link below:
+
+ Verify email address
+
+or open: ` + link + `
`
+ html := emailtemplate.Shell("Verify your email on Tangled", body, x.Config.BaseURL)
if err := x.Sender.Send(addr, "Verify your email on Tangled", text, html); err != nil {
l.Error("failed to send verification email", "err", err)
writeError(w, errInternal, http.StatusInternalServerError)
diff --git a/deliberi/xrpc/signup.go b/deliberi/xrpc/signup.go
--- a/deliberi/xrpc/signup.go
+++ b/deliberi/xrpc/signup.go
@@ -2,6 +2,7 @@
import (
"bytes"
+ "database/sql"
"encoding/json"
"errors"
"fmt"
@@ -9,16 +10,30 @@
"net/http"
"net/url"
"regexp"
+ "strings"
+ "time"
tangled "tangled.org/core/api/org_tangled"
appviewemail "tangled.org/core/appview/email"
db "tangled.org/core/deliberi/db"
+ "tangled.org/core/deliberi/emailtemplate"
"tangled.org/core/deliberi/models"
)
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
var errEmailAlreadyRegistered = errors.New("email already registered")
+
+// signupEmailCooldown caps scripted email spam while turnstile verification is unwired.
+const signupEmailCooldown = time.Minute
+
+func withinSignupCooldown(lastSent string) bool {
+ t, err := time.Parse(time.RFC3339, lastSent)
+ if err != nil {
+ return false
+ }
+ return time.Since(t) < signupEmailCooldown
+}
func isValidSubdomain(name string) bool {
return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name)
@@ -55,6 +70,24 @@
return
}
+ if pending, err := db.GetInflightByEmail(x.DB, email); err == nil && withinSignupCooldown(pending.LastSent) {
+ writeError(w, xrpcErrorTag("TooManyRequests", "please wait a moment before requesting another email"), http.StatusTooManyRequests)
+ return
+ } else if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ l.Error("failed to check pending signup", "err", err)
+ writeError(w, errInternal, http.StatusInternalServerError)
+ return
+ }
+
+ token, err := generateVerificationToken()
+ if err != nil {
+ l.Error("failed to generate verification token", "err", err)
+ writeError(w, errInternal, http.StatusInternalServerError)
+ return
+ }
+
+ // the invite code never leaves deliberi: the emailed link only carries the
+ // verification token, and completeSignup redeems the stored code internally
code, err := x.pdsCreateInviteCode()
if err != nil {
l.Error("failed to create invite code", "err", err)
@@ -62,21 +95,88 @@
return
}
- if err := db.AddInflightSignup(x.DB, models.InflightSignup{Email: email, InviteCode: code}); err != nil {
+ if err := db.AddInflightSignup(x.DB, models.InflightSignup{Email: email, InviteCode: code, VerificationToken: token}); err != nil {
l.Error("failed to add inflight signup", "err", err)
writeError(w, errInternal, http.StatusInternalServerError)
return
}
- text := "Copy and paste this code below to verify your account on Tangled.\n" + code
- html := "Copy and paste this code below to verify your account on Tangled.
\n" + code + "
"
- if err := x.Sender.Send(email, "Verify your Tangled account", text, html); err != nil {
- l.Error("failed to send verification email", "err", err)
+ link := x.signupLink(token)
+ text, html := signupLinkEmail(link, x.Config.BaseURL)
+ if err := x.Sender.Send(email, "Complete your Tangled signup", text, html); err != nil {
+ l.Error("failed to send signup email", "err", err)
writeError(w, errInternal, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
+}
+
+// AccountResendSignup re-mails the same live link — same token, no new invite code.
+func (x *Xrpc) AccountResendSignup(w http.ResponseWriter, r *http.Request) {
+ l := x.Logger.With("handler", "AccountResendSignup")
+
+ if !x.Config.SignupEnabled() {
+ writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency)
+ return
+ }
+
+ var input tangled.TempAccountResendSignup_Input
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ writeError(w, errBadRequestBody, http.StatusBadRequest)
+ return
+ }
+ email := canonicalEmail(input.Email)
+ if !appviewemail.IsValidEmail(email) {
+ writeError(w, xrpcErrorTag("InvalidEmail", "invalid email address"), http.StatusBadRequest)
+ return
+ }
+
+ inflight, err := db.GetInflightByEmail(x.DB, email)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ if serr := db.DeleteExpiredInflightSignups(x.DB); serr != nil {
+ l.Error("failed to sweep expired signups", "err", serr)
+ }
+ writeError(w, xrpcErrorTag("NoPendingSignup", "there is no pending signup for this email; start again from the signup page"), http.StatusNotFound)
+ return
+ }
+ l.Error("failed to get inflight signup", "err", err)
+ writeError(w, errInternal, http.StatusInternalServerError)
+ return
+ }
+
+ if withinSignupCooldown(inflight.LastSent) {
+ writeError(w, xrpcErrorTag("TooManyRequests", "please wait a moment before requesting another email"), http.StatusTooManyRequests)
+ return
+ }
+
+ link := x.signupLink(inflight.VerificationToken)
+ text, html := signupLinkEmail(link, x.Config.BaseURL)
+ if err := x.Sender.Send(email, "Complete your Tangled signup", text, html); err != nil {
+ l.Error("failed to resend signup email", "err", err)
+ writeError(w, errInternal, http.StatusInternalServerError)
+ return
+ }
+ if err := db.UpdateInflightLastSent(x.DB, email); err != nil {
+ l.Error("failed to record resend", "err", err)
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
+
+func (x *Xrpc) signupLink(token string) string {
+ return strings.TrimSuffix(x.Config.BaseURL, "/") + "/signup/verify?token=" + token
+}
+
+func signupLinkEmail(link, baseURL string) (text, html string) {
+ text = "Complete your Tangled signup: " + link
+ body := `Complete your Tangled signup by clicking the link below:
+
+ Complete signup
+
+or open: ` + link + `
`
+ return text, emailtemplate.Shell("Complete your Tangled signup", body, baseURL)
}
func (x *Xrpc) AccountCompleteSignup(w http.ResponseWriter, r *http.Request) {
@@ -93,20 +193,33 @@
return
}
+ token := strings.TrimSpace(input.Token)
+ if token == "" {
+ writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification link"), http.StatusBadRequest)
+ return
+ }
+
if !isValidSubdomain(input.Username) {
writeError(w, xrpcErrorTag("InvalidUsername", "invalid username"), http.StatusBadRequest)
return
}
- emailAddr, err := db.GetEmailForCode(x.DB, input.Code)
+ inflight, err := db.GetInflightByToken(x.DB, token)
if err != nil {
- l.Error("failed to get email for code", "err", err)
- writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification code"), http.StatusBadRequest)
+ if errors.Is(err, sql.ErrNoRows) {
+ if serr := db.DeleteExpiredInflightSignups(x.DB); serr != nil {
+ l.Error("failed to sweep expired signups", "err", serr)
+ }
+ writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification link"), http.StatusBadRequest)
+ return
+ }
+ l.Error("failed to get inflight signup for token", "err", err)
+ writeError(w, errInternal, http.StatusInternalServerError)
return
}
- emailAddr = canonicalEmail(emailAddr)
+ emailAddr := canonicalEmail(inflight.Email)
- did, handle, err := x.provisionAccount(input.Username, input.Password, emailAddr, input.Code)
+ did, handle, err := x.provisionAccount(input.Username, input.Password, emailAddr, inflight.InviteCode)
if err != nil {
if errors.Is(err, errEmailAlreadyRegistered) {
writeError(w, xrpcErrorTag("EmailAlreadyRegistered", "an account already exists for this email"), http.StatusConflict)
@@ -148,14 +261,15 @@
did, handle, err = x.pdsCreateAccount(username, password, emailAddr, code)
if err != nil {
- return "", "", err
+ return did, handle, err
}
if err = db.AddEmail(x.DB, models.Email{Did: did, Address: emailAddr, Verified: true, Primary: true}); err != nil {
if db.IsUniqueConstraintErr(err) {
- return "", "", errEmailAlreadyRegistered
+ // keep did/handle so the defer rolls the PDS account back
+ return did, handle, errEmailAlreadyRegistered
}
- return "", "", err
+ return did, handle, err
}
emailAdded = true
diff --git a/deliberi/xrpc/signup_test.go b/deliberi/xrpc/signup_test.go
new file mode 100644
--- /dev/null
+++ b/deliberi/xrpc/signup_test.go
@@ -0,0 +1,528 @@
+package xrpc
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ config "tangled.org/core/deliberi/config"
+ db "tangled.org/core/deliberi/db"
+ "tangled.org/core/deliberi/mailer"
+ "tangled.org/core/deliberi/models"
+)
+
+const (
+ testInviteCode = "invite-code-abc123"
+ testNewDid = "did:plc:newuser"
+)
+
+const testAdminSecret = "test-admin-secret"
+
+// pdsRecorder stubs the PDS endpoints the signup handlers call and records them.
+type pdsRecorder struct {
+ mu sync.Mutex
+ inviteMints int
+ createAccount []map[string]any
+ adminDeletes []string
+}
+
+func (p *pdsRecorder) InviteMints() int {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.inviteMints
+}
+
+func (p *pdsRecorder) CreateAccounts() []map[string]any {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return append([]map[string]any(nil), p.createAccount...)
+}
+
+func (p *pdsRecorder) AdminDeletes() []string {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return append([]string(nil), p.adminDeletes...)
+}
+
+func newPdsStub(t *testing.T, rec *pdsRecorder) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("/xrpc/com.atproto.server.createInviteCode", func(w http.ResponseWriter, r *http.Request) {
+ user, pass, ok := r.BasicAuth()
+ if !ok || user != "admin" || pass != testAdminSecret {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ rec.mu.Lock()
+ rec.inviteMints++
+ rec.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprint(w, `{"code":"`+testInviteCode+`"}`)
+ })
+ mux.HandleFunc("/xrpc/com.atproto.server.createAccount", func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]any
+ json.NewDecoder(r.Body).Decode(&body)
+ rec.mu.Lock()
+ rec.createAccount = append(rec.createAccount, body)
+ rec.mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprint(w, `{"did":"`+testNewDid+`"}`)
+ })
+ mux.HandleFunc("/xrpc/com.atproto.admin.deleteAccount", func(w http.ResponseWriter, r *http.Request) {
+ user, pass, ok := r.BasicAuth()
+ if !ok || user != "admin" || pass != testAdminSecret {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ var body map[string]string
+ json.NewDecoder(r.Body).Decode(&body)
+ rec.mu.Lock()
+ rec.adminDeletes = append(rec.adminDeletes, body["did"])
+ rec.mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func newSignupXrpc(t *testing.T, baseURL ...string) (http.Handler, *db.DB, *kvRecorder, *pdsRecorder) {
+ t.Helper()
+ rec := &kvRecorder{}
+ pds := &pdsRecorder{}
+ srv := newPdsStub(t, pds)
+ base := "https://tangled.example"
+ if len(baseURL) > 0 && baseURL[0] != "" {
+ base = baseURL[0]
+ }
+ x, router, d, _ := newTestXrpcFull(t, func(x *Xrpc) {
+ x.Config.Pds.AdminSecret = testAdminSecret
+ x.Config.Pds.Host = srv.URL
+ x.Config.BaseURL = base
+ x.Sender = mailer.New(config.ResendConfig{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ x.KV = rec
+ })
+ _ = x
+ return router, d, rec, pds
+}
+
+func TestAccountBeginSignupStoresTokenAndSendsLink(t *testing.T) {
+ router, d, kvRec, _ := newSignupXrpc(t)
+
+ emitted := captureStdout(t, func() {
+ rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":" Alice@Example.com ","turnstileToken":"test"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ })
+
+ if !strings.Contains(emitted, "to: alice@example.com") {
+ t.Fatalf("email not addressed to canonical alice@example.com in:\n%s", emitted)
+ }
+
+ token := ""
+ invite := ""
+ rows, err := d.Query(`select verification_token, invite_code from signups_inflight where email = 'alice@example.com'`)
+ if err != nil {
+ t.Fatalf("query inflight: %v", err)
+ }
+ defer rows.Close()
+ if rows.Next() {
+ rows.Scan(&token, &invite)
+ }
+ if token == "" {
+ t.Fatal("inflight row missing verification_token")
+ }
+ assertVerificationToken(t, token)
+ if invite == "" {
+ t.Fatal("inflight row missing invite_code")
+ }
+
+ if !strings.Contains(emitted, "/signup/verify?token="+token) {
+ t.Fatalf("email link missing verification token; got:\n%s", emitted)
+ }
+ if strings.Contains(emitted, invite) {
+ t.Fatalf("email leaked the invite code %q:\n%s", invite, emitted)
+ }
+
+ kvRec.assertIdle(t)
+}
+
+func TestAccountBeginSignupRejectsRegisteredEmail(t *testing.T) {
+ router, d, kvRec, _ := newSignupXrpc(t)
+
+ if err := db.AddEmail(d, models.Email{Did: testNewDid, Address: "alice@example.com", Verified: true, Primary: true}); err != nil {
+ t.Fatalf("AddEmail: %v", err)
+ }
+
+ rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":"alice@example.com","turnstileToken":"test"}`)
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("status = %d, want 409", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "EmailAlreadyRegistered") {
+ t.Fatalf("error body = %s, want EmailAlreadyRegistered", rec.Body.String())
+ }
+ kvRec.assertIdle(t)
+}
+
+func TestAccountCompleteSignupWithToken(t *testing.T) {
+ router, d, kvRec, pds := newSignupXrpc(t)
+
+ token, err := generateVerificationToken()
+ if err != nil {
+ t.Fatalf("generate token: %v", err)
+ }
+ if err := db.AddInflightSignup(d, models.InflightSignup{Email: "bob@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil {
+ t.Fatalf("seed inflight: %v", err)
+ }
+
+ rec := postOpen(t, router, "org.tangled.temp.account.completeSignup",
+ `{"token":"`+token+`","username":"bobby","password":"password1234"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ var out tangledTempAccountCompleteSignupOutput
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
+ }
+ if out.Did != testNewDid {
+ t.Fatalf("did = %q, want %q", out.Did, testNewDid)
+ }
+ if out.Handle == "" || !strings.HasPrefix(out.Handle, "bobby.") {
+ t.Fatalf("handle = %q, want bobby.", out.Handle)
+ }
+
+ accounts := pds.CreateAccounts()
+ if len(accounts) != 1 {
+ t.Fatalf("createAccount calls = %d, want 1", len(accounts))
+ }
+ body := accounts[0]
+ if body["inviteCode"] != testInviteCode {
+ t.Fatalf("createAccount inviteCode = %v, want %q", body["inviteCode"], testInviteCode)
+ }
+ if body["email"] != "bob@example.com" {
+ t.Fatalf("createAccount email = %v, want bob@example.com", body["email"])
+ }
+ if body["handle"] != out.Handle {
+ t.Fatalf("createAccount handle = %v, want %q", body["handle"], out.Handle)
+ }
+
+ row := emailByAddress(t, d, testNewDid, "bob@example.com")
+ if !row.Verified || !row.Primary {
+ t.Fatalf("email row = verified:%v primary:%v, want verified+primary", row.Verified, row.Primary)
+ }
+
+ emailDids, primaries, deletes := kvRec.snapshot()
+ if len(emailDids) != 1 || emailDids[0] != "bob@example.com|"+testNewDid {
+ t.Errorf("PutEmailDid calls = %v, want [bob@example.com|%s]", emailDids, testNewDid)
+ }
+ if len(primaries) != 1 || primaries[0] != testNewDid+"|bob@example.com" {
+ t.Errorf("SetPrimaryEmail calls = %v, want [%s|bob@example.com]", primaries, testNewDid)
+ }
+ if len(deletes) != 0 {
+ t.Errorf("unexpected KV deletes: %v", deletes)
+ }
+
+ // deletion runs in a goroutine; poll briefly
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ _, err := db.GetInflightByToken(d, token)
+ if errors.Is(err, sql.ErrNoRows) {
+ break
+ } else if err == nil && time.Now().After(deadline) {
+ t.Fatal("inflight row still present after signup")
+ } else if err != nil {
+ t.Fatalf("GetInflightByToken: %v", err)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
+func TestAccountCompleteSignupBadToken(t *testing.T) {
+ router, d, kvRec, _ := newSignupXrpc(t)
+
+ token, _ := generateVerificationToken()
+ if err := db.AddInflightSignup(d, models.InflightSignup{Email: "bob@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil {
+ t.Fatalf("seed inflight: %v", err)
+ }
+
+ rec := postOpen(t, router, "org.tangled.temp.account.completeSignup",
+ `{"token":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef","username":"bobby","password":"password1234"}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "InvalidCode") {
+ t.Fatalf("error body = %s, want InvalidCode", rec.Body.String())
+ }
+
+ if _, err := db.GetInflightByToken(d, token); err != nil {
+ t.Fatalf("inflight row lost on bad token: %v", err)
+ }
+ kvRec.assertIdle(t)
+}
+
+func TestAccountCompleteSignupInvalidUsername(t *testing.T) {
+ router, _, kvRec, _ := newSignupXrpc(t)
+
+ rec := postOpen(t, router, "org.tangled.temp.account.completeSignup",
+ `{"token":"`+strings.Repeat("ab", 32)+`","username":"UPPER","password":"password1234"}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "InvalidUsername") {
+ t.Fatalf("error body = %s, want InvalidUsername", rec.Body.String())
+ }
+ kvRec.assertIdle(t)
+}
+
+func TestAccountCompleteSignupMissingToken(t *testing.T) {
+ router, _, kvRec, _ := newSignupXrpc(t)
+
+ rec := postOpen(t, router, "org.tangled.temp.account.completeSignup",
+ `{"token":" ","username":"bobby","password":"password1234"}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "InvalidCode") {
+ t.Fatalf("error body = %s, want InvalidCode", rec.Body.String())
+ }
+ kvRec.assertIdle(t)
+}
+
+func inflightByEmail(t *testing.T, d *db.DB, email string) models.InflightSignup {
+ t.Helper()
+ row, err := db.GetInflightByEmail(d, email)
+ if err != nil {
+ t.Fatalf("GetInflightByEmail(%s): %v", email, err)
+ }
+ return row
+}
+
+func expireInflight(t *testing.T, d *db.DB, email string) {
+ t.Helper()
+ if _, err := d.Exec(`update signups_inflight set expires_at = '2000-01-01T00:00:00Z' where email = ?`, email); err != nil {
+ t.Fatalf("expire inflight: %v", err)
+ }
+}
+
+func backdateInflightSent(t *testing.T, d *db.DB, email string) {
+ t.Helper()
+ if _, err := d.Exec(`update signups_inflight set last_sent = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-2 minutes') where email = ?`, email); err != nil {
+ t.Fatalf("backdate last_sent: %v", err)
+ }
+}
+
+func inflightCount(t *testing.T, d *db.DB, email string) int {
+ t.Helper()
+ var n int
+ if err := d.QueryRow(`select count(*) from signups_inflight where email = ?`, email).Scan(&n); err != nil {
+ t.Fatalf("count inflight: %v", err)
+ }
+ return n
+}
+
+func TestAccountResendSignupResendsSameLink(t *testing.T) {
+ router, d, kvRec, pds := newSignupXrpc(t)
+
+ rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":"Erin@Example.com","turnstileToken":"test"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("begin status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ if mints := pds.InviteMints(); mints != 1 {
+ t.Fatalf("invite mints after begin = %d, want 1", mints)
+ }
+ row := inflightByEmail(t, d, "erin@example.com")
+ backdateInflightSent(t, d, "erin@example.com")
+
+ emitted := captureStdout(t, func() {
+ res := postOpen(t, router, "org.tangled.temp.account.resendSignup", `{"email":"erin@example.com"}`)
+ if res.Code != http.StatusOK {
+ t.Fatalf("resend status = %d, want 200; body=%s", res.Code, res.Body.String())
+ }
+ })
+
+ if !strings.Contains(emitted, "to: erin@example.com") {
+ t.Fatalf("resend not addressed to erin@example.com in:\n%s", emitted)
+ }
+ if !strings.Contains(emitted, "/signup/verify?token="+row.VerificationToken) {
+ t.Fatalf("resend link differs from the stored token in:\n%s", emitted)
+ }
+ if mints := pds.InviteMints(); mints != 1 {
+ t.Fatalf("invite mints after resend = %d, want still 1 (no new code)", mints)
+ }
+ if after := inflightByEmail(t, d, "erin@example.com"); after.VerificationToken != row.VerificationToken {
+ t.Fatalf("resend rotated the token: %q -> %q", row.VerificationToken, after.VerificationToken)
+ }
+ kvRec.assertIdle(t)
+}
+
+func TestAccountResendSignupNoPending(t *testing.T) {
+ router, _, kvRec, _ := newSignupXrpc(t)
+
+ rec := postOpen(t, router, "org.tangled.temp.account.resendSignup", `{"email":"nobody@example.com"}`)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "NoPendingSignup") {
+ t.Fatalf("error body = %s, want NoPendingSignup", rec.Body.String())
+ }
+ kvRec.assertIdle(t)
+}
+
+func TestAccountResendSignupExpiredSweeps(t *testing.T) {
+ router, d, kvRec, _ := newSignupXrpc(t)
+
+ token, _ := generateVerificationToken()
+ if err := db.AddInflightSignup(d, models.InflightSignup{Email: "erin@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil {
+ t.Fatalf("seed inflight: %v", err)
+ }
+ expireInflight(t, d, "erin@example.com")
+
+ rec := postOpen(t, router, "org.tangled.temp.account.resendSignup", `{"email":"erin@example.com"}`)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "NoPendingSignup") {
+ t.Fatalf("error body = %s, want NoPendingSignup", rec.Body.String())
+ }
+ if n := inflightCount(t, d, "erin@example.com"); n != 0 {
+ t.Fatalf("expired row still present after resend miss (count=%d)", n)
+ }
+ kvRec.assertIdle(t)
+}
+
+func TestAccountCompleteSignupExpiredToken(t *testing.T) {
+ router, d, kvRec, pds := newSignupXrpc(t)
+
+ token, _ := generateVerificationToken()
+ if err := db.AddInflightSignup(d, models.InflightSignup{Email: "erin@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil {
+ t.Fatalf("seed inflight: %v", err)
+ }
+ expireInflight(t, d, "erin@example.com")
+
+ rec := postOpen(t, router, "org.tangled.temp.account.completeSignup",
+ `{"token":"`+token+`","username":"erin","password":"password1234"}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "InvalidCode") {
+ t.Fatalf("error body = %s, want InvalidCode", rec.Body.String())
+ }
+ if n := inflightCount(t, d, "erin@example.com"); n != 0 {
+ t.Fatalf("expired row still present after completeSignup miss (count=%d)", n)
+ }
+ if accounts := pds.CreateAccounts(); len(accounts) != 0 {
+ t.Fatalf("createAccount called with an expired token: %v", accounts)
+ }
+ kvRec.assertIdle(t)
+}
+
+func TestAccountBeginSignupNormalizesTrailingSlashBaseURL(t *testing.T) {
+ router, _, kvRec, _ := newSignupXrpc(t, "https://tangled.example/")
+
+ emitted := captureStdout(t, func() {
+ rec := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":"gina-signup@example.com","turnstileToken":"test"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ })
+
+ if strings.Contains(emitted, "//signup/verify") {
+ t.Fatalf("emailed link contains a double slash:\n%s", emitted)
+ }
+ if !strings.Contains(emitted, "https://tangled.example/signup/verify?token=") {
+ t.Fatalf("emailed link missing normalized base in:\n%s", emitted)
+ }
+ kvRec.assertIdle(t)
+}
+
+func TestAccountBeginSignupCooldown(t *testing.T) {
+ router, _, kvRec, _ := newSignupXrpc(t)
+
+ first := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":"henry-signup@example.com","turnstileToken":"test"}`)
+ if first.Code != http.StatusOK {
+ t.Fatalf("first begin status = %d, want 200; body=%s", first.Code, first.Body.String())
+ }
+
+ second := postOpen(t, router, "org.tangled.temp.account.beginSignup", `{"email":"henry-signup@example.com","turnstileToken":"test"}`)
+ if second.Code != http.StatusTooManyRequests {
+ t.Fatalf("immediate re-begin status = %d, want 429; body=%s", second.Code, second.Body.String())
+ }
+ if !strings.Contains(second.Body.String(), "TooManyRequests") {
+ t.Fatalf("error body = %s, want TooManyRequests", second.Body.String())
+ }
+ kvRec.assertIdle(t)
+}
+
+func TestAccountResendSignupCooldown(t *testing.T) {
+ router, d, kvRec, _ := newSignupXrpc(t)
+
+ token, _ := generateVerificationToken()
+ if err := db.AddInflightSignup(d, models.InflightSignup{Email: "ivan-signup@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil {
+ t.Fatalf("seed inflight: %v", err)
+ }
+ backdateInflightSent(t, d, "ivan-signup@example.com")
+
+ first := postOpen(t, router, "org.tangled.temp.account.resendSignup", `{"email":"ivan-signup@example.com"}`)
+ if first.Code != http.StatusOK {
+ t.Fatalf("first resend status = %d, want 200; body=%s", first.Code, first.Body.String())
+ }
+
+ second := postOpen(t, router, "org.tangled.temp.account.resendSignup", `{"email":"ivan-signup@example.com"}`)
+ if second.Code != http.StatusTooManyRequests {
+ t.Fatalf("immediate re-resend status = %d, want 429; body=%s", second.Code, second.Body.String())
+ }
+ kvRec.assertIdle(t)
+}
+
+func TestAccountCompleteSignupRollsBackOnAddEmailConflict(t *testing.T) {
+ router, d, kvRec, pds := newSignupXrpc(t)
+
+ if err := db.AddEmail(d, models.Email{Did: "did:plc:other", Address: "judy@example.com", Verified: true, Primary: true}); err != nil {
+ t.Fatalf("seed competing email: %v", err)
+ }
+ token, _ := generateVerificationToken()
+ if err := db.AddInflightSignup(d, models.InflightSignup{Email: "judy@example.com", InviteCode: testInviteCode, VerificationToken: token}); err != nil {
+ t.Fatalf("seed inflight: %v", err)
+ }
+
+ rec := postOpen(t, router, "org.tangled.temp.account.completeSignup",
+ `{"token":"`+token+`","username":"judy","password":"password1234"}`)
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "EmailAlreadyRegistered") {
+ t.Fatalf("error body = %s, want EmailAlreadyRegistered", rec.Body.String())
+ }
+
+ deletes := pds.AdminDeletes()
+ if len(deletes) != 1 || deletes[0] != testNewDid {
+ t.Fatalf("admin.deleteAccount calls = %v, want [%s]", deletes, testNewDid)
+ }
+ emails, err := db.GetAllEmails(d, testNewDid)
+ if err != nil {
+ t.Fatalf("GetAllEmails: %v", err)
+ }
+ if len(emails) != 0 {
+ t.Fatalf("email row leaked for rolled-back account: %v", emails)
+ }
+ if _, err := db.GetInflightByToken(d, token); err != nil {
+ t.Fatalf("inflight row lost on conflict: %v", err)
+ }
+ kvRec.assertIdle(t)
+}
+
+// typed mirror of the generated output so the assertions don't depend on codegen
+type tangledTempAccountCompleteSignupOutput struct {
+ Did string `json:"did"`
+ Handle string `json:"handle"`
+}
diff --git a/deliberi/xrpc/xrpc.go b/deliberi/xrpc/xrpc.go
--- a/deliberi/xrpc/xrpc.go
+++ b/deliberi/xrpc/xrpc.go
@@ -36,6 +36,7 @@
r.Get("/_health", x.health)
r.Post("/"+tangled.TempAccountBeginSignupNSID, x.AccountBeginSignup)
+ r.Post("/"+tangled.TempAccountResendSignupNSID, x.AccountResendSignup)
r.Post("/"+tangled.TempAccountCompleteSignupNSID, x.AccountCompleteSignup)
r.Post("/"+tangled.TempAccountVerifyEmailNSID, x.AccountVerifyEmail)