From 557b811d253e33b52fc155353d0c196b9dfb4090 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Tue, 25 Aug 2026 15:44:02 +0300 Subject: [PATCH] deliberi/xrpc: wire add-email and link verification with kv mirror Signed-off-by: Anirudh Oppiliappan --- deliberi/deliberi.go | 6 + deliberi/xrpc/account.go | 202 ++++++++++++- deliberi/xrpc/account_test.go | 523 ++++++++++++++++++++++++++++++++++ deliberi/xrpc/signup.go | 34 ++- deliberi/xrpc/xrpc.go | 17 +- deliberi/xrpc/xrpc_test.go | 15 +- 6 files changed, 765 insertions(+), 32 deletions(-) create mode 100644 deliberi/xrpc/account_test.go diff --git a/deliberi/deliberi.go b/deliberi/deliberi.go index c19764f0..4023ab36 100644 --- a/deliberi/deliberi.go +++ b/deliberi/deliberi.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" "tangled.org/core/deliberi/config" deldb "tangled.org/core/deliberi/db" + "tangled.org/core/deliberi/kv" "tangled.org/core/deliberi/mailer" delxrpc "tangled.org/core/deliberi/xrpc" "tangled.org/core/idresolver" @@ -55,6 +56,11 @@ func Run(ctx context.Context, cfg *config.Config) error { Sender: sender, } + if cfg.KvApiToken != "" && cfg.CloudflareAccountID != "" && cfg.KvNamespaceID != "" { + x.KV = kv.NewClient(cfg.KvApiToken, cfg.CloudflareAccountID, cfg.KvNamespaceID, + log.SubLogger(logger, "kv")) + } + srv := &http.Server{Addr: cfg.ListenAddr, Handler: chiMount(x)} go func() { logger.Info("starting http server", "addr", cfg.ListenAddr) diff --git a/deliberi/xrpc/account.go b/deliberi/xrpc/account.go index 27ccffef..62e2d264 100644 --- a/deliberi/xrpc/account.go +++ b/deliberi/xrpc/account.go @@ -1,13 +1,16 @@ package xrpc import ( + "crypto/rand" "database/sql" + "encoding/hex" "encoding/json" "errors" "net/http" "strings" tangled "tangled.org/core/api/org_tangled" + appviewemail "tangled.org/core/appview/email" db "tangled.org/core/deliberi/db" xrpcerr "tangled.org/core/xrpc/errors" ) @@ -55,7 +58,7 @@ func (x *Xrpc) AccountDeleteEmail(w http.ResponseWriter, r *http.Request) { writeError(w, errBadRequestBody, http.StatusBadRequest) return } - addr := strings.TrimSpace(input.Email) + addr := canonicalEmail(input.Email) existing, err := db.GetEmail(x.DB, did, addr) if err != nil { @@ -78,6 +81,10 @@ func (x *Xrpc) AccountDeleteEmail(w http.ResponseWriter, r *http.Request) { return } + if x.KV != nil { + x.KV.DeleteEmailKey(addr) + } + w.WriteHeader(http.StatusOK) } @@ -95,7 +102,7 @@ func (x *Xrpc) AccountSetPrimaryEmail(w http.ResponseWriter, r *http.Request) { writeError(w, errBadRequestBody, http.StatusBadRequest) return } - addr := strings.TrimSpace(input.Email) + addr := canonicalEmail(input.Email) existing, err := db.GetEmail(x.DB, did, addr) if err != nil { @@ -118,5 +125,196 @@ func (x *Xrpc) AccountSetPrimaryEmail(w http.ResponseWriter, r *http.Request) { return } + if x.KV != nil { + x.KV.PutEmailDid(addr, did) + x.KV.SetPrimaryEmail(did, addr) + } + + w.WriteHeader(http.StatusOK) +} + +func canonicalEmail(input string) string { + return strings.ToLower(strings.TrimSpace(input)) +} + +func generateVerificationToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func (x *Xrpc) verifyLink(token string) string { + return strings.TrimSuffix(x.Config.BaseURL, "/") + "/verify/email?token=" + token +} + +func (x *Xrpc) AccountAddEmail(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "AccountAddEmail") + + did, ok := actorDid(r) + if !ok { + writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) + return + } + + var input tangled.TempAccountAddEmail_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + addr := canonicalEmail(input.Email) + + if !appviewemail.IsValidEmail(addr) { + writeError(w, xrpcErrorTag("InvalidEmail", "invalid email address"), http.StatusBadRequest) + return + } + + token, err := generateVerificationToken() + if err != nil { + l.Error("failed to generate verification token", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + existsAtAll, err := db.CheckEmailExistsAtAll(x.DB, addr) + if err != nil { + l.Error("failed to check email existence", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if existsAtAll { + existsForDid, err := db.CheckEmailExists(x.DB, did, addr) + if err != nil { + l.Error("failed to check email for did", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if existsForDid { + existing, err := db.GetEmail(x.DB, did, addr) + if err != nil { + l.Error("failed to get email", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if existing.Verified { + writeError(w, xrpcErrorTag("EmailAlreadyRegistered", "this email address is already on your account"), http.StatusConflict) + return + } + if err := db.UpdateVerificationCode(x.DB, did, addr, token); err != nil { + l.Error("failed to update verification token", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + } else { + writeError(w, xrpcErrorTag("EmailAlreadyRegistered", "an account already exists for this email"), http.StatusConflict) + return + } + } else { + if err := db.InsertUnverifiedEmail(x.DB, did, addr, token); err != nil { + if db.IsUniqueConstraintErr(err) { + writeError(w, xrpcErrorTag("EmailAlreadyRegistered", "an account already exists for this email"), http.StatusConflict) + return + } + l.Error("failed to insert email", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + } + + link := x.verifyLink(token) + text := "Verify your email address on Tangled: " + link + html := "

