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>
5 files changed,
+280
−0
internal/console/invite_delivery_test.go+141 −0internal/console/keymailfed.go+21 −0internal/console/keymailfed_test.go+25 −0internal/console/server.go+84 −0internal/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 |