carlos / platform Public

console: invite volume limits and one proof of work at a time

handleInvite refuses past 20 waiting invites per account and past 20
invites created per account in a sliding hour, before any row or
delivery. Both refusal strings are drafts in two helpers pinned by
TestInviteCopyHelpers. registerSender takes a process-wide one-slot
semaphore around powMine and gives up at its context's deadline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev f2c3a31ba6dbdcffc9fec1d59999ebea8804d3f8 parent a747866
5 files changed, +280 −0
  • internal/console/invite_delivery_test.go +141 −0
  • internal/console/keymailfed.go +21 −0
  • internal/console/keymailfed_test.go +25 −0
  • internal/console/server.go +84 −0
  • internal/console/store.go +9 −0
diff --git a/internal/console/invite_delivery_test.go b/internal/console/invite_delivery_test.go
index 698ca94da..8081cc199 100644
--- a/internal/console/invite_delivery_test.go
+++ b/internal/console/invite_delivery_test.go
@@ -2,6 +2,7 @@ package console
import (
"context"
+ "fmt"
"net/http"
"net/url"
"strings"
@@ -26,6 +27,13 @@ func (f *fakeConsoleMail) Send(_ context.Context, to, _, body string) error {
return nil
}
+// sentCopy reads sent under the lock: Send runs on the server's goroutine.
+func (f *fakeConsoleMail) sentCopy() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]string(nil), f.sent...)
+}
+
// flashAfter reads the flash cookie the last redirect set.
func flashAfter(t *testing.T, c *http.Client, base string) (kind, msg string) {
t.Helper()
@@ -156,6 +164,14 @@ func TestInviteCopyHelpers(t *testing.T) {
"Invited sam@keymail.dev, but we couldn't deliver the link. Send it to them yourself: https://console.example/invite/tok"; got != want {
t.Errorf("inviteFlashFailed = %q, want %q", got, want)
}
+ // DRAFTS under copy review (final-review F2): swap the approved text in
+ // the helpers and here.
+ if got, want := inviteFlashTooManyPending(), "This account already has 20 invites waiting. Revoke some before you send more."; got != want {
+ t.Errorf("inviteFlashTooManyPending = %q, want %q", got, want)
+ }
+ if got, want := inviteFlashTooManyPerHour(), "This account has sent 20 invites in the last hour. Try again later."; got != want {
+ t.Errorf("inviteFlashTooManyPerHour = %q, want %q", got, want)
+ }
wantSubject := "You've been invited to an account on the CARLOS platform"
wantBody := "paul@keymail.dev invited you to the account \"ops\" on the CARLOS platform.\n\n" +
"Open this link while signed in as sam@keymail.dev to accept. It works once and expires in 7 days:\n\n" +
@@ -169,3 +185,128 @@ func TestInviteCopyHelpers(t *testing.T) {
t.Errorf("inviteMessage body = %q, want %q", gotBody, wantBody)
}
}
+
+// inviteAttempts swaps both delivery legs for counters, so a refusal can
+// prove nothing was even tried.
+func inviteAttempts(fx *consoleFixture) (fed *int, mailer *fakeConsoleMail) {
+ n := 0
+ fx.srv.sendFederated = func(context.Context, string, string, string) (string, error) {
+ n++
+ return "id", nil
+ }
+ mailer = &fakeConsoleMail{}
+ fx.srv.consoleMail = mailer
+ return &n, mailer
+}
+
+// Final review Important #2: an account cannot pile up more than
+// maxPendingInvites unaccepted, unexpired invites.
+func TestInviteRefusedAtThePendingCap(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := newBrowser(t)
+ signUp(t, fx, owner, "code-owner")
+ for i := 0; i < maxPendingInvites; i++ {
+ if _, err := fx.st.CreateInvite(OpsAccountID, fmt.Sprintf("p%d@keymail.dev", i), "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ fed, mailer := inviteAttempts(fx)
+ csrf := sessionToken(t, fx, owner)
+ postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
+ if kind, msg := flashAfter(t, owner, fx.ts.URL); kind != "err" || msg != inviteFlashTooManyPending() {
+ t.Fatalf("flash = %q %q, want err %q", kind, msg, inviteFlashTooManyPending())
+ }
+ if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != maxPendingInvites {
+ t.Fatalf("pending = %d, want %d (no new row)", len(pending), maxPendingInvites)
+ }
+ if *fed != 0 || len(mailer.sentCopy()) != 0 {
+ t.Fatalf("delivery attempted: keymail %d, email %v", *fed, mailer.sentCopy())
+ }
+}
+
+// An expired invite no longer counts against the cap.
+func TestInvitePendingCapIgnoresExpired(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := newBrowser(t)
+ signUp(t, fx, owner, "code-owner")
+ old := time.Now().Add(-InviteTTL - time.Hour)
+ for i := 0; i < maxPendingInvites; i++ {
+ if _, err := fx.st.CreateInvite(OpsAccountID, fmt.Sprintf("p%d@keymail.dev", i), "paul@keymail.dev", old); err != nil {
+ t.Fatal(err)
+ }
+ }
+ inviteAttempts(fx)
+ csrf := sessionToken(t, fx, owner)
+ postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
+ if kind, msg := flashAfter(t, owner, fx.ts.URL); kind != "ok" {
+ t.Fatalf("flash = %q %q, want ok", kind, msg)
+ }
+}
+
+// At most maxInvitesPerHour invite rows per account per hour, whatever
+// became of them; the window slides.
+func TestInviteHourlyLimitSlides(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := newBrowser(t)
+ signUp(t, fx, owner, "code-owner")
+ now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
+ fx.srv.inviteRate.now = func() time.Time { return now }
+ fed, mailer := inviteAttempts(fx)
+ csrf := sessionToken(t, fx, owner)
+ invite := func(to string) (string, string) {
+ postForm(t, owner, fx.u("/invite"), url.Values{"csrf": {csrf}, "address": {to}})
+ return flashAfter(t, owner, fx.ts.URL)
+ }
+ for i := 0; i < maxInvitesPerHour; i++ {
+ if kind, msg := invite(fmt.Sprintf("p%d@keymail.dev", i)); kind != "ok" {
+ t.Fatalf("invite %d: flash = %q %q, want ok", i, kind, msg)
+ }
+ // Revoked straight away, so the pending cap is never what refuses.
+ pending, _ := fx.st.PendingInvites(OpsAccountID)
+ for _, p := range pending {
+ if err := fx.st.RevokeInvite(p.Token); err != nil {
+ t.Fatal(err)
+ }
+ }
+ now = now.Add(time.Minute)
+ }
+ *fed = 0
+ if kind, msg := invite("late@keymail.dev"); kind != "err" || msg != inviteFlashTooManyPerHour() {
+ t.Fatalf("flash = %q %q, want err %q", kind, msg, inviteFlashTooManyPerHour())
+ }
+ if pending, _ := fx.st.PendingInvites(OpsAccountID); len(pending) != 0 {
+ t.Fatalf("a refused invite created a row: %+v", pending)
+ }
+ if *fed != 0 || len(mailer.sentCopy()) != 0 {
+ t.Fatalf("delivery attempted: keymail %d, email %v", *fed, mailer.sentCopy())
+ }
+ // The first invite went out at 12:00; an hour and a second later it has
+ // left the window and one more is allowed.
+ now = time.Date(2026, 10, 2, 13, 0, 1, 0, time.UTC)
+ if kind, msg := invite("later@keymail.dev"); kind != "ok" {
+ t.Fatalf("after the window slid: flash = %q %q, want ok", kind, msg)
+ }
+}
+
+// The window counts rows created, not deliveries: an invite nobody could
+// deliver still used a slot.
+func TestInviteWindowCountsUndeliveredInvites(t *testing.T) {
+ var w inviteWindow
+ now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
+ w.now = func() time.Time { return now }
+ for i := 0; i < maxInvitesPerHour; i++ {
+ if !w.take(7) {
+ t.Fatalf("take %d refused", i)
+ }
+ }
+ if w.take(7) {
+ t.Fatal("take past the limit allowed")
+ }
+ if !w.take(8) {
+ t.Fatal("another account shares the window")
+ }
+ now = now.Add(time.Hour + time.Nanosecond)
+ if !w.take(7) {
+ t.Fatal("window did not slide")
+ }
+}
diff --git a/internal/console/keymailfed.go b/internal/console/keymailfed.go
index 338b32497..63e341037 100644
--- a/internal/console/keymailfed.go
+++ b/internal/console/keymailfed.go
@@ -118,6 +118,22 @@ func needsRegistration(err error) bool {
return ke.Msg == "unknown_sender" || ke.Msg == "signing key does not match registered key"
}
+// powSlot lets one proof of work run at a time, process-wide. Each one is
+// up to maxFederationPowBits of hashing on a core, and invites can arrive
+// together.
+var powSlot = make(chan struct{}, 1)
+
+// acquirePowSlot waits for powSlot, or gives up when ctx ends so a waiting
+// invite falls through to the next channel at its deadline.
+func acquirePowSlot(ctx context.Context) (release func(), err error) {
+ select {
+ case powSlot <- struct{}{}:
+ return func() { <-powSlot }, nil
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+}
+
func (k *Keymail) registerSender(ctx context.Context, to, from string) error {
signPub := k.SignPub()
var ch struct {
@@ -136,7 +152,12 @@ func (k *Keymail) registerSender(ctx context.Context, to, from string) error {
return err
}
nonce := sha256.Sum256([]byte(ch.Challenge))
+ releasePow, err := acquirePowSlot(ctx)
+ if err != nil {
+ return fmt.Errorf("keymail: proof of work: %w", err)
+ }
counter, err := powMine(ctx, nonce[:], signPubRaw, ch.Bits)
+ releasePow()
if err != nil {
return fmt.Errorf("keymail: proof of work: %w", err)
}
diff --git a/internal/console/keymailfed_test.go b/internal/console/keymailfed_test.go
index 7a919b4a6..794fb9fb2 100644
--- a/internal/console/keymailfed_test.go
+++ b/internal/console/keymailfed_test.go
@@ -8,6 +8,7 @@ import (
"errors"
"strings"
"testing"
+ "time"
)
func fedClient(t *testing.T, fk *fakeKeymail) *Keymail {
@@ -108,3 +109,27 @@ func TestSendFederatedWithoutKey(t *testing.T) {
t.Fatal("SendFederated without a signing key succeeded")
}
}
+
+// Final review Important #2: proof of work is one core's worth at a time.
+// A second registration waits for the slot and gives up at its deadline.
+func TestPowSlotIsOneAtATimeAndHonoursTheDeadline(t *testing.T) {
+ release, err := acquirePowSlot(context.Background())
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ start := time.Now()
+ if _, err := acquirePowSlot(ctx); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("second acquire while held = %v, want DeadlineExceeded", err)
+ }
+ if d := time.Since(start); d > time.Second {
+ t.Fatalf("waiting acquire took %v", d)
+ }
+ release()
+ again, err := acquirePowSlot(context.Background())
+ if err != nil {
+ t.Fatalf("acquire after release: %v", err)
+ }
+ again()
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 46c7903d5..af7efdc33 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -826,6 +826,10 @@ type Server struct {
consoleMail ConsoleMailer
inviteTimeout time.Duration
inviteKeymailShare time.Duration
+ // inviteRate is each account's invites created in the last hour
+ // (maxInvitesPerHour). In memory: a restart forgets it, and the pending
+ // cap still holds.
+ inviteRate inviteWindow
// metricsCache holds the result of LoadMetrics's last successful bucket
// sweep (metricsview.go). Concurrent callers within metricsCacheTTL share
// it outright; callers arriving later still get it immediately (stale-
@@ -2200,8 +2204,24 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
back("err", to+" is already a member of this account.")
return
}
+ // Volume limits (each invite is a send as the platform): first the
+ // account's waiting invites, then its last hour. Both refuse before a row
+ // exists or any delivery is tried.
+ if n, err := s.store.CountPendingInvites(acct.ID, time.Now()); err != nil {
+ log.Printf("console: count pending invites: %v", err)
+ back("err", "Could not create the invitation. Please try again.")
+ return
+ } else if n >= maxPendingInvites {
+ back("err", inviteFlashTooManyPending())
+ return
+ }
+ if !s.inviteRate.take(acct.ID) {
+ back("err", inviteFlashTooManyPerHour())
+ return
+ }
tok, err := s.store.CreateInvite(acct.ID, to, addr, time.Now())
if err != nil {
+ s.inviteRate.giveBack(acct.ID)
log.Printf("console: create invite: %v", err)
back("err", "Could not create the invitation. Please try again.")
return
@@ -2246,6 +2266,70 @@ func inviteFlashFailed(to, link string) string {
return "Invited " + to + ", but we couldn't deliver the link. Send it to them yourself: " + link
}
+// The two volume refusals. DRAFTS under copy review: the approved text goes
+// here and in TestInviteCopyHelpers.
+func inviteFlashTooManyPending() string {
+ return "This account already has 20 invites waiting. Revoke some before you send more."
+}
+func inviteFlashTooManyPerHour() string {
+ return "This account has sent 20 invites in the last hour. Try again later."
+}
+
+// Invite volume limits. Every invite is a message sent as the platform, to
+// an address of the owner's choosing, from an account anyone signed in can
+// create.
+const (
+ maxPendingInvites = 20
+ maxInvitesPerHour = 20
+)
+
+// inviteWindow is a per-account sliding hour of invite creations. The zero
+// value is ready to use.
+type inviteWindow struct {
+ mu sync.Mutex
+ now func() time.Time // nil: time.Now
+ sent map[int64][]time.Time
+}
+
+// take records one invite for acct and reports true, or reports false
+// without recording when acct already has maxInvitesPerHour in the last hour.
+// Check and record are one step, so concurrent invites cannot both slip
+// under the limit.
+func (w *inviteWindow) take(acct int64) bool {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ now := time.Now()
+ if w.now != nil {
+ now = w.now()
+ }
+ cutoff := now.Add(-time.Hour)
+ kept := w.sent[acct][:0]
+ for _, t := range w.sent[acct] {
+ if t.After(cutoff) {
+ kept = append(kept, t)
+ }
+ }
+ if len(kept) >= maxInvitesPerHour {
+ w.sent[acct] = kept
+ return false
+ }
+ if w.sent == nil {
+ w.sent = map[int64][]time.Time{}
+ }
+ w.sent[acct] = append(kept, now)
+ return true
+}
+
+// giveBack undoes the latest take for acct: the row it was for was never
+// created.
+func (w *inviteWindow) giveBack(acct int64) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ if ts := w.sent[acct]; len(ts) > 0 {
+ w.sent[acct] = ts[:len(ts)-1]
+ }
+}
+
func (s *Server) handleRevokeInvite(w http.ResponseWriter, r *http.Request) {
_, raw, acct, ok := s.owner(w, r)
if !ok {
diff --git a/internal/console/store.go b/internal/console/store.go
index 35597f3bb..24cb0ff73 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -1291,6 +1291,15 @@ func (s *Store) RevokeInvite(token string) error {
return err
}
+// CountPendingInvites counts an account's unaccepted invitations that have
+// not expired at now: the ones handleInvite's cap is about.
+func (s *Store) CountPendingInvites(accountID int64, now time.Time) (int, error) {
+ var n int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM invites WHERE account_id = ? AND accepted_at = 0 AND expires_at >= ?`,
+ accountID, now.Unix()).Scan(&n)
+ return n, err
+}
+
// PendingInvites lists an account's unaccepted, unexpired invitations.
func (s *Store) PendingInvites(accountID int64) ([]Invite, error) {
rows, err := s.db.Query(`SELECT token, account_id, address, invited_by, created_at, expires_at