Verify your email address on Tangled

\n

or open: " + link + "

" + 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) + return + } + w.WriteHeader(http.StatusOK) } + +func (x *Xrpc) AccountVerifyEmail(w http.ResponseWriter, r *http.Request) { + l := x.Logger.With("handler", "AccountVerifyEmail") + + var input tangled.TempAccountVerifyEmail_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, errBadRequestBody, http.StatusBadRequest) + return + } + token := strings.TrimSpace(input.Token) + if token == "" { + writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification link"), http.StatusBadRequest) + return + } + + // count + promote plus the token burn, in one transaction; the conditional + // burn is the single-use gate, so exactly one concurrent click wins + tx, err := x.DB.BeginTx(r.Context(), nil) + if err != nil { + l.Error("failed to begin verification transaction", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + defer tx.Rollback() + + row, err := db.GetEmailByToken(tx, token) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification link"), http.StatusBadRequest) + return + } + l.Error("failed to look up verification token", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + result, err := tx.Exec(`update emails set verified = true, verification_code = '' + where verification_code = ? and verified = false`, token) + if err != nil { + l.Error("failed to mark email verified", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + redeemed, err := result.RowsAffected() + if err != nil { + l.Error("failed to read redeemed rows", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + if redeemed == 0 { + writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification link"), http.StatusBadRequest) + return + } + + verifiedCount, err := db.CountVerifiedEmails(tx, row.Did) + if err != nil { + l.Error("failed to count verified emails", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + becamePrimary := verifiedCount == 1 + if becamePrimary { + if err := db.MakeEmailPrimary(tx, row.Did, row.Address); err != nil { + l.Error("failed to make email primary", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + } + if err := tx.Commit(); err != nil { + l.Error("failed to commit verification", "err", err) + writeError(w, errInternal, http.StatusInternalServerError) + return + } + + l.Info("email verified", "email", row.Address, "primary", becamePrimary) + + if x.KV != nil { + x.KV.PutEmailDid(row.Address, row.Did) + if becamePrimary { + x.KV.SetPrimaryEmail(row.Did, row.Address) + } + } + + x.writeJSON(w, &tangled.TempAccountVerifyEmail_Output{Email: row.Address}) +} diff --git a/deliberi/xrpc/account_test.go b/deliberi/xrpc/account_test.go new file mode 100644 index 00000000..f7d3c220 --- /dev/null +++ b/deliberi/xrpc/account_test.go @@ -0,0 +1,523 @@ +package xrpc + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "sort" + "strings" + "sync" + "testing" + + tangled "tangled.org/core/api/org_tangled" + config "tangled.org/core/deliberi/config" + db "tangled.org/core/deliberi/db" + "tangled.org/core/deliberi/kv" + "tangled.org/core/deliberi/mailer" + "tangled.org/core/deliberi/models" +) + +type kvRecorder struct { + mu sync.Mutex + emailDids []string + primaries []string + deletes []string +} + +func (k *kvRecorder) PutEmailDid(email, did string) { + k.mu.Lock() + defer k.mu.Unlock() + k.emailDids = append(k.emailDids, email+"|"+did) +} + +func (k *kvRecorder) SetPrimaryEmail(did, email string) { + k.mu.Lock() + defer k.mu.Unlock() + k.primaries = append(k.primaries, did+"|"+email) +} + +func (k *kvRecorder) DeleteEmailKey(email string) { + k.mu.Lock() + defer k.mu.Unlock() + k.deletes = append(k.deletes, email) +} + +func (k *kvRecorder) snapshot() (emailDids, primaries, deletes []string) { + k.mu.Lock() + defer k.mu.Unlock() + return append([]string(nil), k.emailDids...), append([]string(nil), k.primaries...), append([]string(nil), k.deletes...) +} + +func (k *kvRecorder) assertIdle(t *testing.T) { + t.Helper() + emailDids, primaries, deletes := k.snapshot() + if len(emailDids) != 0 || len(primaries) != 0 || len(deletes) != 0 { + t.Errorf("unexpected KV writes: puts=%v primaries=%v deletes=%v", emailDids, primaries, deletes) + } +} + +var _ kv.EmailKV = (*kvRecorder)(nil) + +func newEmailXrpc(t *testing.T) (http.Handler, *db.DB, *kvRecorder, func(nsid string) string) { + t.Helper() + rec := &kvRecorder{} + x, router, d, sign := newTestXrpcFull(t, func(x *Xrpc) { + x.Sender = mailer.New(config.ResendConfig{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + x.KV = rec + }) + _ = x + return router, d, rec, sign +} + +func postJSON(t *testing.T, router http.Handler, nsid, body string, sign func(nsid string) string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/"+nsid, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+sign(nsid)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func postOpen(t *testing.T, router http.Handler, nsid, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/"+nsid, strings.NewReader(body)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func emailByAddress(t *testing.T, d *db.DB, did, addr string) models.Email { + t.Helper() + emails, err := db.GetAllEmails(d, did) + if err != nil { + t.Fatalf("GetAllEmails: %v", err) + } + for _, e := range emails { + if e.Address == addr { + return e + } + } + t.Fatalf("no email row for %s on %s", addr, did) + return models.Email{} +} + +func assertVerificationToken(t *testing.T, token string) { + t.Helper() + if len(token) != 64 { + t.Errorf("token %q not 64 hex characters", token) + } + for _, c := range token { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + t.Errorf("token %q contains non-hex character", token) + } + } +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = old }() + fn() + w.Close() + data, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read stdout: %v", err) + } + return string(data) +} + +func seedVerifiedSecondary(t *testing.T, d *db.DB, did, addr string) { + t.Helper() + if err := db.InsertUnverifiedEmail(d, did, addr, "000000"); err != nil { + t.Fatalf("InsertUnverifiedEmail: %v", err) + } + if err := db.MarkEmailVerified(d, did, addr); err != nil { + t.Fatalf("MarkEmailVerified: %v", err) + } +} + +func TestAccountAddEmailCanonicalizesAndInsertsUnverified(t *testing.T) { + router, d, kvRec, sign := newEmailXrpc(t) + + emitted := captureStdout(t, func() { + rec := postJSON(t, router, "org.tangled.temp.account.addEmail", `{"email":" Alice@Example.com "}`, sign) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + }) + + row := emailByAddress(t, d, testActor, "alice@example.com") + if row.Verified { + t.Errorf("email verified before link submission") + } + if row.Primary { + t.Errorf("unverified email marked primary") + } + assertVerificationToken(t, row.VerificationCode) + if !strings.Contains(emitted, "alice@example.com") || + !strings.Contains(emitted, "/verify/email?token="+row.VerificationCode) { + t.Errorf("verification email missing canonical address or link; emitted:\n%s", emitted) + } + kvRec.assertIdle(t) +} + +func TestAccountAddEmailRejectsRegisteredElsewhere(t *testing.T) { + router, d, kvRec, sign := newEmailXrpc(t) + + other := "did:plc:someone-else" + if err := db.AddEmail(d, models.Email{Did: other, Address: "taken@example.com", Verified: true, Primary: true}); err != nil { + t.Fatalf("AddEmail: %v", err) + } + + rec := postJSON(t, router, "org.tangled.temp.account.addEmail", `{"email":"taken@example.com"}`, sign) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body.String()) + } + + exists, err := db.CheckEmailExists(d, testActor, "taken@example.com") + if err != nil { + t.Fatalf("CheckEmailExists: %v", err) + } + if exists { + t.Errorf("caller gained a row for the rejected address") + } + kvRec.assertIdle(t) +} + +func TestAccountAddEmailRejectsVerifiedOwnAddress(t *testing.T) { + router, d, kvRec, sign := newEmailXrpc(t) + + if err := db.AddEmail(d, models.Email{Did: testActor, Address: "mine@example.com", Verified: true, Primary: true}); err != nil { + t.Fatalf("AddEmail: %v", err) + } + + rec := postJSON(t, router, "org.tangled.temp.account.addEmail", `{"email":"mine@example.com"}`, sign) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body.String()) + } + + code, err := db.GetVerificationCodeForEmail(d, testActor, "mine@example.com") + if err != nil { + t.Fatalf("GetVerificationCodeForEmail: %v", err) + } + if code != "" { + t.Errorf("resend overwrote the state of an already-verified address") + } + kvRec.assertIdle(t) +} + +func TestAccountAddEmailResendsForUnverified(t *testing.T) { + router, d, kvRec, sign := newEmailXrpc(t) + + if err := db.InsertUnverifiedEmail(d, testActor, "retry@example.com", "000000"); err != nil { + t.Fatalf("InsertUnverifiedEmail: %v", err) + } + + emitted := captureStdout(t, func() { + rec := postJSON(t, router, "org.tangled.temp.account.addEmail", `{"email":"retry@example.com"}`, sign) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (resend); body=%s", rec.Code, rec.Body.String()) + } + }) + + token, err := db.GetVerificationCodeForEmail(d, testActor, "retry@example.com") + if err != nil { + t.Fatalf("GetVerificationCodeForEmail: %v", err) + } + if token == "000000" { + t.Errorf("resend kept the old verification token") + } + assertVerificationToken(t, token) + if !strings.Contains(emitted, "retry@example.com") || + !strings.Contains(emitted, "/verify/email?token="+token) { + t.Errorf("resend email missing address or fresh link; emitted:\n%s", emitted) + } + // the superseded token must not verify anything anymore + if rec := postOpen(t, router, "org.tangled.temp.account.verifyEmail", `{"token":"000000"}`); rec.Code != http.StatusBadRequest { + t.Errorf("superseded token status = %d, want 400", rec.Code) + } + row := emailByAddress(t, d, testActor, "retry@example.com") + if row.Verified || row.VerificationCode != token { + t.Errorf("superseded token mutated the row: %+v", row) + } + kvRec.assertIdle(t) +} + +const verifTokenA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func TestAccountVerifyEmailFirstBecomesPrimaryAndWritesKV(t *testing.T) { + router, d, kvRec, _ := newEmailXrpc(t) + + if err := db.InsertUnverifiedEmail(d, testActor, "bob@example.com", verifTokenA); err != nil { + t.Fatalf("InsertUnverifiedEmail: %v", err) + } + + rec := postOpen(t, router, "org.tangled.temp.account.verifyEmail", `{"token":"`+verifTokenA+`"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var out tangled.TempAccountVerifyEmail_Output + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + if out.Email != "bob@example.com" { + t.Errorf("output email = %q, want bob@example.com", out.Email) + } + + row := emailByAddress(t, d, testActor, "bob@example.com") + if !row.Verified { + t.Errorf("email not verified after link submission") + } + if !row.Primary { + t.Errorf("first verified email not promoted to primary") + } + if row.VerificationCode != "" { + t.Errorf("verification token not burned: %q", row.VerificationCode) + } + + emailDids, primaries, _ := kvRec.snapshot() + if len(emailDids) != 1 || emailDids[0] != "bob@example.com|"+testActor { + t.Errorf("PutEmailDid calls = %v, want [bob@example.com|%s]", emailDids, testActor) + } + if len(primaries) != 1 || primaries[0] != testActor+"|bob@example.com" { + t.Errorf("SetPrimaryEmail calls = %v, want [%s|bob@example.com]", primaries, testActor) + } +} + +func TestAccountVerifyEmailKeepsExistingPrimary(t *testing.T) { + router, d, kvRec, _ := newEmailXrpc(t) + + if err := db.AddEmail(d, models.Email{Did: testActor, Address: "primary@example.com", Verified: true, Primary: true}); err != nil { + t.Fatalf("AddEmail: %v", err) + } + if err := db.InsertUnverifiedEmail(d, testActor, "second@example.com", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); err != nil { + t.Fatalf("InsertUnverifiedEmail: %v", err) + } + + rec := postOpen(t, router, "org.tangled.temp.account.verifyEmail", `{"token":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + row := emailByAddress(t, d, testActor, "second@example.com") + if !row.Verified { + t.Errorf("email not verified") + } + if row.Primary { + t.Errorf("verified secondary took over an existing primary") + } + if row.VerificationCode != "" { + t.Errorf("secondary verify did not burn the token: %q", row.VerificationCode) + } + if primary := emailByAddress(t, d, testActor, "primary@example.com"); !primary.Primary { + t.Errorf("existing primary lost its flag") + } + + emailDids, primaries, _ := kvRec.snapshot() + if len(emailDids) != 1 || emailDids[0] != "second@example.com|"+testActor { + t.Errorf("PutEmailDid calls = %v, want [second@example.com|%s]", emailDids, testActor) + } + if len(primaries) != 0 { + t.Errorf("SetPrimaryEmail called for a secondary verify: %v", primaries) + } +} + +func TestAccountVerifyEmailBadToken(t *testing.T) { + router, d, kvRec, _ := newEmailXrpc(t) + + if err := db.InsertUnverifiedEmail(d, testActor, "bob@example.com", verifTokenA); err != nil { + t.Fatalf("InsertUnverifiedEmail: %v", err) + } + + rec := postOpen(t, router, "org.tangled.temp.account.verifyEmail", `{"token":"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + + row := emailByAddress(t, d, testActor, "bob@example.com") + if row.Verified { + t.Errorf("email verified despite bad token") + } + if row.VerificationCode != verifTokenA { + t.Errorf("bad token mutated the stored verification token") + } + if row.Primary { + t.Errorf("row promoted despite bad token") + } + kvRec.assertIdle(t) +} + +func TestAccountVerifyEmailTokenSingleUse(t *testing.T) { + router, d, _, _ := newEmailXrpc(t) + + if err := db.InsertUnverifiedEmail(d, testActor, "bob@example.com", verifTokenA); err != nil { + t.Fatalf("InsertUnverifiedEmail: %v", err) + } + + body := `{"token":"` + verifTokenA + `"}` + if rec := postOpen(t, router, "org.tangled.temp.account.verifyEmail", body); rec.Code != http.StatusOK { + t.Fatalf("first use status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if rec := postOpen(t, router, "org.tangled.temp.account.verifyEmail", body); rec.Code != http.StatusBadRequest { + t.Fatalf("second use status = %d, want 400 (burned token)", rec.Code) + } +} + +func TestAccountVerifyEmailTokenConcurrent(t *testing.T) { + router, d, kvRec, _ := newEmailXrpc(t) + + if err := db.InsertUnverifiedEmail(d, testActor, "bob@example.com", verifTokenA); err != nil { + t.Fatalf("InsertUnverifiedEmail: %v", err) + } + + body := `{"token":"` + verifTokenA + `"}` + nsid := "org.tangled.temp.account.verifyEmail" + serve := func() int { + req := httptest.NewRequest(http.MethodPost, "/"+nsid, strings.NewReader(body)) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec.Code + } + codes := make(chan int, 2) + go func() { codes <- serve() }() + go func() { codes <- serve() }() + + got := []int{<-codes, <-codes} + sort.Ints(got) + if got[0] != http.StatusOK || got[1] != http.StatusBadRequest { + t.Fatalf("concurrent statuses = %v, want one 200 and one 400", got) + } + + row := emailByAddress(t, d, testActor, "bob@example.com") + if !row.Verified || !row.Primary { + t.Errorf("winner state: verified=%v primary=%v, want both true", row.Verified, row.Primary) + } + if row.VerificationCode != "" { + t.Errorf("token not burned after concurrent verify: %q", row.VerificationCode) + } + emailDids, primaries, _ := kvRec.snapshot() + if len(emailDids) != 1 || len(primaries) != 1 { + t.Errorf("KV calls = %v / %v, want exactly one of each", emailDids, primaries) + } +} + +func TestAccountSetPrimaryEmailWritesKV(t *testing.T) { + router, d, kvRec, sign := newEmailXrpc(t) + + const oldPrimary = "primary@example.com" + const newPrimary = "secondary@example.com" + if err := db.AddEmail(d, models.Email{Did: testActor, Address: oldPrimary, Verified: true, Primary: true}); err != nil { + t.Fatalf("AddEmail: %v", err) + } + seedVerifiedSecondary(t, d, testActor, newPrimary) + + rec := postJSON(t, router, "org.tangled.temp.account.setPrimaryEmail", `{"email":"secondary@example.com"}`, sign) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + emailDids, primaries, _ := kvRec.snapshot() + if len(emailDids) != 1 || emailDids[0] != newPrimary+"|"+testActor { + t.Errorf("PutEmailDid calls = %v, want [%s|%s]", emailDids, newPrimary, testActor) + } + if len(primaries) != 1 || primaries[0] != testActor+"|"+newPrimary { + t.Errorf("SetPrimaryEmail calls = %v, want [%s|%s]", primaries, testActor, newPrimary) + } + + got := emailByAddress(t, d, testActor, newPrimary) + if !got.Primary { + t.Errorf("db did not promote %s", newPrimary) + } + if old := emailByAddress(t, d, testActor, oldPrimary); old.Primary { + t.Errorf("db kept %s as primary", oldPrimary) + } +} + +func TestAccountDeleteEmailWritesKV(t *testing.T) { + router, d, kvRec, sign := newEmailXrpc(t) + + const primary = "primary@example.com" + const secondary = "secondary@example.com" + if err := db.AddEmail(d, models.Email{Did: testActor, Address: primary, Verified: true, Primary: true}); err != nil { + t.Fatalf("AddEmail: %v", err) + } + seedVerifiedSecondary(t, d, testActor, secondary) + + rec := postJSON(t, router, "org.tangled.temp.account.deleteEmail", `{"email":"secondary@example.com"}`, sign) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + _, _, deletes := kvRec.snapshot() + if len(deletes) != 1 || deletes[0] != secondary { + t.Errorf("DeleteEmailKey calls = %v, want [%s]", deletes, secondary) + } + + emails, err := db.GetAllEmails(d, testActor) + if err != nil { + t.Fatalf("GetAllEmails: %v", err) + } + for _, e := range emails { + if e.Address == secondary { + t.Errorf("deleted row %s still present", secondary) + } + if e.Address == primary && !e.Primary { + t.Errorf("primary flag lost on %s", primary) + } + } +} + +func TestAccountDeleteUnverifiedEmail(t *testing.T) { + router, d, kvRec, sign := newEmailXrpc(t) + + if err := db.InsertUnverifiedEmail(d, testActor, "pending@example.com", "111111"); err != nil { + t.Fatalf("InsertUnverifiedEmail: %v", err) + } + + rec := postJSON(t, router, "org.tangled.temp.account.deleteEmail", `{"email":"pending@example.com"}`, sign) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + emails, err := db.GetAllEmails(d, testActor) + if err != nil { + t.Fatalf("GetAllEmails: %v", err) + } + if len(emails) != 0 { + t.Errorf("pending row not removed: %+v", emails) + } + _, _, deletes := kvRec.snapshot() + if len(deletes) != 1 || deletes[0] != "pending@example.com" { + t.Errorf("DeleteEmailKey calls = %v, want [pending@example.com]", deletes) + } +} + +func TestAccountDeletePrimaryRejected(t *testing.T) { + router, d, kvRec, sign := newEmailXrpc(t) + + if err := db.AddEmail(d, models.Email{Did: testActor, Address: "primary@example.com", Verified: true, Primary: true}); err != nil { + t.Fatalf("AddEmail: %v", err) + } + + rec := postJSON(t, router, "org.tangled.temp.account.deleteEmail", `{"email":"primary@example.com"}`, sign) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + + row := emailByAddress(t, d, testActor, "primary@example.com") + if !row.Primary { + t.Errorf("primary flag dropped despite rejection") + } + _, _, deletes := kvRec.snapshot() + if len(deletes) != 0 { + t.Errorf("KV delete called despite rejection: %v", deletes) + } +} diff --git a/deliberi/xrpc/signup.go b/deliberi/xrpc/signup.go index 408af869..9886017d 100644 --- a/deliberi/xrpc/signup.go +++ b/deliberi/xrpc/signup.go @@ -3,6 +3,7 @@ package xrpc import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,9 +16,10 @@ import ( "tangled.org/core/deliberi/models" ) -// subdomainRegex validates the requested pds handle label var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) +var errEmailAlreadyRegistered = errors.New("email already registered") + func isValidSubdomain(name string) bool { return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) } @@ -25,7 +27,6 @@ func isValidSubdomain(name string) bool { func (x *Xrpc) AccountBeginSignup(w http.ResponseWriter, r *http.Request) { l := x.Logger.With("handler", "AccountBeginSignup") - // signup needs a pds admin secret (turnstile is enforced upstream) if !x.Config.SignupEnabled() { writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency) return @@ -36,13 +37,14 @@ func (x *Xrpc) AccountBeginSignup(w http.ResponseWriter, r *http.Request) { writeError(w, errBadRequestBody, http.StatusBadRequest) return } + email := canonicalEmail(input.Email) - if !appviewemail.IsValidEmail(input.Email) { + if !appviewemail.IsValidEmail(email) { writeError(w, xrpcErrorTag("InvalidEmail", "invalid email address"), http.StatusBadRequest) return } - exists, err := db.CheckEmailExistsAtAll(x.DB, input.Email) + exists, err := db.CheckEmailExistsAtAll(x.DB, email) if err != nil { l.Error("failed to check email existence", "err", err) writeError(w, errInternal, http.StatusInternalServerError) @@ -53,7 +55,6 @@ func (x *Xrpc) AccountBeginSignup(w http.ResponseWriter, r *http.Request) { return } - // the verification code is an invite code minted by the PDS code, err := x.pdsCreateInviteCode() if err != nil { l.Error("failed to create invite code", "err", err) @@ -61,17 +62,15 @@ func (x *Xrpc) AccountBeginSignup(w http.ResponseWriter, r *http.Request) { return } - if err := db.AddInflightSignup(x.DB, models.InflightSignup{Email: input.Email, InviteCode: code}); err != nil { + if err := db.AddInflightSignup(x.DB, models.InflightSignup{Email: email, InviteCode: code}); err != nil { l.Error("failed to add inflight signup", "err", err) writeError(w, errInternal, http.StatusInternalServerError) return } - // deliberi owns email now: send the verification code inline (stdout in dev - // when no resend key is set). 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(input.Email, "Verify your Tangled account", text, html); err != nil { + if err := x.Sender.Send(email, "Verify your Tangled account", text, html); err != nil { l.Error("failed to send verification email", "err", err) writeError(w, errInternal, http.StatusInternalServerError) return @@ -105,9 +104,14 @@ func (x *Xrpc) AccountCompleteSignup(w http.ResponseWriter, r *http.Request) { writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification code"), http.StatusBadRequest) return } + emailAddr = canonicalEmail(emailAddr) did, handle, err := x.provisionAccount(input.Username, input.Password, emailAddr, input.Code) if err != nil { + if errors.Is(err, errEmailAlreadyRegistered) { + writeError(w, xrpcErrorTag("EmailAlreadyRegistered", "an account already exists for this email"), http.StatusConflict) + return + } l.Error("failed to provision account", "err", err) writeError(w, errUpstream, http.StatusBadGateway) return @@ -122,8 +126,6 @@ func (x *Xrpc) AccountCompleteSignup(w http.ResponseWriter, r *http.Request) { x.writeJSON(w, &tangled.TempAccountCompleteSignup_Output{Did: did, Handle: handle}) } -// provisionAccount creates the pds account and records its verified primary -// email, rolling back on failure. func (x *Xrpc) provisionAccount(username, password, emailAddr, code string) (did, handle string, err error) { success := false emailAdded := false @@ -150,18 +152,22 @@ func (x *Xrpc) provisionAccount(username, password, emailAddr, code string) (did } if err = db.AddEmail(x.DB, models.Email{Did: did, Address: emailAddr, Verified: true, Primary: true}); err != nil { + if db.IsUniqueConstraintErr(err) { + return "", "", errEmailAlreadyRegistered + } return "", "", err } emailAdded = true - // sites subdomain auto-claim now belongs elsewhere; deliberi does not own the sites table + if x.KV != nil { + x.KV.PutEmailDid(emailAddr, did) + x.KV.SetPrimaryEmail(did, emailAddr) + } success = true return did, handle, nil } -// pdsRequest posts to a pds xrpc endpoint; useAuth sends the admin secret via -// basic auth. these are unauth'd or admin-authed, so they use raw http. func (x *Xrpc) pdsRequest(endpoint string, body any, useAuth bool) (*http.Response, error) { jsonData, err := json.Marshal(body) if err != nil { diff --git a/deliberi/xrpc/xrpc.go b/deliberi/xrpc/xrpc.go index 80b1ac63..d15861af 100644 --- a/deliberi/xrpc/xrpc.go +++ b/deliberi/xrpc/xrpc.go @@ -11,6 +11,7 @@ import ( tangled "tangled.org/core/api/org_tangled" config "tangled.org/core/deliberi/config" db "tangled.org/core/deliberi/db" + "tangled.org/core/deliberi/kv" "tangled.org/core/deliberi/mailer" "tangled.org/core/idresolver" xrpcerr "tangled.org/core/xrpc/errors" @@ -26,24 +27,21 @@ type Xrpc struct { ServiceAuth *serviceauth.ServiceAuth IdResolver *idresolver.Resolver Sender *mailer.Sender + KV kv.EmailKV } func (x *Xrpc) Router() http.Handler { r := chi.NewRouter() r.Use(x.cors) - // health check, atproto _health convention r.Get("/_health", x.health) - - // open endpoints: signup happens pre-identity, so no service auth r.Post("/"+tangled.TempAccountBeginSignupNSID, x.AccountBeginSignup) r.Post("/"+tangled.TempAccountCompleteSignupNSID, x.AccountCompleteSignup) + r.Post("/"+tangled.TempAccountVerifyEmailNSID, x.AccountVerifyEmail) - // authenticated endpoints r.Group(func(r chi.Router) { r.Use(x.ServiceAuth.VerifyServiceAuth) - // notifications r.Get("/"+tangled.TempNotificationListNotificationsNSID, x.NotificationList) r.Get("/"+tangled.TempNotificationGetUnreadCountNSID, x.NotificationGetUnreadCount) r.Post("/"+tangled.TempNotificationUpdateSeenNSID, x.NotificationUpdateSeen) @@ -52,19 +50,17 @@ func (x *Xrpc) Router() http.Handler { r.Get("/"+tangled.TempNotificationGetPreferencesNSID, x.NotificationGetPreferences) r.Post("/"+tangled.TempNotificationUpdatePreferencesNSID, x.NotificationUpdatePreferences) - // account management r.Get("/"+tangled.TempAccountListEmailsNSID, x.AccountListEmails) r.Post("/"+tangled.TempAccountDeleteEmailNSID, x.AccountDeleteEmail) r.Post("/"+tangled.TempAccountSetPrimaryEmailNSID, x.AccountSetPrimaryEmail) + r.Post("/"+tangled.TempAccountAddEmailNSID, x.AccountAddEmail) }) return r } -// timeFormat is the datetime format used across lexicon output fields const timeFormat = "2006-01-02T15:04:05.000Z" -// health responds to /xrpc/_health with the running version func (x *Xrpc) health(w http.ResponseWriter, r *http.Request) { x.writeJSON(w, map[string]string{"version": serviceVersion()}) } @@ -82,8 +78,8 @@ func serviceVersion() string { return "dev" } -// cors allows the browser origin to call the xrpc endpoints. auth is via -// bearer tokens, not cookies, so a wildcard origin is safe. +// cors opens the xrpc endpoints to browser origins; bearer auth, not cookies, +// makes a wildcard origin safe func (x *Xrpc) cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") @@ -117,7 +113,6 @@ func actorDid(r *http.Request) (string, bool) { return did.String(), true } -// stable client-facing errors; handlers log the real cause and return these var ( errInternal = xrpcErrorTag("InternalError", "internal server error") errBadRequestBody = xrpcErrorTag("InvalidRequest", "invalid request body") diff --git a/deliberi/xrpc/xrpc_test.go b/deliberi/xrpc/xrpc_test.go index 5738311c..d288b028 100644 --- a/deliberi/xrpc/xrpc_test.go +++ b/deliberi/xrpc/xrpc_test.go @@ -29,10 +29,13 @@ const ( testAudience = "did:web:test.example" ) -// newTestXrpc builds an Xrpc backed by a fresh temp DB, service auth wired to a -// mock directory holding testActor's key. returns router, db, and a token -// signer for a given lexicon method. func newTestXrpc(t *testing.T) (http.Handler, *db.DB, func(nsid string) string) { + x, router, d, sign := newTestXrpcFull(t, nil) + _ = x + return router, d, sign +} + +func newTestXrpcFull(t *testing.T, configure func(*Xrpc)) (*Xrpc, http.Handler, *db.DB, func(nsid string) string) { t.Helper() d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "test.db")) @@ -65,6 +68,9 @@ func newTestXrpc(t *testing.T) (http.Handler, *db.DB, func(nsid string) string) Logger: logger, ServiceAuth: serviceauth.NewServiceAuth(logger, dir, testAudience), } + if configure != nil { + configure(x) + } sign := func(nsid string) string { lxm := syntax.NSID(nsid) @@ -75,7 +81,7 @@ func newTestXrpc(t *testing.T) (http.Handler, *db.DB, func(nsid string) string) return token } - return x.Router(), d, sign + return x, x.Router(), d, sign } func TestHealth(t *testing.T) { @@ -110,7 +116,6 @@ func TestServiceAuthRequired(t *testing.T) { func TestWrongLexiconTokenRejected(t *testing.T) { router, _, sign := newTestXrpc(t) - // a token minted for a different method must not authorize this call req := httptest.NewRequest(http.MethodGet, "/org.tangled.temp.notification.getUnreadCount", nil) req.Header.Set("Authorization", "Bearer "+sign("org.tangled.temp.notification.listNotifications")) rec := httptest.NewRecorder() -- 2.51.2