+{{end}}
+{{define "text"}}{{t "account.passkeyAdded.title"}}
+
+{{t "account.passkeyAdded.body" "name" .PasskeyName "date" (date .CreatedAt)}}
+
+{{t "account.passkeyAdded.revoke"}} {{.SecurityURL}}
+
+{{t "account.securityNotice.warning"}}
+{{end}}
diff --git a/server/internal/envcheck/docs_defaults_test.go b/server/internal/envcheck/docs_defaults_test.go
index d9440656f..31e2cc0a7 100644
--- a/server/internal/envcheck/docs_defaults_test.go
+++ b/server/internal/envcheck/docs_defaults_test.go
@@ -143,6 +143,16 @@ var docsDefaultAccessors = map[string]func(*config.Config) string{
"SP_LDAP_USER_FILTER": fixed(config.LDAPDefaultUserFilter),
"SP_LDAP_EMAIL_ATTRIBUTE": fixed(config.LDAPDefaultEmailAttribute),
"SP_LDAP_NAME_ATTRIBUTE": fixed(config.LDAPDefaultNameAttribute),
+
+ "SP_STATUS_SUBSCRIBERS_CONFIRM_PER_RECIPIENT_PER_HOUR": intOf(func(c *config.Config) int {
+ return c.StatusSubscribers.ConfirmPerRecipientPerHour
+ }),
+ "SP_STATUS_SUBSCRIBERS_CONFIRM_PER_PAGE_PER_HOUR": intOf(func(c *config.Config) int {
+ return c.StatusSubscribers.ConfirmPerPagePerHour
+ }),
+ "SP_STATUS_SUBSCRIBERS_CONFIRM_PER_IP_PER_HOUR": intOf(func(c *config.Config) int {
+ return c.StatusSubscribers.ConfirmPerIPPerHour
+ }),
}
func fixed(v string) func(*config.Config) string { return func(*config.Config) string { return v } }
diff --git a/server/internal/handlers/auth/device_handler.go b/server/internal/handlers/auth/device_handler.go
index 9191ed446..f7be66e2d 100644
--- a/server/internal/handlers/auth/device_handler.go
+++ b/server/internal/handlers/auth/device_handler.go
@@ -212,6 +212,9 @@ func (h *Handler) RespondToDeviceConsent(writer http.ResponseWriter, req *http.R
// writeDeviceConsentError maps consent failures onto the house error shape.
func (h *Handler) writeDeviceConsentError(writer http.ResponseWriter, request *http.Request, err error) error {
switch {
+ case errors.Is(err, ErrPATForbidden):
+ return h.WriteErrorErr(writer, request, http.StatusForbidden, base.ErrorCodePATForbidden,
+ PATForbiddenMessage, err)
case errors.Is(err, ErrDeviceAuthNotFound):
return h.WriteError(writer, http.StatusNotFound, base.ErrorCodeNotFound,
"Device authorization request not found or expired")
diff --git a/server/internal/handlers/auth/device_service.go b/server/internal/handlers/auth/device_service.go
index d4131d009..efe0a8e2e 100644
--- a/server/internal/handlers/auth/device_service.go
+++ b/server/internal/handlers/auth/device_service.go
@@ -248,6 +248,14 @@ func (s *Service) GetDeviceConsent(ctx context.Context, rawUserCode string) (*De
func (s *Service) RespondToDeviceConsent(
ctx context.Context, claims *Claims, rawUserCode, orgSlug string, approve bool,
) error {
+ // A PAT must not approve a device grant (spec 2026-10-28-01): the org is
+ // taken from the body, so it would mint a PAT for any org of its owner.
+ // RequireAuth refuses the route already; this holds if it is ever reached
+ // another way.
+ if claims.IsPAT() || IsPATCredential(ctx) {
+ return ErrPATForbidden
+ }
+
userCode := NormalizeDeviceUserCode(rawUserCode)
if userCode == "" {
return ErrDeviceAuthNotFound
diff --git a/server/internal/handlers/auth/email_change.go b/server/internal/handlers/auth/email_change.go
index 4c1fd335b..7737fefab 100644
--- a/server/internal/handlers/auth/email_change.go
+++ b/server/internal/handlers/auth/email_change.go
@@ -268,6 +268,12 @@ func (s *Service) changeOwnEmail(ctx context.Context, claims *Claims, req Update
return false, ErrImpersonationForbidden
}
+ // The sign-in email is a sign-in factor: a PAT bound to one org must not
+ // change it (spec 2026-10-28-01). The rest of PATCH /auth/me stays open.
+ if claims.IsPAT() {
+ return false, ErrPATForbidden
+ }
+
user, err := s.db.GetUser(ctx, claims.UserUID)
if err != nil || user == nil {
return false, ErrUserNotFound
diff --git a/server/internal/handlers/auth/handler.go b/server/internal/handlers/auth/handler.go
index ab311727f..8709bcb00 100644
--- a/server/internal/handlers/auth/handler.go
+++ b/server/internal/handlers/auth/handler.go
@@ -371,6 +371,9 @@ func (h *Handler) handleEmailChangeError(
case errors.Is(err, ErrImpersonationForbidden):
return true, h.WriteErrorErr(writer, req, http.StatusForbidden, base.ErrorCodeImpersonationForbidden,
ImpersonationForbiddenMessage, err)
+ case errors.Is(err, ErrPATForbidden):
+ return true, h.WriteErrorErr(writer, req, http.StatusForbidden, base.ErrorCodePATForbidden,
+ PATForbiddenMessage, err)
case errors.Is(err, ErrEmailChangeNotSuperAdmin):
return true, h.WriteErrorErr(writer, req, http.StatusForbidden, base.ErrorCodeForbidden, err.Error(), err)
case errors.Is(err, ErrRateLimited):
@@ -551,6 +554,8 @@ func (h *Handler) handleAuthError(writer http.ResponseWriter, request *http.Requ
case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrOrganizationNotFound), errors.Is(err, ErrUserNotFound):
return h.WriteErrorErr(
writer, request, http.StatusUnauthorized, base.ErrorCodeInvalidCredentials, "Invalid credentials", err)
+ case errors.Is(err, ErrPATForbidden):
+ return h.WriteErrorErr(writer, request, http.StatusForbidden, base.ErrorCodePATForbidden, PATForbiddenMessage, err)
default:
return h.WriteInternalError(writer, request, err)
}
diff --git a/server/internal/handlers/auth/invite_hash_test.go b/server/internal/handlers/auth/invite_hash_test.go
index 8bdb5cc94..245dfb2ef 100644
--- a/server/internal/handlers/auth/invite_hash_test.go
+++ b/server/internal/handlers/auth/invite_hash_test.go
@@ -110,19 +110,18 @@ func createHashTestInvite(
return resp
}
-// writeLegacyInvite stores an invite the way CreateInvitation did before
-// spec 2026-09-26-02: keyed by the plaintext token, token in the value.
-func writeLegacyInvite(
+// writePreIndexInvite stores an org-scoped invite the way CreateInvitation
+// did before spec 2026-10-28-03: hashed key, no global index entry.
+func writePreIndexInvite(
t *testing.T, dbSvc db.Service, org *models.Organization,
inviterUID, email, token string,
) string {
t.Helper()
ctx := t.Context()
- key := inviteKeyPrefix + token
+ key := inviteKeyPrefix + hashPendingToken(token)
ttl := 24 * time.Hour
require.NoError(t, dbSvc.SetStateEntry(ctx, &org.UID, key, &models.JSONMap{
- keyToken: token,
keyEmail: email,
"role": string(models.MemberRoleViewer),
"inviterUID": inviterUID,
@@ -252,38 +251,94 @@ func runInviteHashContract(t *testing.T, svc *Service, dbSvc db.Service) {
r.ErrorIs(err, ErrInvitationNotFound)
})
- t.Run("a legacy plaintext-keyed invite is still accepted and deleted", func(t *testing.T) {
+ t.Run("a legacy plaintext-keyed invite is not accepted", func(t *testing.T) {
r := require.New(t)
ctx := t.Context()
org, inviter := inviteHashFixture(t, dbSvc, "ih-legacy")
+ // Shape of an invite written before spec 2026-09-26-02: keyed by the
+ // plaintext token, token in the value. Its fallback branch is gone
+ // (spec 2026-10-28-03).
const legacyToken = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
- key := writeLegacyInvite(t, dbSvc, org, inviter.UID, "legacy@acme.com", legacyToken)
+ ttl := 24 * time.Hour
+ r.NoError(dbSvc.SetStateEntry(ctx, &org.UID, inviteKeyPrefix+legacyToken, &models.JSONMap{
+ keyToken: legacyToken,
+ keyEmail: "legacy@acme.com",
+ "role": string(models.MemberRoleViewer),
+ "inviterUID": inviter.UID,
+ }, &ttl))
- info, err := svc.GetInviteInfo(ctx, legacyToken)
- r.NoError(err)
- r.Equal(org.Slug, info.OrgSlug)
- r.Equal(string(models.MemberRoleViewer), info.Role)
+ _, err := svc.GetInviteInfo(ctx, legacyToken)
+ r.ErrorIs(err, ErrInvitationNotFound)
- login, err := svc.AcceptInvite(ctx, AcceptInviteRequest{
+ _, err = svc.AcceptInvite(ctx, AcceptInviteRequest{
Token: legacyToken, Name: "Bob", Password: inviteHashTestPassword,
}, "")
+ r.ErrorIs(err, ErrInvitationNotFound)
+ })
+
+ t.Run("an invite without its index entry is not found", func(t *testing.T) {
+ r := require.New(t)
+ ctx := t.Context()
+ org, inviter := inviteHashFixture(t, dbSvc, "ih-noindex")
+
+ const token = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"
+
+ writePreIndexInvite(t, dbSvc, org, inviter.UID, "noindex@acme.com", token)
+
+ _, err := svc.GetInviteInfo(ctx, token)
+ r.ErrorIs(err, ErrInvitationNotFound, "pre-upgrade invites must be re-sent, no org scan")
+ })
+
+ t.Run("creation writes a global index entry pointing at the org", func(t *testing.T) {
+ r := require.New(t)
+ ctx := t.Context()
+ org, inviter := inviteHashFixture(t, dbSvc, "ih-index")
+
+ resp := createHashTestInvite(t, svc, org.Slug, inviter.UID, "index@acme.com")
+ key := inviteKeyPrefix + hashPendingToken(resp.Token)
+
+ index, err := dbSvc.GetStateEntry(ctx, nil, key)
r.NoError(err)
- r.Equal(org.Slug, login.Organization.Slug)
+ r.NotNil(index)
+ r.Nil(index.OrganizationUID)
+ r.NotNil(index.Value)
+ r.Equal(org.UID, (*index.Value)[keyInviteOrgUID])
+ r.NotNil(index.ExpiresAt, "the index entry expires with the invite")
- entry, err := dbSvc.GetStateEntry(ctx, &org.UID, key)
+ for field, v := range *index.Value {
+ if s, ok := v.(string); ok {
+ r.NotContains(s, resp.Token, "index field %q leaks the plaintext token", field)
+ }
+ }
+
+ _, err = svc.AcceptInvite(ctx, AcceptInviteRequest{
+ Token: resp.Token, Name: "Ida", Password: inviteHashTestPassword,
+ }, "")
r.NoError(err)
- r.Nil(entry, "the legacy entry must be deleted on acceptance")
- user, err := dbSvc.GetUserByEmail(ctx, "legacy@acme.com")
+ index, err = dbSvc.GetStateEntry(ctx, nil, key)
r.NoError(err)
- member, err := dbSvc.GetMemberByUserAndOrg(ctx, user.UID, org.UID)
+ r.Nil(index, "acceptance deletes the index entry")
+ })
+
+ t.Run("an index entry whose org entry is gone is not found", func(t *testing.T) {
+ r := require.New(t)
+ ctx := t.Context()
+ org, inviter := inviteHashFixture(t, dbSvc, "ih-dangling")
+
+ resp := createHashTestInvite(t, svc, org.Slug, inviter.UID, "dangling@acme.com")
+ key := inviteKeyPrefix + hashPendingToken(resp.Token)
+
+ _, err := dbSvc.DeleteStateEntry(ctx, &org.UID, key)
r.NoError(err)
- r.Equal(models.MemberRoleViewer, member.Role)
+
+ _, err = svc.GetInviteInfo(ctx, resp.Token)
+ r.ErrorIs(err, ErrInvitationNotFound)
})
- t.Run("already-member acceptance deletes the matched key, legacy and hashed", func(t *testing.T) {
+ t.Run("already-member acceptance deletes the invite and its index", func(t *testing.T) {
r := require.New(t)
ctx := t.Context()
org, inviter := inviteHashFixture(t, dbSvc, "ih-member")
@@ -293,25 +348,18 @@ func runInviteHashContract(t *testing.T, svc *Service, dbSvc db.Service) {
r.NoError(dbSvc.CreateOrganizationMember(ctx,
models.NewOrganizationMember(org.UID, existing.UID, models.MemberRoleUser)))
- const legacyToken = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
-
- legacyKey := writeLegacyInvite(t, dbSvc, org, inviter.UID, existing.Email, legacyToken)
-
- _, err := svc.AcceptInvite(ctx, AcceptInviteRequest{Token: legacyToken}, existing.UID)
- r.NoError(err)
-
- entry, err := dbSvc.GetStateEntry(ctx, &org.UID, legacyKey)
- r.NoError(err)
- r.Nil(entry, "the legacy entry must be deleted for an existing member")
-
resp := createHashTestInvite(t, svc, org.Slug, inviter.UID, existing.Email)
- _, err = svc.AcceptInvite(ctx, AcceptInviteRequest{Token: resp.Token}, existing.UID)
+ _, err := svc.AcceptInvite(ctx, AcceptInviteRequest{Token: resp.Token}, existing.UID)
r.NoError(err)
entries, err := dbSvc.ListStateEntries(ctx, &org.UID, inviteKeyPrefix)
r.NoError(err)
r.Empty(entries, "the hashed entry must be deleted for an existing member")
+
+ index, err := dbSvc.GetStateEntry(ctx, nil, inviteKeyPrefix+hashPendingToken(resp.Token))
+ r.NoError(err)
+ r.Nil(index, "the index entry must be deleted for an existing member")
})
t.Run("list and revoke work on a hashed-key invite", func(t *testing.T) {
@@ -336,5 +384,33 @@ func runInviteHashContract(t *testing.T, svc *Service, dbSvc db.Service) {
_, err = svc.GetInviteInfo(ctx, resp.Token)
r.ErrorIs(err, ErrInvitationNotFound)
+
+ index, err := dbSvc.GetStateEntry(ctx, nil, inviteKeyPrefix+hashPendingToken(resp.Token))
+ r.NoError(err)
+ r.Nil(index, "revocation deletes the index entry")
+ })
+
+ t.Run("a federated join through the invite deletes its index", func(t *testing.T) {
+ r := require.New(t)
+ ctx := t.Context()
+ org, inviter := inviteHashFixture(t, dbSvc, "ih-fedjoin")
+
+ resp := createHashTestInvite(t, svc, org.Slug, inviter.UID, "fedjoin@acme.com")
+
+ user := models.NewUser("fedjoin@acme.com")
+ r.NoError(dbSvc.CreateUser(ctx, user))
+
+ member, pending, err := svc.JoinOrgViaLogin(ctx, org, user)
+ r.NoError(err)
+ r.False(pending)
+ r.NotNil(member)
+
+ index, err := dbSvc.GetStateEntry(ctx, nil, inviteKeyPrefix+hashPendingToken(resp.Token))
+ r.NoError(err)
+ r.Nil(index, "a consumed invite leaves no index entry")
+ })
+
+ t.Run("lookup cost does not depend on the organization count", func(t *testing.T) {
+ runInviteLookupQueryCount(t, svc, dbSvc)
})
}
diff --git a/server/internal/handlers/auth/invite_lookup_query_count_test.go b/server/internal/handlers/auth/invite_lookup_query_count_test.go
new file mode 100644
index 000000000..c43a57a15
--- /dev/null
+++ b/server/internal/handlers/auth/invite_lookup_query_count_test.go
@@ -0,0 +1,88 @@
+package auth
+
+import (
+ "context"
+ "fmt"
+ "sync/atomic"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/uptrace/bun"
+
+ "github.com/fclairamb/solidping/server/internal/db"
+ "github.com/fclairamb/solidping/server/internal/db/models"
+)
+
+// inviteQueryCounter counts the queries bun runs while armed.
+type inviteQueryCounter struct {
+ armed atomic.Bool
+ total atomic.Int64
+}
+
+func (c *inviteQueryCounter) BeforeQuery(ctx context.Context, _ *bun.QueryEvent) context.Context {
+ return ctx
+}
+
+func (c *inviteQueryCounter) AfterQuery(_ context.Context, _ *bun.QueryEvent) {
+ if c.armed.Load() {
+ c.total.Add(1)
+ }
+}
+
+// count returns how many queries fn runs.
+func (c *inviteQueryCounter) count(fn func()) int64 {
+ c.total.Store(0)
+ c.armed.Store(true)
+ fn()
+ c.armed.Store(false)
+
+ return c.total.Load()
+}
+
+// runInviteLookupQueryCount is the regression test of spec 2026-10-28-03:
+// the public invite lookup used to probe every organization (about 2 x N
+// queries for an unknown token). It must cost the same with 50 more orgs.
+func runInviteLookupQueryCount(t *testing.T, svc *Service, dbSvc db.Service) {
+ t.Helper()
+
+ r := require.New(t)
+ ctx := t.Context()
+
+ counter := &inviteQueryCounter{}
+ dbSvc.DB().AddQueryHook(counter)
+
+ org, inviter := inviteHashFixture(t, dbSvc, "ih-qcount")
+ resp := createHashTestInvite(t, svc, org.Slug, inviter.UID, "qcount@acme.com")
+
+ const unknown = "1111111111111111111111111111111111111111111111111111111111111111"
+
+ lookUp := func(token string) (int64, error) {
+ var err error
+
+ n := counter.count(func() { _, err = svc.GetInviteInfo(ctx, token) })
+
+ return n, err
+ }
+
+ unknownBefore, err := lookUp(unknown)
+ r.ErrorIs(err, ErrInvitationNotFound)
+ r.GreaterOrEqual(unknownBefore, int64(1), "positive control: the hook must fire")
+
+ validBefore, err := lookUp(resp.Token)
+ r.NoError(err)
+
+ for i := range 50 {
+ r.NoError(dbSvc.CreateOrganization(ctx,
+ models.NewOrganization(fmt.Sprintf("ih-qcount-%02d", i), "Query count org")))
+ }
+
+ unknownAfter, err := lookUp(unknown)
+ r.ErrorIs(err, ErrInvitationNotFound)
+ r.Equal(unknownBefore, unknownAfter, "an unknown token must not cost more with more orgs")
+ r.LessOrEqual(unknownAfter, int64(2), "an unknown token is one index lookup")
+
+ validAfter, err := lookUp(resp.Token)
+ r.NoError(err)
+ r.Equal(validBefore, validAfter, "a valid token must not cost more with more orgs")
+ r.LessOrEqual(validAfter, int64(4))
+}
diff --git a/server/internal/handlers/auth/join_policy.go b/server/internal/handlers/auth/join_policy.go
index 31d22e957..e4ffcf4e5 100644
--- a/server/internal/handlers/auth/join_policy.go
+++ b/server/internal/handlers/auth/join_policy.go
@@ -235,7 +235,7 @@ func (s *Service) JoinOrgViaLogin(
}
// Consume the invitation — it is single-use, like AcceptInvite's.
- _, _ = s.db.DeleteStateEntry(ctx, &org.UID, invite.key)
+ _ = s.deleteInvite(ctx, org.UID, invite.key)
return created, false, nil
}
diff --git a/server/internal/handlers/auth/passkey_notice.go b/server/internal/handlers/auth/passkey_notice.go
new file mode 100644
index 000000000..e18650024
--- /dev/null
+++ b/server/internal/handlers/auth/passkey_notice.go
@@ -0,0 +1,36 @@
+package auth
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/fclairamb/solidping/server/internal/config"
+ "github.com/fclairamb/solidping/server/internal/db/models"
+ "github.com/fclairamb/solidping/server/internal/email"
+ "github.com/fclairamb/solidping/server/internal/handlers/base"
+ "github.com/fclairamb/solidping/server/internal/i18n/recipient"
+)
+
+// notifyPasskeyAdded emails the account owner a record of the passkey just
+// registered (spec 2026-10-28-01). A passkey signs in to every org of the
+// account, counts as MFA and survives a password reset, so one added behind
+// the owner's back must not go unnoticed. Best effort, like the device token
+// notice: the passkey is already stored.
+func (s *Service) notifyPasskeyAdded(ctx context.Context, user *models.User, passkey *models.UserPasskey) {
+ dashboardURL := strings.TrimRight(s.fullCfg.Server.BaseURL, "/") + config.DashboardBasePath
+ securityURL := dashboardURL + "/"
+
+ if claims, ok := ctx.Value(base.ContextKeyClaims).(*Claims); ok && claims.OrgSlug != "" {
+ securityURL = fmt.Sprintf("%s/orgs/%s/account/security", dashboardURL, claims.OrgSlug)
+ }
+
+ s.enqueueEmail(ctx, "", user.Email, email.TemplatePasskeyAdded, recipient.ForUser(user),
+ map[string]any{
+ "PasskeyName": passkey.Name,
+ "CreatedAt": time.Now().UTC(),
+ "SecurityURL": securityURL,
+ },
+ )
+}
diff --git a/server/internal/handlers/auth/passkey_notice_test.go b/server/internal/handlers/auth/passkey_notice_test.go
new file mode 100644
index 000000000..9a810c8f0
--- /dev/null
+++ b/server/internal/handlers/auth/passkey_notice_test.go
@@ -0,0 +1,77 @@
+package auth_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fclairamb/solidping/server/internal/config"
+ "github.com/fclairamb/solidping/server/internal/db/models"
+ "github.com/fclairamb/solidping/server/internal/db/sqlite"
+ "github.com/fclairamb/solidping/server/internal/email"
+ "github.com/fclairamb/solidping/server/internal/handlers/auth"
+ "github.com/fclairamb/solidping/server/internal/handlers/base"
+ "github.com/fclairamb/solidping/server/internal/jobs/jobdef"
+ "github.com/fclairamb/solidping/server/internal/jobs/jobsvc"
+ "github.com/fclairamb/solidping/server/internal/notifier"
+)
+
+// TestPasskeyRegistrationNotifiesOwner: registering a passkey from a session
+// still works, and enqueues a security notice to the account owner pointing at
+// the account security page (spec 2026-10-28-01).
+func TestPasskeyRegistrationNotifiesOwner(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ ctx := t.Context()
+
+ dbSvc, err := sqlite.New(ctx, sqlite.Config{InMemory: true})
+ r.NoError(err)
+ r.NoError(dbSvc.Initialize(ctx))
+ t.Cleanup(func() { _ = dbSvc.Close() })
+
+ user := models.NewUser("alice@acme.com")
+ r.NoError(dbSvc.CreateUser(ctx, user))
+
+ cfg := &config.Config{
+ Auth: config.AuthConfig{
+ JWTSecret: "test-secret-1234567890",
+ WebAuthn: config.WebAuthnConfig{Enabled: true},
+ },
+ Server: config.ServerConfig{BaseURL: passkeyTestOrigin},
+ }
+ jobs := jobsvc.NewService(dbSvc.DB(), dbSvc, notifier.NewLocalEventNotifier(), nil)
+ authSvc := auth.NewService(dbSvc, cfg.Auth, cfg, jobs, nil)
+ pkSvc := auth.NewPasskeyService(authSvc, dbSvc)
+
+ // The request context RequireAuth builds for a session in org acme.
+ reqCtx := context.WithValue(ctx, base.ContextKeyClaims, &auth.Claims{UserUID: user.UID, OrgSlug: "acme"})
+
+ device := newSoftAuthenticator(t)
+ begin, err := pkSvc.BeginRegistration(reqCtx, user.UID)
+ r.NoError(err)
+
+ info, err := pkSvc.FinishRegistration(reqCtx, begin.Session,
+ device.register(t, begin.Options.Response.Challenge.String()), "Laptop key")
+ r.NoError(err)
+ r.Equal("Laptop key", info.Name)
+
+ listed, err := dbSvc.ListJobs(ctx, nil, 0)
+ r.NoError(err)
+
+ var notices []*models.Job
+
+ for _, job := range listed {
+ if job.Type == string(jobdef.JobTypeEmail) && job.Config["template"] == email.TemplatePasskeyAdded {
+ notices = append(notices, job)
+ }
+ }
+
+ r.Len(notices, 1, "exactly one passkey notice")
+ r.Equal([]any{user.Email}, notices[0].Config["to"])
+
+ data, ok := notices[0].Config["templateData"].(map[string]any)
+ r.True(ok, "template data present: %v", notices[0].Config)
+ r.Equal("Laptop key", data["PasskeyName"])
+ r.Equal(passkeyTestOrigin+config.DashboardBasePath+"/orgs/acme/account/security", data["SecurityURL"])
+}
diff --git a/server/internal/handlers/auth/passkey_service.go b/server/internal/handlers/auth/passkey_service.go
index 6df6e9f2f..b3b86fc33 100644
--- a/server/internal/handlers/auth/passkey_service.go
+++ b/server/internal/handlers/auth/passkey_service.go
@@ -278,6 +278,8 @@ func (s *PasskeyService) FinishRegistration(
return nil, fmt.Errorf("store passkey: %w", err)
}
+ s.authSvc.notifyPasskeyAdded(ctx, user, row)
+
info := passkeyInfoFromModel(row)
return &info, nil
diff --git a/server/internal/handlers/auth/pat_binding_test.go b/server/internal/handlers/auth/pat_binding_test.go
new file mode 100644
index 000000000..9d9813f20
--- /dev/null
+++ b/server/internal/handlers/auth/pat_binding_test.go
@@ -0,0 +1,231 @@
+package auth
+
+import (
+ "context"
+ "strconv"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fclairamb/solidping/server/internal/db"
+ "github.com/fclairamb/solidping/server/internal/db/models"
+ "github.com/fclairamb/solidping/server/internal/db/postgres"
+ "github.com/fclairamb/solidping/server/internal/db/sqlite"
+ "github.com/fclairamb/solidping/server/internal/testsupport"
+)
+
+// portPATBindingPG is distinct from every other embedded-Postgres port
+// claimed in the repo (see the port-numbering note in
+// internal/db/incident_number_test.go).
+const portPATBindingPG = 15673
+
+// patBindingFixture: one user, admin of org A and org B, holding a PAT created
+// in org A (spec 2026-10-28-01).
+type patBindingFixture struct {
+ svc *Service
+ db db.Service
+ orgA *models.Organization
+ orgB *models.Organization
+ user *models.User
+ claims *Claims
+}
+
+// patCtx is the request context RequireAuth builds for a PAT.
+func patCtx(ctx context.Context) context.Context {
+ return WithPATCredential(ctx)
+}
+
+func newPATBindingFixture(
+ ctx context.Context, t *testing.T, svc *Service, dbSvc db.Service, suffix string,
+) *patBindingFixture {
+ t.Helper()
+ r := require.New(t)
+
+ orgA := models.NewOrganization("pata"+suffix, "Acme A "+suffix)
+ r.NoError(dbSvc.CreateOrganization(ctx, orgA))
+ orgB := models.NewOrganization("patb"+suffix, "Acme B "+suffix)
+ r.NoError(dbSvc.CreateOrganization(ctx, orgB))
+
+ user := models.NewUser("alice-" + suffix + "@acme.com")
+ r.NoError(dbSvc.CreateUser(ctx, user))
+
+ now := time.Now()
+ for _, org := range []*models.Organization{orgA, orgB} {
+ member := models.NewOrganizationMember(org.UID, user.UID, models.MemberRoleAdmin)
+ member.JoinedAt = &now
+ r.NoError(dbSvc.CreateOrganizationMember(ctx, member))
+ }
+
+ pat, err := svc.CreatePAT(ctx, orgA.Slug, user.UID, CreateTokenRequest{Name: "ci"})
+ r.NoError(err)
+
+ claims, err := svc.ValidateToken(ctx, pat.Token)
+ r.NoError(err)
+ r.True(claims.IsPAT())
+ r.Equal(orgA.Slug, claims.OrgSlug)
+
+ return &patBindingFixture{
+ svc: svc, db: dbSvc, orgA: orgA, orgB: orgB, user: user, claims: claims,
+ }
+}
+
+func (f *patBindingFixture) tokenCount(ctx context.Context, t *testing.T, tokenType models.TokenType) int {
+ t.Helper()
+
+ tokens, err := f.db.ListUserTokensByType(ctx, f.user.UID, tokenType)
+ require.NoError(t, err)
+
+ return len(tokens)
+}
+
+// runPATBindingContract is the service half of the PAT binding, shared by the
+// SQLite test and its Postgres twin. The route half (RequireAuth's denylist)
+// is covered against the real route table in internal/app.
+func runPATBindingContract(t *testing.T, newFixture func(t *testing.T) (*patBindingFixture, context.Context)) {
+ t.Helper()
+
+ t.Run("switch-org from a PAT mints no session", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ f, ctx := newFixture(t)
+
+ before := f.tokenCount(ctx, t, models.TokenTypeRefresh)
+
+ _, err := f.svc.SwitchOrg(patCtx(ctx), f.user.UID, f.orgB.Slug, Context{})
+ r.ErrorIs(err, ErrPATForbidden)
+ r.Equal(before, f.tokenCount(ctx, t, models.TokenTypeRefresh), "no refresh token was minted")
+
+ // Positive control: the same call from an interactive session works.
+ resp, err := f.svc.SwitchOrg(ctx, f.user.UID, f.orgB.Slug, Context{})
+ r.NoError(err)
+ r.NotEmpty(resp.RefreshToken)
+ r.Equal(before+1, f.tokenCount(ctx, t, models.TokenTypeRefresh))
+ })
+
+ t.Run("device consent from a PAT mints no PAT", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ f, ctx := newFixture(t)
+
+ before := f.tokenCount(ctx, t, models.TokenTypePAT)
+
+ start, err := f.svc.StartDeviceAuthorization(ctx, "sp CLI", DeviceRequester{IP: "203.0.113.7"})
+ r.NoError(err)
+
+ err = f.svc.RespondToDeviceConsent(patCtx(ctx), f.claims, start.UserCode, f.orgB.Slug, true)
+ r.ErrorIs(err, ErrPATForbidden)
+ r.Equal(before, f.tokenCount(ctx, t, models.TokenTypePAT), "no PAT was minted")
+
+ // Positive control: a session approves the same request.
+ session := &Claims{UserUID: f.user.UID, OrgSlug: f.orgA.Slug}
+ r.NoError(f.svc.RespondToDeviceConsent(ctx, session, start.UserCode, f.orgB.Slug, true))
+ r.Equal(before+1, f.tokenCount(ctx, t, models.TokenTypePAT))
+ })
+
+ t.Run("a PAT cannot change the sign-in email", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ f, ctx := newFixture(t)
+
+ newEmail := "mallory-" + f.orgA.Slug + "@acme.com"
+ _, err := f.svc.UpdateProfile(patCtx(ctx), f.claims, UpdateProfileRequest{Email: &newEmail})
+ r.ErrorIs(err, ErrPATForbidden)
+
+ user, err := f.db.GetUser(ctx, f.user.UID)
+ r.NoError(err)
+ r.Equal(f.user.Email, user.Email)
+
+ // The rest of the profile stays editable with a PAT.
+ name := "Alice"
+ _, err = f.svc.UpdateProfile(patCtx(ctx), f.claims, UpdateProfileRequest{Name: &name})
+ r.NoError(err)
+ })
+
+ t.Run("a PAT still creates a PAT in its own org", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ f, ctx := newFixture(t)
+
+ created, err := f.svc.CreatePAT(patCtx(ctx), f.orgA.Slug, f.user.UID, CreateTokenRequest{Name: "another"})
+ r.NoError(err)
+
+ claims, err := f.svc.ValidateToken(patCtx(ctx), created.Token)
+ r.NoError(err)
+ r.Equal(f.orgA.Slug, claims.OrgSlug)
+ })
+
+ t.Run("startSession refuses a PAT request", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ f, ctx := newFixture(t)
+
+ before := f.tokenCount(ctx, t, models.TokenTypeRefresh)
+ row := &models.UserToken{UID: "pat-session-" + f.orgA.Slug, UserUID: f.user.UID, Type: models.TokenTypeRefresh}
+
+ err := f.svc.startSession(patCtx(ctx), sessionActor{UID: f.user.UID, Email: f.user.Email},
+ f.orgA, row, string(models.MemberRoleAdmin), "password", Context{})
+ r.ErrorIs(err, ErrPATForbidden)
+ r.Equal(before, f.tokenCount(ctx, t, models.TokenTypeRefresh))
+ })
+}
+
+func TestPATBinding(t *testing.T) {
+ t.Parallel()
+
+ var seq atomic.Int64
+
+ runPATBindingContract(t, func(t *testing.T) (*patBindingFixture, context.Context) {
+ t.Helper()
+
+ ctx := t.Context()
+ dbSvc, err := sqlite.New(ctx, sqlite.Config{InMemory: true})
+ require.NoError(t, err)
+ require.NoError(t, dbSvc.Initialize(ctx))
+ t.Cleanup(func() { _ = dbSvc.Close() })
+
+ cfg := impersonationTestConfig()
+ svc := NewService(dbSvc, cfg.Auth, cfg, nil, nil)
+
+ return newPATBindingFixture(ctx, t, svc, dbSvc, "s"+strconv.FormatInt(seq.Add(1), 10)), ctx
+ })
+}
+
+// TestPATBinding_Postgres is the real-engine twin of TestPATBinding.
+func TestPATBinding_Postgres(t *testing.T) {
+ t.Parallel()
+
+ if testing.Short() {
+ t.Skip("skipping embedded-postgres test in -short mode")
+ }
+
+ ctx := t.Context()
+
+ dbService, err := postgres.New(ctx, &postgres.Config{
+ Embedded: true,
+ Port: portPATBindingPG,
+ RunMode: "test",
+ })
+ if err != nil {
+ testsupport.PostgresUnavailable(t, err)
+ }
+
+ t.Cleanup(func() { _ = dbService.Close() })
+
+ if initErr := dbService.Initialize(ctx); initErr != nil {
+ testsupport.PostgresInitFailed(t, initErr)
+ }
+
+ var seq atomic.Int64
+
+ runPATBindingContract(t, func(t *testing.T) (*patBindingFixture, context.Context) {
+ t.Helper()
+
+ cfg := impersonationTestConfig()
+ svc := NewService(dbService, cfg.Auth, cfg, nil, nil)
+ subCtx := t.Context()
+
+ return newPATBindingFixture(subCtx, t, svc, dbService, "p"+strconv.FormatInt(seq.Add(1), 10)), subCtx
+ })
+}
diff --git a/server/internal/handlers/auth/pat_guard.go b/server/internal/handlers/auth/pat_guard.go
new file mode 100644
index 000000000..a33b234f0
--- /dev/null
+++ b/server/internal/handlers/auth/pat_guard.go
@@ -0,0 +1,116 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "net/http"
+)
+
+// ErrPATForbidden is returned when a personal access token asks for something
+// it must never do (spec 2026-10-28-01): mint a session, a token for another
+// org, or change how the account signs in. Mapped to 403 PAT_FORBIDDEN.
+var ErrPATForbidden = errors.New("not allowed with a personal access token")
+
+// PATForbiddenMessage is the human half of every PAT_FORBIDDEN response.
+const PATForbiddenMessage = "A personal access token is bound to one organization. " +
+ "It cannot open a session, switch organization, or change how the account signs in: " +
+ "sign in to the dashboard, or with `sp auth login`, to do that."
+
+// IsPAT reports whether these claims were produced by ValidatePATToken, i.e.
+// the request authenticated with a personal access token. The flag is an
+// unexported field, so no signed JWT can claim it, nor shed it.
+func (c *Claims) IsPAT() bool {
+ return c != nil && c.pat
+}
+
+// patForbiddenRoutes is what a personal access token may never reach (spec
+// 2026-10-28-01). A PAT is bound to the org it was created in, and the device
+// consent page has the user choose that org on purpose; everything on this
+// list would let its holder step out of that binding: a session (with a
+// refresh token that outlives the PAT) in another org of the owner, a PAT for
+// another org, or a sign-in factor that works in every org and survives the
+// PAT's revocation.
+//
+// It starts from impersonationForbiddenRoutes, minus what a PAT may keep:
+// minting a PAT or an enrollment key in its own org (same org, same role),
+// revoking tokens, and discovery scans. The rows match on chi's resolved
+// route pattern, like the demo and impersonation guards.
+//
+// The denylist is not the only wall: Service.startSession refuses to mint any
+// session from a PAT request, whatever the route, and the device consent and
+// self-service email change refuse a PAT in the service too.
+//
+//nolint:gochecknoglobals // Effectively a constant table; Go has no const slices.
+var patForbiddenRoutes = []demoAllowedRoute{
+ // Session minting: a full session, with a refresh token, in any org of
+ // the owner.
+ {http.MethodPost, ImpersonationPathSwitchOrg},
+ {http.MethodPost, ImpersonationPathOrgs},
+ {http.MethodPost, ImpersonationPathAcceptInvite},
+ // Sign-in factors. Each one signs in to every org of the account.
+ {http.MethodPost, ImpersonationPathChangePassword},
+ {http.MethodPost, ImpersonationPath2FASetup},
+ {http.MethodPost, ImpersonationPath2FAConfirm},
+ // Also spends the shared login 2FA budget: a PAT holder could keep the
+ // owner's TOTP sign-in at 429, and so keep them from revoking the token.
+ {http.MethodDelete, ImpersonationPath2FA},
+ {http.MethodPost, ImpersonationPathPasskeyBegin},
+ {http.MethodPost, ImpersonationPathPasskeyFinish},
+ {http.MethodDelete, ImpersonationPatternPasskey},
+ // A super-admin route that changes another user's sign-in email.
+ {http.MethodPatch, ImpersonationPatternSystemUser},
+ // A device grant for any org of the owner.
+ {http.MethodPost, ImpersonationPathDeviceConsent},
+ // External identity binding: a bound Discord identity signs in.
+ {http.MethodPost, ImpersonationPatternDiscordLink},
+ // Paging redirection to a chat the holder controls.
+ {http.MethodPost, ImpersonationPatternDiscordConnect},
+ {http.MethodPost, ImpersonationPatternTelegramLink},
+ // Impersonation mints a token acting as someone else.
+ {http.MethodPost, ImpersonationPatternImpersonate},
+}
+
+// IsPATAllowed reports whether a personal access token may perform this
+// request. Safe methods always pass.
+func IsPATAllowed(method, routePattern string) bool {
+ switch method {
+ case http.MethodGet, http.MethodHead, http.MethodOptions:
+ return true
+ }
+
+ for i := range patForbiddenRoutes {
+ if patForbiddenRoutes[i].method == method && patForbiddenRoutes[i].pattern == routePattern {
+ return false
+ }
+ }
+
+ return true
+}
+
+// PATForbiddenRoutes returns the denylist as (method, pattern) pairs, for the
+// route-table test in internal/app that proves every entry names a real route.
+func PATForbiddenRoutes() [][2]string {
+ out := make([][2]string, 0, len(patForbiddenRoutes))
+ for i := range patForbiddenRoutes {
+ out = append(out, [2]string{patForbiddenRoutes[i].method, patForbiddenRoutes[i].pattern})
+ }
+
+ return out
+}
+
+type patCredentialContextKey struct{}
+
+// WithPATCredential marks the request context as authenticated by a personal
+// access token. Called by RequireAuth. Its own key, like the impersonator, so
+// no later rewrite of the audit actor can drop it.
+func WithPATCredential(ctx context.Context) context.Context {
+ return context.WithValue(ctx, patCredentialContextKey{}, true)
+}
+
+// IsPATCredential reports whether the request authenticated with a personal
+// access token.
+func IsPATCredential(ctx context.Context) bool {
+ is, _ := ctx.Value(patCredentialContextKey{}).(bool)
+
+ return is
+}
diff --git a/server/internal/handlers/auth/service.go b/server/internal/handlers/auth/service.go
index 47d78f892..1717cc92f 100644
--- a/server/internal/handlers/auth/service.go
+++ b/server/internal/handlers/auth/service.go
@@ -5,7 +5,6 @@ import (
"context"
"crypto/rand"
"crypto/sha256"
- "crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
@@ -265,6 +264,10 @@ type Claims struct {
// 2026-10-16-12). Empty on every other token. It lets RequireAuth tell the
// first-party CLI client from a third-party one; see IsMCPCredential.
ClientID string `json:"clientId,omitempty"`
+ // pat marks claims produced by ValidatePATToken (spec 2026-10-28-01).
+ // Unexported, so it never appears in a signed JWT: no token can claim to
+ // be, or not to be, a PAT. See IsPAT and patForbiddenRoutes.
+ pat bool
jwt.RegisteredClaims
}
@@ -933,6 +936,13 @@ func (s *Service) startSession(
return ErrImpersonationForbidden
}
+ // Same rule for a personal access token (spec 2026-10-28-01): it is bound
+ // to one org, and a session would carry its holder into every org of the
+ // owner, with a refresh token that outlives the PAT's revocation.
+ if IsPATCredential(ctx) {
+ return ErrPATForbidden
+ }
+
if err := s.db.CreateUserToken(ctx, refreshToken); err != nil {
return err
}
@@ -1665,6 +1675,7 @@ func (s *Service) ValidatePATToken(ctx context.Context, patToken string) (*Claim
// published demo API key safe (spec 2026-09-06-02).
Demo: user.Demo,
Scopes: scopesFromProperties(token.Properties),
+ pat: true,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: expiresAt,
IssuedAt: jwt.NewNumericDate(token.CreatedAt),
@@ -2484,6 +2495,7 @@ const (
registrationKeyPrefix = "email_registration:"
registrationTTL = 3 * 24 * time.Hour
inviteKeyPrefix = "invite:"
+ keyInviteOrgUID = "orgUID"
passwordResetKeyPrefix = "password_reset:"
passwordResetCountKeyPrefix = "password_reset_count:"
passwordResetIPKeyPrefix = "pwd_reset_rl:"
@@ -3794,6 +3806,14 @@ func (s *Service) CreateInvitation(
return nil, fmt.Errorf("failed to store invitation: %w", storeErr)
}
+ // Global index entry, same key, no organization: the public invite
+ // routes resolve the token to its org through it in one lookup instead
+ // of probing every organization (spec 2026-10-28-03). Same TTL.
+ indexValue := &models.JSONMap{keyInviteOrgUID: org.UID}
+ if storeErr := s.db.SetStateEntry(ctx, nil, stateKey, indexValue, &ttl); storeErr != nil {
+ return nil, fmt.Errorf("failed to index invitation: %w", storeErr)
+ }
+
// Get the state entry to return its UID
entry, err := s.db.GetStateEntry(ctx, &org.UID, stateKey)
if err != nil || entry == nil {
@@ -3878,18 +3898,25 @@ func (s *Service) RevokeInvitation(ctx context.Context, orgSlug, invitationUID s
for _, entry := range entries {
if entry.UID == invitationUID {
- _, err := s.db.DeleteStateEntry(ctx, &org.UID, entry.Key)
-
- return err
+ return s.deleteInvite(ctx, org.UID, entry.Key)
}
}
return ErrInvitationNotFound
}
-// findInvite locates the live invitation a plaintext token refers to,
-// across every organization. It returns the matching entry (whose Key is
-// the one to delete on acceptance) and its org, or ErrInvitationNotFound.
+// findInvite locates the live invitation a plaintext token refers to. It
+// returns the matching org-scoped entry (whose Key is the one to delete on
+// acceptance) and its org, or ErrInvitationNotFound.
+//
+// The lookup goes through the global index entry CreateInvitation writes
+// (invite:, no organization, value {orgUID}), so it costs a fixed
+// number of queries whatever the organization count: the route is public,
+// and scanning every organization per request was a DoS (spec 2026-10-28-03).
+// Invites created before that spec have no index entry and are not found:
+// admins re-send them.
+//
+// A stored hash presented as the token hashes to another key and misses.
func (s *Service) findInvite(
ctx context.Context, token string,
) (*models.StateEntry, *models.Organization, error) {
@@ -3897,51 +3924,53 @@ func (s *Service) findInvite(
return nil, nil, ErrInvitationNotFound
}
- orgs, err := s.db.ListOrganizations(ctx)
+ key := inviteKeyPrefix + hashPendingToken(token)
+
+ index, err := s.db.GetStateEntry(ctx, nil, key)
if err != nil {
- return nil, nil, fmt.Errorf("failed to list organizations: %w", err)
+ return nil, nil, fmt.Errorf("failed to look up invitation: %w", err)
}
- hashedKey := inviteKeyPrefix + hashPendingToken(token)
- legacyKey := inviteKeyPrefix + token
+ if index == nil || index.Value == nil {
+ return nil, nil, ErrInvitationNotFound
+ }
- for _, org := range orgs {
- entry, getErr := s.db.GetStateEntry(ctx, &org.UID, hashedKey)
- if getErr == nil && entry != nil && entry.Value != nil {
- return entry, org, nil
- }
+ orgUID := stringFromMap(*index.Value, keyInviteOrgUID)
+ if orgUID == "" {
+ return nil, nil, ErrInvitationNotFound
+ }
- // Legacy fallback: invites created before spec 2026-09-26-02 are
- // keyed by the plaintext token and carry it in their value. They are
- // never rewritten, they just expire (max TTL 1 week).
- //
- // The value check is what keeps a hashed row from answering here:
- // presenting a stored hash as the token would otherwise hit
- // invite: — the new-style key — through this branch. Hashed
- // rows hold no token field, so they never match.
- //
- // TODO(remove after 2026-10-10): drop this branch once every
- // pre-hash invite has expired.
- entry, getErr = s.db.GetStateEntry(ctx, &org.UID, legacyKey)
- if getErr == nil && entry != nil && entry.Value != nil && legacyInviteTokenMatches(entry, token) {
- return entry, org, nil
+ entry, err := s.db.GetStateEntry(ctx, &orgUID, key)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get invitation: %w", err)
+ }
+
+ if entry == nil || entry.Value == nil {
+ return nil, nil, ErrInvitationNotFound
+ }
+
+ org, err := s.db.GetOrganization(ctx, orgUID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil, ErrInvitationNotFound
}
+
+ return nil, nil, fmt.Errorf("failed to get organization: %w", err)
}
- return nil, nil, ErrInvitationNotFound
+ return entry, org, nil
}
-// legacyInviteTokenMatches reports whether a legacy (plaintext-keyed) invite
-// row stores exactly the presented token in its value.
-//
-// TODO(remove after 2026-10-10): goes with the legacy branch of findInvite.
-func legacyInviteTokenMatches(entry *models.StateEntry, token string) bool {
- stored, ok := (*entry.Value)[keyToken].(string)
- if !ok || stored == "" {
- return false
+// deleteInvite consumes or revokes an invitation: its org-scoped entry and
+// the global index entry findInvite resolves it through share the key.
+func (s *Service) deleteInvite(ctx context.Context, orgUID, key string) error {
+ if _, err := s.db.DeleteStateEntry(ctx, &orgUID, key); err != nil {
+ return err
}
- return subtle.ConstantTimeCompare([]byte(stored), []byte(token)) == 1
+ _, err := s.db.DeleteStateEntry(ctx, nil, key)
+
+ return err
}
// GetInviteInfo returns public information about an invitation.
@@ -3971,9 +4000,8 @@ func (s *Service) GetInviteInfo(ctx context.Context, token string) (*InviteInfoR
//
//nolint:cyclop,funlen,gocritic // multiple steps; req is the decoded wire shape, passed by value
func (s *Service) AcceptInvite(ctx context.Context, req AcceptInviteRequest, callerUID string) (*LoginResponse, error) {
- // Find the invitation across orgs. matchedEntry.Key is the key that
- // actually matched (hashed, or legacy plaintext), so the deletes below
- // must use it rather than recompute one.
+ // Find the invitation through its global index entry. matchedEntry.Key
+ // is the key the deletes below must use.
matchedEntry, matchedOrg, err := s.findInvite(ctx, req.Token)
if err != nil {
return nil, err
@@ -4022,7 +4050,7 @@ func (s *Service) AcceptInvite(ctx context.Context, req AcceptInviteRequest, cal
_, err = s.db.GetMemberByUserAndOrg(ctx, user.UID, matchedOrg.UID)
if err == nil {
// Already a member, just clean up and login
- _, _ = s.db.DeleteStateEntry(ctx, &matchedOrg.UID, matchedEntry.Key)
+ _ = s.deleteInvite(ctx, matchedOrg.UID, matchedEntry.Key)
} else {
// Enforce the MaxUsers cap before adding this member. Invitation
// acceptance is a membership-creation path, so it must honor the
@@ -4054,7 +4082,7 @@ func (s *Service) AcceptInvite(ctx context.Context, req AcceptInviteRequest, cal
})
// Delete the invitation
- _, _ = s.db.DeleteStateEntry(ctx, &matchedOrg.UID, matchedEntry.Key)
+ _ = s.deleteInvite(ctx, matchedOrg.UID, matchedEntry.Key)
}
// Auto-join matching orgs for new users
diff --git a/server/internal/handlers/base/base.go b/server/internal/handlers/base/base.go
index 963a951ed..8be50086e 100644
--- a/server/internal/handlers/base/base.go
+++ b/server/internal/handlers/base/base.go
@@ -134,6 +134,12 @@ const (
// impersonate again. Distinct from ErrorCodeForbidden so the dashboard can
// explain it rather than render a dead "Permission denied".
ErrorCodeImpersonationForbidden ErrorCode = "IMPERSONATION_FORBIDDEN"
+ // ErrorCodePATForbidden accompanies the 403 a personal access token gets
+ // for anything outside its org binding (spec 2026-10-28-01): opening a
+ // session, approving a device grant, or changing a sign-in factor. The
+ // CLI reads it to tell the user to create a token in the target org or
+ // to log in interactively.
+ ErrorCodePATForbidden ErrorCode = "PAT_FORBIDDEN"
)
// ContextKey is the type used for context keys in middleware and handlers.
diff --git a/server/internal/handlers/checks/snmp_community_secret_test.go b/server/internal/handlers/checks/snmp_community_secret_test.go
new file mode 100644
index 000000000..e3e16efcf
--- /dev/null
+++ b/server/internal/handlers/checks/snmp_community_secret_test.go
@@ -0,0 +1,57 @@
+package checks_test
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/fclairamb/solidping/server/internal/handlers/checks"
+)
+
+// TestSNMPCommunityIsSecret: the community string never reaches the public
+// column, the API response or the export, and survives an unrelated edit.
+func TestSNMPCommunityIsSecret(t *testing.T) {
+ t.Parallel()
+
+ r := require.New(t)
+ svc, dbSvc, creds, org := setupEncryptedChecksServiceCreds(t)
+ ctx := t.Context()
+
+ created, err := svc.CreateCheck(ctx, org.Slug, checks.CreateCheckRequest{
+ Name: "snmp", Slug: "snmp", Type: "snmp",
+ Config: map[string]any{
+ "host": "10.0.0.1", "oid": "1.3.6.1.2.1.1.3.0", "community": "s3cr3t-acme",
+ },
+ })
+ r.NoError(err)
+ r.NotContains(created.Config, "community")
+ r.Contains(created.ConfigPrivateKeys, "community")
+
+ got, err := svc.GetCheck(ctx, org.Slug, created.UID, checks.GetCheckOptions{})
+ r.NoError(err)
+ r.NotContains(got.Config, "community")
+ r.Contains(got.ConfigPrivateKeys, "community")
+
+ row, err := dbSvc.GetCheck(ctx, org.UID, created.UID)
+ r.NoError(err)
+ rawCfg, err := json.Marshal(row.Config)
+ r.NoError(err)
+ r.NotContains(string(rawCfg), "s3cr3t-acme")
+ r.Equal("s3cr3t-acme", decryptPrivate(t, creds, row)["community"])
+
+ doc, err := svc.ExportChecks(ctx, org.Slug, checks.ListChecksOptions{})
+ r.NoError(err)
+ exported, err := json.Marshal(doc)
+ r.NoError(err)
+ r.NotContains(string(exported), "s3cr3t-acme")
+
+ // Unrelated edit that does not resend the community keeps it.
+ cfg := map[string]any{"host": "10.0.0.2", "oid": "1.3.6.1.2.1.1.3.0"}
+ _, err = svc.UpdateCheck(ctx, org.Slug, created.UID, &checks.UpdateCheckRequest{Config: &cfg})
+ r.NoError(err)
+
+ after, err := dbSvc.GetCheck(ctx, org.UID, created.UID)
+ r.NoError(err)
+ r.Equal("s3cr3t-acme", decryptPrivate(t, creds, after)["community"])
+}
diff --git a/server/internal/handlers/emailpreview/fixtures.go b/server/internal/handlers/emailpreview/fixtures.go
index 5d7e617e2..c72988cfe 100644
--- a/server/internal/handlers/emailpreview/fixtures.go
+++ b/server/internal/handlers/emailpreview/fixtures.go
@@ -82,6 +82,7 @@ var fixtureBuilders = map[string]func(i18n.Locale) map[string]any{
"password-changed.html": passwordChangedFixture,
"email-changed.html": emailChangedFixture,
"device-token-created.html": deviceTokenCreatedFixture,
+ "passkey-added.html": passkeyAddedFixture,
"membership-request-new.html": membershipRequestNewFixture,
"membership-request-decision.html": membershipRequestDecisionFixture,
"uptime-report.html": uptimeReportFixture,
diff --git a/server/internal/handlers/emailpreview/fixtures_account.go b/server/internal/handlers/emailpreview/fixtures_account.go
index 81699015b..66d4cf9ce 100644
--- a/server/internal/handlers/emailpreview/fixtures_account.go
+++ b/server/internal/handlers/emailpreview/fixtures_account.go
@@ -70,6 +70,14 @@ func deviceTokenCreatedFixture(_ i18n.Locale) map[string]any {
}
}
+func passkeyAddedFixture(_ i18n.Locale) map[string]any {
+ return map[string]any{
+ "PasskeyName": "iCloud Keychain",
+ "CreatedAt": fixtureChangedAt(),
+ "SecurityURL": fixtureDashboardURL + "/orgs/acme/account/security",
+ }
+}
+
func invitationFixture(loc i18n.Locale) map[string]any {
return map[string]any{
keyOrgName: fixtureOrgName,
diff --git a/server/internal/handlers/emailpreview/locale_account_test.go b/server/internal/handlers/emailpreview/locale_account_test.go
index 06a15baf2..a34829f7d 100644
--- a/server/internal/handlers/emailpreview/locale_account_test.go
+++ b/server/internal/handlers/emailpreview/locale_account_test.go
@@ -20,7 +20,7 @@ import (
//nolint:gochecknoglobals // read-only test table.
var accountTemplates = []string{
"registration.html", "welcome.html", "password-reset.html", "password-reset-sso.html",
- "password-changed.html", "email-changed.html", "device-token-created.html", "invitation.html",
+ "password-changed.html", "email-changed.html", "device-token-created.html", "passkey-added.html", "invitation.html",
"membership-request-new.html", "membership-request-decision.html",
}
@@ -130,6 +130,24 @@ func TestAccountMail_EveryLocale(t *testing.T) {
i18n.LangGerman: {"Ihr SolidPing-Passwort wurde geändert", "am 5. Juli, 10:00 UTC geändert wurde."},
i18n.LangSpanish: {"Se ha cambiado tu contraseña de SolidPing", "se cambió el 5 jul, 10:00 UTC."},
},
+ "passkey-added.html": {
+ i18n.LangEnglish: {
+ "A passkey was added to your SolidPing account",
+ "It signs in to every organization of the account.",
+ },
+ i18n.LangFrench: {
+ "Une clé d'accès a été ajoutée à votre compte SolidPing",
+ "Une clé d'accès nommée « iCloud Keychain » a été ajoutée",
+ },
+ i18n.LangGerman: {
+ "Ihrem SolidPing-Konto wurde ein Passkey hinzugefügt",
+ "ein Passkey mit dem Namen „iCloud Keychain“ hinzugefügt",
+ },
+ i18n.LangSpanish: {
+ "Se ha añadido una llave de acceso a tu cuenta de SolidPing",
+ "una llave de acceso llamada «iCloud Keychain»",
+ },
+ },
"device-token-created.html": {
i18n.LangEnglish: {
"A device was signed in to your SolidPing account",
diff --git a/server/internal/handlers/statussubscribers/delivery_test.go b/server/internal/handlers/statussubscribers/delivery_test.go
index de52d2688..35835f4cd 100644
--- a/server/internal/handlers/statussubscribers/delivery_test.go
+++ b/server/internal/handlers/statussubscribers/delivery_test.go
@@ -350,14 +350,14 @@ func TestPublicSubscribeStaysEmailOnly(t *testing.T) {
s := newSubSetup(t)
channel := "webhook"
- _, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, &statussubscribers.SubscribeRequest{
+ _, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, "", &statussubscribers.SubscribeRequest{
Email: "someone@example.com", Channel: &channel,
})
r.ErrorIs(err, statussubscribers.ErrPublicChannelNotAllowed)
// Positive control: the email channel still works from the public path.
email := "email"
- _, err = s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, &statussubscribers.SubscribeRequest{
+ _, err = s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, "", &statussubscribers.SubscribeRequest{
Email: "someone@example.com", Channel: &email,
})
r.NoError(err)
diff --git a/server/internal/handlers/statussubscribers/handler.go b/server/internal/handlers/statussubscribers/handler.go
index 06df403aa..2b9fceb82 100644
--- a/server/internal/handlers/statussubscribers/handler.go
+++ b/server/internal/handlers/statussubscribers/handler.go
@@ -68,8 +68,21 @@ func (h *Handler) baseURL() string {
return "http://localhost:4000"
}
+// clientIP resolves the caller's IP with the rate limiter's trusted-proxy
+// rules, never the spoofable raw X-Forwarded-For.
+func (h *Handler) clientIP(req *http.Request) string {
+ trusted := 0
+ if h.cfg != nil {
+ trusted = h.cfg.Server.RateLimiting.TrustedProxies
+ }
+
+ return base.ExtractTrustedClientIP(req, trusted)
+}
+
// Subscribe handles POST /api/v1/orgs/:org/status-pages/:statusPageUid/subscribers.
-// Public, no auth. Always returns 202 on success and sends a confirm mail.
+// Public, no auth. Always returns 202 on success. The confirm mail goes out
+// only when the service says one is due: never for an already-confirmed
+// address, never once a budget is spent (spec 2026-10-28-02).
func (h *Handler) Subscribe(writer http.ResponseWriter, req *http.Request) error {
orgSlug := httpx.Param(req, "org")
statusPageUID := httpx.Param(req, "statusPageUid")
@@ -81,12 +94,14 @@ func (h *Handler) Subscribe(writer http.ResponseWriter, req *http.Request) error
})
}
- result, err := h.svc.Subscribe(req.Context(), orgSlug, statusPageUID, &body)
+ result, err := h.svc.Subscribe(req.Context(), orgSlug, statusPageUID, h.clientIP(req), &body)
if err != nil {
return h.handleError(writer, req, err)
}
- h.sendConfirmMail(req.Context(), result)
+ if result.NeedsConfirmMail() {
+ h.sendConfirmMail(req.Context(), result)
+ }
return h.WriteJSON(writer, http.StatusAccepted, map[string]any{
respKeyData: map[string]any{
diff --git a/server/internal/handlers/statussubscribers/notifier_test.go b/server/internal/handlers/statussubscribers/notifier_test.go
index 573357a86..1616742ca 100644
--- a/server/internal/handlers/statussubscribers/notifier_test.go
+++ b/server/internal/handlers/statussubscribers/notifier_test.go
@@ -242,14 +242,14 @@ func TestSubscribingToALockedPageRequiresTheUnlock(t *testing.T) {
r.NoError(s.dbSvc.UpdateStatusPage(ctx, s.page.UID,
&models.StatusPageUpdate{Visibility: &visibility, PasswordHash: &hash}))
- _, err := s.svc.Subscribe(ctx, s.org.Slug, s.page.UID,
+ _, err := s.svc.Subscribe(ctx, s.org.Slug, s.page.UID, "",
&statussubscribers.SubscribeRequest{Email: "stranger@example.com"})
r.ErrorIs(err, statuspagelock.ErrLocked)
// Positive control: with a grant on the context (what the router installs
// from a valid unlock cookie) the very same call succeeds.
granted := statuspagelock.WithGrant(ctx, func(*models.StatusPage) bool { return true })
- _, err = s.svc.Subscribe(granted, s.org.Slug, s.page.UID,
+ _, err = s.svc.Subscribe(granted, s.org.Slug, s.page.UID, "",
&statussubscribers.SubscribeRequest{Email: "stranger@example.com"})
r.NoError(err)
}
diff --git a/server/internal/handlers/statussubscribers/service.go b/server/internal/handlers/statussubscribers/service.go
index e532afcab..34df66507 100644
--- a/server/internal/handlers/statussubscribers/service.go
+++ b/server/internal/handlers/statussubscribers/service.go
@@ -7,13 +7,16 @@ package statussubscribers
import (
"context"
"crypto/rand"
+ "crypto/sha256"
"database/sql"
"encoding/base64"
+ "encoding/hex"
"errors"
"net/mail"
"strings"
"time"
+ "github.com/fclairamb/solidping/server/internal/config"
"github.com/fclairamb/solidping/server/internal/crypto/credentials"
"github.com/fclairamb/solidping/server/internal/db"
"github.com/fclairamb/solidping/server/internal/db/models"
@@ -64,6 +67,9 @@ type Service struct {
// than failing at every delivery. nil means "no policy": every https URL
// is accepted (tests that do not exercise the policy).
guard *egress.Guard
+ // budget caps the double opt-in mails of the public subscribe endpoint
+ // (spec 2026-10-28-02). Defaults to config.DefaultStatusSubscribersConfig.
+ budget config.StatusSubscribersConfig
}
// Option configures the subscribers service.
@@ -78,9 +84,23 @@ func WithEgressGuard(guard *egress.Guard) Option {
return func(s *Service) { s.guard = guard }
}
+// WithConfirmBudget sets the per-recipient, per-page and per-IP hourly budgets
+// of the confirm mail. A budget of 0 is not enforced.
+func WithConfirmBudget(budget config.StatusSubscribersConfig) Option {
+ return func(s *Service) { s.budget = budget }
+}
+
+// confirmBudgetWindow is the sliding window every confirm-mail budget counts on.
+const confirmBudgetWindow = time.Hour
+
+// confirmCounterPrefix namespaces the confirm-mail counters in the global
+// state entries. The recipient key carries a hash of the address, never the
+// address itself (PII).
+const confirmCounterPrefix = "status_subscriber_confirm:"
+
// NewService creates a new status subscribers service.
func NewService(dbService db.Service, creds credentials.Service, opts ...Option) *Service {
- svc := &Service{db: dbService, creds: creds}
+ svc := &Service{db: dbService, creds: creds, budget: config.DefaultStatusSubscribersConfig()}
for _, opt := range opts {
opt(svc)
}
@@ -164,7 +184,19 @@ type SubscribeResult struct {
// update fan-out: the recipient is subscribing to a page.
PageLogoURL *string
PageHideBranding bool
+ // AlreadyConfirmed: the address already holds a live, confirmed
+ // subscription. Nothing changed and no mail is due.
AlreadyConfirmed bool
+ // Throttled: a confirm-mail budget is spent. Nothing changed and no mail
+ // is due. Subscriber is nil when no row existed.
+ Throttled bool
+}
+
+// NeedsConfirmMail reports whether the caller must send the confirm mail.
+// The public endpoint answers the same 202 either way, so it reveals neither
+// whether an address is subscribed nor whether a budget is spent.
+func (r *SubscribeResult) NeedsConfirmMail() bool {
+ return r != nil && r.Subscriber != nil && !r.AlreadyConfirmed && !r.Throttled
}
// generateToken returns a URL-safe, unguessable opaque token.
@@ -191,32 +223,28 @@ func normalizeEmail(raw string) (string, error) {
return email, nil
}
-// Subscribe starts a (re)subscription. The returned subscriber is always
-// unconfirmed with a fresh confirm token — a confirm mail must be sent. If a
-// live unconfirmed/confirmed row already exists it is reused/soft-undeleted
-// rather than duplicated.
-//
-//nolint:cyclop // validation of optional scope/incident fields requires several branches
-func (s *Service) Subscribe(
- ctx context.Context, orgSlug, statusPageUID string, req *SubscribeRequest,
-) (*SubscribeResult, error) {
+// parseSubscribeRequest validates a public subscribe body: email channel only,
+// a well-formed address, and a known scope with its incident when required.
+func parseSubscribeRequest(
+ req *SubscribeRequest,
+) (string, models.SubscriberScope, *string, error) {
// Public self-serve is email-only, explicitly. The channel field is
// accepted so a mistaken client gets a clear error rather than silently
// creating an email subscription it did not ask for.
if req.Channel != nil && *req.Channel != "" && *req.Channel != string(models.SubscriberChannelEmail) {
- return nil, ErrPublicChannelNotAllowed
+ return "", "", nil, ErrPublicChannelNotAllowed
}
email, err := normalizeEmail(req.Email)
if err != nil {
- return nil, err
+ return "", "", nil, err
}
scope := models.SubscriberScopePage
if req.Scope != nil && *req.Scope != "" {
scope = models.SubscriberScope(*req.Scope)
if !scope.IsValid() {
- return nil, ErrInvalidScope
+ return "", "", nil, ErrInvalidScope
}
}
@@ -224,24 +252,32 @@ func (s *Service) Subscribe(
if scope == models.SubscriberScopeIncident {
if req.IncidentUID == nil || *req.IncidentUID == "" {
- return nil, ErrIncidentRequired
+ return "", "", nil, ErrIncidentRequired
}
incidentUID = req.IncidentUID
}
+ return email, scope, incidentUID, nil
+}
+
+// resolveSubscribePage finds the org and the public, unlocked status page a
+// visitor subscribes to, and checks the incident of an incident-scoped one.
+func (s *Service) resolveSubscribePage(
+ ctx context.Context, orgSlug, statusPageUID string, incidentUID *string,
+) (*models.Organization, *models.StatusPage, error) {
org, err := s.db.GetOrganizationBySlug(ctx, orgSlug)
if err != nil {
- return nil, ErrOrganizationNotFound
+ return nil, nil, ErrOrganizationNotFound
}
page, err := s.db.GetStatusPage(ctx, org.UID, statusPageUID)
if err != nil || page == nil {
- return nil, ErrStatusPageNotFound
+ return nil, nil, ErrStatusPageNotFound
}
if !statuspagelock.Visible(page) {
- return nil, ErrStatusPageNotFound
+ return nil, nil, ErrStatusPageNotFound
}
// The subscribe FORM sits behind the unlock — a page shared with a
@@ -250,15 +286,80 @@ func (s *Service) Subscribe(
// notifier.go never consults visibility, so locking a page down does not
// silently stop the notifications people already opted into.
if !statuspagelock.Allows(ctx, page) {
- return nil, statuspagelock.ErrLocked
+ return nil, nil, statuspagelock.ErrLocked
}
if incidentUID != nil {
if _, incErr := s.db.GetIncident(ctx, org.UID, *incidentUID); incErr != nil {
- return nil, ErrIncidentNotFound
+ return nil, nil, ErrIncidentNotFound
}
}
+ return org, page, nil
+}
+
+// Subscribe starts a (re)subscription on behalf of clientIP (trusted-proxy
+// resolution; "" skips the per-IP budget). A new, unconfirmed or soft-deleted
+// row gets a fresh confirm token and NeedsConfirmMail reports true. A live
+// confirmed subscription is left untouched (AlreadyConfirmed), and so is
+// everything once a confirm-mail budget is spent (Throttled).
+func (s *Service) Subscribe(
+ ctx context.Context, orgSlug, statusPageUID, clientIP string, req *SubscribeRequest,
+) (*SubscribeResult, error) {
+ email, scope, incidentUID, err := parseSubscribeRequest(req)
+ if err != nil {
+ return nil, err
+ }
+
+ org, page, err := s.resolveSubscribePage(ctx, orgSlug, statusPageUID, incidentUID)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &SubscribeResult{
+ PageName: page.Name,
+ PageSlug: page.Slug,
+ OrgSlug: org.Slug,
+ PageLogoURL: statuspageassets.PublicURL(page.Settings.LogoFileUID()),
+ PageHideBranding: page.Settings.HideBranding(),
+ }
+
+ // Match any prior row for this tuple (including soft-deleted) so a returning
+ // visitor revives their existing subscription rather than creating a
+ // duplicate that would collide with the live unique index once confirmed.
+ existing, err := s.db.FindAnySubscriber(ctx, page.UID, email, scope, incidentUID)
+ if err != nil {
+ if !errors.Is(err, sql.ErrNoRows) {
+ return nil, err
+ }
+
+ existing = nil
+ }
+
+ // A live, confirmed subscription stays exactly as it is: re-subscribing it
+ // would null confirmed_at and rotate the unsubscribe token, letting anyone
+ // who knows an address silently stop its notifications.
+ if existing != nil && existing.DeletedAt == nil && existing.ConfirmedAt != nil {
+ result.Subscriber = existing
+ result.AlreadyConfirmed = true
+
+ return result, nil
+ }
+
+ // Over budget the row is left untouched too: rotating its confirm token
+ // without mailing the new one would void the links already sent.
+ allowed, err := s.spendConfirmBudget(ctx, page.UID, email, clientIP)
+ if err != nil {
+ return nil, err
+ }
+
+ if !allowed {
+ result.Subscriber = existing
+ result.Throttled = true
+
+ return result, nil
+ }
+
confirmToken, err := generateToken()
if err != nil {
return nil, err
@@ -270,52 +371,77 @@ func (s *Service) Subscribe(
}
sub, err := s.upsertSubscriber(
- ctx, org.UID, page.UID, email, scope, incidentUID, confirmToken, unsubToken)
+ ctx, existing, org.UID, page.UID, email, scope, incidentUID, confirmToken, unsubToken)
if err != nil {
return nil, err
}
- return &SubscribeResult{
- Subscriber: sub,
- PageName: page.Name,
- PageSlug: page.Slug,
- OrgSlug: org.Slug,
- PageLogoURL: statuspageassets.PublicURL(page.Settings.LogoFileUID()),
- PageHideBranding: page.Settings.HideBranding(),
- }, nil
+ result.Subscriber = sub
+
+ return result, nil
+}
+
+// spendConfirmBudget counts one confirm mail against the per-client-IP,
+// per-recipient and per-page budgets, in that order, and reports whether all
+// of them still had room. The first spent budget stops the count, so a
+// refused attempt does not eat into the later budgets. clientIP "" skips the
+// IP budget.
+func (s *Service) spendConfirmBudget(ctx context.Context, pageUID, email, clientIP string) (bool, error) {
+ recipientHash := sha256.Sum256([]byte(email))
+
+ budgets := []struct {
+ key string
+ limit int
+ }{
+ {confirmCounterPrefix + "ip:" + clientIP, s.budget.ConfirmPerIPPerHour},
+ {confirmCounterPrefix + "rcpt:" + hex.EncodeToString(recipientHash[:]), s.budget.ConfirmPerRecipientPerHour},
+ {confirmCounterPrefix + "page:" + pageUID, s.budget.ConfirmPerPagePerHour},
+ }
+
+ for i, budget := range budgets {
+ if budget.limit <= 0 || (i == 0 && clientIP == "") {
+ continue
+ }
+
+ limited, err := s.db.BumpGlobalCounter(ctx, budget.key, budget.limit, confirmBudgetWindow)
+ if err != nil {
+ return false, err
+ }
+
+ if limited {
+ return false, nil
+ }
+ }
+
+ return true, nil
}
-// upsertSubscriber reuses an existing live row (refreshing tokens + resetting
-// confirmation) or inserts a new unconfirmed row.
+// upsertSubscriber revives existing (soft-deleted or unconfirmed: refreshed
+// tokens, confirmation reset) or, when it is nil, inserts a new unconfirmed row.
func (s *Service) upsertSubscriber(
ctx context.Context,
+ existing *models.StatusPageSubscriber,
orgUID, pageUID, email string,
scope models.SubscriberScope,
incidentUID *string,
confirmToken, unsubToken string,
) (*models.StatusPageSubscriber, error) {
- // Match any prior row for this tuple (including soft-deleted) so a returning
- // visitor revives their existing subscription rather than creating a
- // duplicate that would collide with the live unique index once confirmed.
- existing, err := s.db.FindAnySubscriber(ctx, pageUID, email, scope, incidentUID)
- if err == nil && existing != nil {
+ if existing != nil {
// Reuse the row: soft-undelete, refresh tokens, require re-confirmation.
if reErr := s.db.ResubscribeSubscriber(ctx, existing.UID, confirmToken, unsubToken); reErr != nil {
return nil, reErr
}
+ sentAt := nowUTC()
existing.ConfirmToken = confirmToken
existing.UnsubscribeToken = unsubToken
+ existing.ConfirmSentAt = &sentAt
existing.ConfirmedAt = nil
existing.DeletedAt = nil
return existing, nil
}
- if err != nil && !errors.Is(err, sql.ErrNoRows) {
- return nil, err
- }
-
sub := models.NewStatusPageSubscriber(orgUID, pageUID, email, scope)
sub.IncidentUID = incidentUID
sub.ConfirmToken = confirmToken
@@ -351,6 +477,12 @@ func (s *Service) Confirm(ctx context.Context, token string) (*ConfirmResult, er
return nil, err
}
+ // An unconfirmed row past its confirm TTL is refused like an unknown
+ // token; the state-cleanup job reaps it.
+ if sub.ConfirmExpired(nowUTC()) {
+ return nil, ErrInvalidToken
+ }
+
if err := s.db.ConfirmSubscriber(ctx, sub.UID, nowUTC()); err != nil {
return nil, err
}
diff --git a/server/internal/handlers/statussubscribers/service_test.go b/server/internal/handlers/statussubscribers/service_test.go
index 07f4c079d..682613a8b 100644
--- a/server/internal/handlers/statussubscribers/service_test.go
+++ b/server/internal/handlers/statussubscribers/service_test.go
@@ -49,7 +49,7 @@ func newSubSetup(t *testing.T) *subSetup {
func (s *subSetup) subscribe(t *testing.T, email string) *statussubscribers.SubscribeResult {
t.Helper()
- res, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID,
+ res, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, "",
&statussubscribers.SubscribeRequest{Email: email})
require.NoError(t, err)
@@ -87,7 +87,7 @@ func TestSubscribeInvalidEmail(t *testing.T) {
r := require.New(t)
s := newSubSetup(t)
- _, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID,
+ _, err := s.svc.Subscribe(t.Context(), s.org.Slug, s.page.UID, "",
&statussubscribers.SubscribeRequest{Email: "not-an-email"})
r.ErrorIs(err, statussubscribers.ErrInvalidEmail)
}
@@ -247,7 +247,7 @@ func TestSubscribeUnknownPage(t *testing.T) {
r := require.New(t)
s := newSubSetup(t)
- _, err := s.svc.Subscribe(t.Context(), s.org.Slug, "no-such-page",
+ _, err := s.svc.Subscribe(t.Context(), s.org.Slug, "no-such-page", "",
&statussubscribers.SubscribeRequest{Email: "x@example.com"})
r.ErrorIs(err, statussubscribers.ErrStatusPageNotFound)
}
diff --git a/server/internal/handlers/statussubscribers/subscribe_abuse_test.go b/server/internal/handlers/statussubscribers/subscribe_abuse_test.go
new file mode 100644
index 000000000..4332cafa1
--- /dev/null
+++ b/server/internal/handlers/statussubscribers/subscribe_abuse_test.go
@@ -0,0 +1,363 @@
+package statussubscribers_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "github.com/uptrace/bun"
+
+ "github.com/fclairamb/solidping/server/internal/config"
+ "github.com/fclairamb/solidping/server/internal/db"
+ "github.com/fclairamb/solidping/server/internal/db/models"
+ "github.com/fclairamb/solidping/server/internal/db/postgres"
+ "github.com/fclairamb/solidping/server/internal/db/sqlite"
+ "github.com/fclairamb/solidping/server/internal/email"
+ "github.com/fclairamb/solidping/server/internal/handlers/statussubscribers"
+ "github.com/fclairamb/solidping/server/internal/httpx"
+ "github.com/fclairamb/solidping/server/internal/jobs/jobdef"
+ "github.com/fclairamb/solidping/server/internal/jobs/jobtypes"
+ "github.com/fclairamb/solidping/server/internal/testsupport"
+)
+
+// portSubscribeAbusePG is distinct from every other embedded-Postgres port
+// claimed in the repo (see the port-numbering note in
+// internal/db/incident_number_test.go).
+const portSubscribeAbusePG = 15674
+
+// abuseFixture is one org + public page behind the real Subscribe handler, on
+// either engine (spec 2026-10-28-02).
+type abuseFixture struct {
+ svc *statussubscribers.Service
+ dbSvc db.Service
+ bunDB *bun.DB
+ router *httpx.Router
+ sender *fakeSender
+ org *models.Organization
+ page *models.StatusPage
+ // suffix keeps addresses unique across the subtests sharing one database:
+ // the per-recipient budget is instance-wide.
+ suffix string
+ // clientIP is the caller address of every request this fixture sends.
+ clientIP string
+}
+
+type abuseEngine struct {
+ dbSvc db.Service
+ bunDB *bun.DB
+ seq atomic.Int64
+}
+
+func (e *abuseEngine) fixture(t *testing.T, budget config.StatusSubscribersConfig) *abuseFixture {
+ t.Helper()
+
+ ctx := t.Context()
+ r := require.New(t)
+ n := e.seq.Add(1)
+ suffix := strconv.FormatInt(n, 10) + "-" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 10)
+
+ org := models.NewOrganization("abuse-"+suffix, "Acme "+suffix)
+ r.NoError(e.dbSvc.CreateOrganization(ctx, org))
+
+ page := models.NewStatusPage(org.UID, "Acme Status", "acme-status-"+suffix)
+ r.NoError(e.dbSvc.CreateStatusPage(ctx, page))
+
+ svc := statussubscribers.NewService(e.dbSvc, nil, statussubscribers.WithConfirmBudget(budget))
+
+ formatter, err := email.NewFormatter()
+ r.NoError(err)
+
+ cfg := &config.Config{}
+ cfg.Server.BaseURL = "https://status.example.com"
+ sender := &fakeSender{}
+ handler := statussubscribers.NewHandler(svc, e.dbSvc, sender, formatter, cfg)
+
+ router := httpx.New()
+ router.POST("/api/v1/orgs/:org/status-pages/:statusPageUid/subscribers", handler.Subscribe)
+
+ return &abuseFixture{
+ svc: svc, dbSvc: e.dbSvc, bunDB: e.bunDB, router: router, sender: sender,
+ org: org, page: page, suffix: suffix,
+ clientIP: fmt.Sprintf("198.51.%d.%d", (n/250)%250, n%250+1),
+ }
+}
+
+func (f *abuseFixture) addr(local string) string {
+ return local + "-" + f.suffix + "@acme.com"
+}
+
+// subscribeHTTP posts to the public endpoint and asserts the uniform 202.
+func (f *abuseFixture) subscribeHTTP(t *testing.T, address string) {
+ t.Helper()
+
+ body, err := json.Marshal(map[string]any{"email": address})
+ require.NoError(t, err)
+
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
+ "/api/v1/orgs/"+f.org.Slug+"/status-pages/"+f.page.UID+"/subscribers", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.RemoteAddr = f.clientIP + ":4321"
+
+ rec := httptest.NewRecorder()
+ f.router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusAccepted, rec.Code, rec.Body.String())
+ require.Contains(t, rec.Body.String(), "pending_confirmation")
+}
+
+func (f *abuseFixture) row(t *testing.T, address string) *models.StatusPageSubscriber {
+ t.Helper()
+
+ sub, err := f.dbSvc.FindAnySubscriber(t.Context(), f.page.UID, address, models.SubscriberScopePage, nil)
+ require.NoError(t, err)
+
+ return sub
+}
+
+func (f *abuseFixture) mailsTo(address string) int {
+ count := 0
+
+ for _, msg := range f.sender.sent() {
+ if len(msg.Recipients.To) == 1 && msg.Recipients.To[0] == address {
+ count++
+ }
+ }
+
+ return count
+}
+
+// age backdates a row's confirm token (and creation) by `by`.
+func (f *abuseFixture) age(t *testing.T, uid string, by time.Duration) {
+ t.Helper()
+
+ old := time.Now().UTC().Add(-by)
+ _, err := f.bunDB.NewUpdate().
+ Model((*models.StatusPageSubscriber)(nil)).
+ Set("confirm_sent_at = ?", old).
+ Set("created_at = ?", old).
+ Where("uid = ?", uid).
+ Exec(t.Context())
+ require.NoError(t, err)
+}
+
+func defaultBudget() config.StatusSubscribersConfig {
+ return config.DefaultStatusSubscribersConfig()
+}
+
+func runSubscribeAbuseContract(t *testing.T, engine *abuseEngine) {
+ t.Helper()
+
+ t.Run("re-subscribing a confirmed address leaves it confirmed and mails nothing", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ f := engine.fixture(t, defaultBudget())
+ address := f.addr("ops")
+
+ f.subscribeHTTP(t, address)
+ _, err := f.svc.Confirm(t.Context(), f.row(t, address).ConfirmToken)
+ r.NoError(err)
+
+ before := f.row(t, address)
+ r.NotNil(before.ConfirmedAt)
+
+ f.subscribeHTTP(t, address)
+
+ after := f.row(t, address)
+ r.NotNil(after.ConfirmedAt, "a repeat subscribe must not un-confirm the subscriber")
+ r.Equal(before.UnsubscribeToken, after.UnsubscribeToken, "the unsubscribe links already mailed must keep working")
+ r.Equal(before.ConfirmToken, after.ConfirmToken)
+ r.Equal(1, f.mailsTo(address), "only the first subscribe mails a confirm link")
+
+ confirmed, err := f.dbSvc.ListConfirmedSubscribers(t.Context(), f.page.UID, nil)
+ r.NoError(err)
+ r.Len(confirmed, 1, "the subscriber still receives the fan-out")
+ })
+
+ t.Run("ten subscribes of one new address mail at most the per-recipient budget", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ f := engine.fixture(t, defaultBudget())
+ address := f.addr("victim")
+
+ for range 10 {
+ f.subscribeHTTP(t, address)
+ }
+
+ r.Equal(config.DefaultStatusSubscriberConfirmPerRecipientPerHour, f.mailsTo(address))
+
+ // A throttled call leaves the row alone, so the last link mailed
+ // still confirms.
+ lastMail := f.sender.sent()[len(f.sender.sent())-1]
+ token := f.row(t, address).ConfirmToken
+ r.Contains(lastMail.HTML, token)
+ _, err := f.svc.Confirm(t.Context(), token)
+ r.NoError(err)
+ })
+
+ t.Run("the per-recipient budget spans every page", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ first := engine.fixture(t, defaultBudget())
+ second := engine.fixture(t, defaultBudget())
+ address := first.addr("spread")
+
+ for range 2 {
+ first.subscribeHTTP(t, address)
+ }
+
+ for range 3 {
+ second.subscribeHTTP(t, address)
+ }
+
+ r.Equal(2, first.mailsTo(address))
+ r.Equal(1, second.mailsTo(address))
+ })
+
+ t.Run("the per-IP budget caps distinct addresses from one client", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ f := engine.fixture(t, defaultBudget())
+
+ for i := range config.DefaultStatusSubscriberConfirmPerIPPerHour + 5 {
+ f.subscribeHTTP(t, f.addr("ip"+strconv.Itoa(i)))
+ }
+
+ r.Len(f.sender.sent(), config.DefaultStatusSubscriberConfirmPerIPPerHour)
+ })
+
+ t.Run("the per-page budget caps a page across clients", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ budget := defaultBudget()
+ budget.ConfirmPerPagePerHour = 2
+ f := engine.fixture(t, budget)
+
+ for i := range 4 {
+ f.clientIP = "203.0.113." + strconv.Itoa(i+1) + "0"
+ f.subscribeHTTP(t, f.addr("page"+strconv.Itoa(i)))
+ }
+
+ r.Len(f.sender.sent(), 2)
+ })
+
+ t.Run("a first subscribe sends exactly one confirm mail", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ f := engine.fixture(t, defaultBudget())
+ address := f.addr("first")
+
+ f.subscribeHTTP(t, address)
+
+ r.Len(f.sender.sent(), 1)
+ r.Equal(1, f.mailsTo(address))
+ r.Contains(f.sender.sent()[0].HTML, f.row(t, address).ConfirmToken)
+ })
+
+ t.Run("an unsubscribed address can subscribe again and gets a new confirm mail", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ f := engine.fixture(t, defaultBudget())
+ address := f.addr("returning")
+
+ f.subscribeHTTP(t, address)
+ _, err := f.svc.Confirm(t.Context(), f.row(t, address).ConfirmToken)
+ r.NoError(err)
+ _, err = f.svc.Unsubscribe(t.Context(), f.row(t, address).UnsubscribeToken)
+ r.NoError(err)
+
+ f.subscribeHTTP(t, address)
+
+ r.Equal(2, f.mailsTo(address))
+ revived := f.row(t, address)
+ r.Nil(revived.DeletedAt)
+ r.Nil(revived.ConfirmedAt)
+ r.Contains(f.sender.sent()[1].HTML, revived.ConfirmToken)
+ })
+
+ t.Run("an expired unconfirmed row is refused then reaped; a confirmed one is kept", func(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+ ctx := t.Context()
+ f := engine.fixture(t, defaultBudget())
+ stale, kept, fresh := f.addr("stale"), f.addr("kept"), f.addr("fresh")
+
+ f.subscribeHTTP(t, stale)
+ f.subscribeHTTP(t, kept)
+ f.subscribeHTTP(t, fresh)
+ _, err := f.svc.Confirm(ctx, f.row(t, kept).ConfirmToken)
+ r.NoError(err)
+
+ age := models.SubscriberConfirmTTL + 24*time.Hour
+ staleRow := f.row(t, stale)
+ f.age(t, staleRow.UID, age)
+ f.age(t, f.row(t, kept).UID, age)
+
+ // Expired but not yet reaped: the link is already refused.
+ _, err = f.svc.Confirm(ctx, staleRow.ConfirmToken)
+ r.ErrorIs(err, statussubscribers.ErrInvalidToken)
+
+ runner, err := (&jobtypes.StateCleanupJobDefinition{}).CreateJobRun(nil)
+ r.NoError(err)
+ r.NoError(runner.Run(ctx, &jobdef.JobContext{DBService: f.dbSvc, Logger: slog.Default()}))
+
+ _, err = f.dbSvc.FindAnySubscriber(ctx, f.page.UID, stale, models.SubscriberScopePage, nil)
+ r.Error(err, "the expired unconfirmed row is hard-deleted")
+
+ _, err = f.svc.Confirm(ctx, staleRow.ConfirmToken)
+ r.ErrorIs(err, statussubscribers.ErrInvalidToken)
+
+ r.NotNil(f.row(t, kept).ConfirmedAt, "a confirmed row of the same age is kept")
+ r.Nil(f.row(t, fresh).ConfirmedAt, "a fresh unconfirmed row is kept")
+
+ _, err = f.svc.Confirm(ctx, f.row(t, fresh).ConfirmToken)
+ r.NoError(err)
+ })
+}
+
+func TestSubscribeAbuse_SQLite(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+
+ dbSvc, err := sqlite.New(ctx, sqlite.Config{InMemory: true})
+ require.NoError(t, err)
+ require.NoError(t, dbSvc.Initialize(ctx))
+ t.Cleanup(func() { _ = dbSvc.Close() })
+
+ runSubscribeAbuseContract(t, &abuseEngine{dbSvc: dbSvc, bunDB: dbSvc.DB()})
+}
+
+func TestSubscribeAbuse_Postgres(t *testing.T) {
+ t.Parallel()
+
+ if testing.Short() {
+ t.Skip("skipping embedded-postgres test in -short mode")
+ }
+
+ ctx := t.Context()
+
+ dbSvc, err := postgres.New(ctx, &postgres.Config{
+ Embedded: true,
+ Port: portSubscribeAbusePG,
+ RunMode: "test",
+ })
+ if err != nil {
+ testsupport.PostgresUnavailable(t, err)
+ }
+
+ t.Cleanup(func() { _ = dbSvc.Close() })
+
+ if initErr := dbSvc.Initialize(ctx); initErr != nil {
+ testsupport.PostgresInitFailed(t, initErr)
+ }
+
+ runSubscribeAbuseContract(t, &abuseEngine{dbSvc: dbSvc, bunDB: dbSvc.DB()})
+}
diff --git a/server/internal/i18n/locales/de/account.json b/server/internal/i18n/locales/de/account.json
index 6ffd02cd4..29de4fcf3 100644
--- a/server/internal/i18n/locales/de/account.json
+++ b/server/internal/i18n/locales/de/account.json
@@ -75,6 +75,14 @@
"revoke": "Wenn Sie diese Anmeldung nicht selbst gestartet haben, widerrufen Sie das Token jetzt auf der Token-Seite Ihres Kontos:",
"cta": "Meine Tokens ansehen"
},
+ "passkeyAdded": {
+ "subject": "Ihrem SolidPing-Konto wurde ein Passkey hinzugefügt",
+ "preheader": "„{name}“ kann sich jetzt bei Ihrem Konto anmelden.",
+ "title": "Ein neuer Passkey wurde hinzugefügt",
+ "body": "Ihrem SolidPing-Konto wurde am {date} ein Passkey mit dem Namen „{name}“ hinzugefügt. Er meldet sich bei allen Organisationen des Kontos an.",
+ "revoke": "Wenn Sie ihn nicht selbst hinzugefügt haben, entfernen Sie ihn jetzt auf der Sicherheitsseite Ihres Kontos und ändern Sie Ihr Passwort:",
+ "cta": "Meine Passkeys ansehen"
+ },
"invitation": {
"subject": "Einladung zu {org} bei SolidPing",
"preheader": {
diff --git a/server/internal/i18n/locales/en/account.json b/server/internal/i18n/locales/en/account.json
index 40f10a613..5d3dc57bf 100644
--- a/server/internal/i18n/locales/en/account.json
+++ b/server/internal/i18n/locales/en/account.json
@@ -75,6 +75,14 @@
"revoke": "If you did not start this sign-in yourself, revoke the token now from your account tokens page:",
"cta": "Review my tokens"
},
+ "passkeyAdded": {
+ "subject": "A passkey was added to your SolidPing account",
+ "preheader": "\"{name}\" can now sign in to your account.",
+ "title": "A new passkey was added",
+ "body": "A passkey named \"{name}\" was added to your SolidPing account on {date}. It signs in to every organization of the account.",
+ "revoke": "If you did not add it yourself, remove it now from your account security page and change your password:",
+ "cta": "Review my passkeys"
+ },
"invitation": {
"subject": "You're invited to join {org} on SolidPing",
"preheader": {
diff --git a/server/internal/i18n/locales/es/account.json b/server/internal/i18n/locales/es/account.json
index 939ffb507..77b3491c5 100644
--- a/server/internal/i18n/locales/es/account.json
+++ b/server/internal/i18n/locales/es/account.json
@@ -75,6 +75,14 @@
"revoke": "Si no iniciaste tú este inicio de sesión, revoca el token ahora desde la página de tokens de tu cuenta:",
"cta": "Ver mis tokens"
},
+ "passkeyAdded": {
+ "subject": "Se ha añadido una llave de acceso a tu cuenta de SolidPing",
+ "preheader": "«{name}» ya puede iniciar sesión en tu cuenta.",
+ "title": "Se ha añadido una nueva llave de acceso",
+ "body": "El {date} se añadió a tu cuenta de SolidPing una llave de acceso llamada «{name}». Permite iniciar sesión en todas las organizaciones de la cuenta.",
+ "revoke": "Si no la añadiste tú, elimínala ahora desde la página de seguridad de tu cuenta y cambia tu contraseña:",
+ "cta": "Ver mis llaves de acceso"
+ },
"invitation": {
"subject": "Te han invitado a unirte a {org} en SolidPing",
"preheader": {
diff --git a/server/internal/i18n/locales/fr/account.json b/server/internal/i18n/locales/fr/account.json
index 44fee6c06..3144a3943 100644
--- a/server/internal/i18n/locales/fr/account.json
+++ b/server/internal/i18n/locales/fr/account.json
@@ -75,6 +75,14 @@
"revoke": "Si vous n'avez pas lancé cette connexion vous-même, révoquez ce jeton dès maintenant depuis la page des jetons de votre compte :",
"cta": "Voir mes jetons"
},
+ "passkeyAdded": {
+ "subject": "Une clé d'accès a été ajoutée à votre compte SolidPing",
+ "preheader": "« {name} » peut désormais se connecter à votre compte.",
+ "title": "Une nouvelle clé d'accès a été ajoutée",
+ "body": "Une clé d'accès nommée « {name} » a été ajoutée à votre compte SolidPing le {date}. Elle permet de se connecter à toutes les organisations du compte.",
+ "revoke": "Si vous ne l'avez pas ajoutée vous-même, supprimez-la dès maintenant depuis la page de sécurité de votre compte et changez votre mot de passe :",
+ "cta": "Voir mes clés d'accès"
+ },
"invitation": {
"subject": "Invitation à rejoindre {org} sur SolidPing",
"preheader": {
diff --git a/server/internal/jobs/jobtypes/job_state_cleanup.go b/server/internal/jobs/jobtypes/job_state_cleanup.go
index d886c38fe..e335f29bc 100644
--- a/server/internal/jobs/jobtypes/job_state_cleanup.go
+++ b/server/internal/jobs/jobtypes/job_state_cleanup.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"time"
+ "github.com/fclairamb/solidping/server/internal/db/models"
"github.com/fclairamb/solidping/server/internal/handlers/attachments"
"github.com/fclairamb/solidping/server/internal/jobs/jobdef"
"github.com/fclairamb/solidping/server/internal/jobs/jobsvc"
@@ -61,6 +62,7 @@ func (r *StateCleanupJobRun) Run(ctx context.Context, jctx *jobdef.JobContext) e
}
sweepExpiredAuthHandoffCodes(ctx, jctx)
+ sweepExpiredUnconfirmedSubscribers(ctx, jctx)
sweepOrphanIncidentAttachments(ctx, jctx)
sweepOrphanCheckAttachments(ctx, jctx)
@@ -97,6 +99,26 @@ func sweepExpiredAuthHandoffCodes(ctx context.Context, jctx *jobdef.JobContext)
}
}
+// sweepExpiredUnconfirmedSubscribers hard-deletes status-page subscriptions
+// whose confirm link expired unclicked (models.SubscriberConfirmTTL). Anyone
+// can create such a row through the public subscribe endpoint, so without this
+// the table grows without bound from anonymous traffic (spec 2026-10-28-02).
+// Confirmed rows are never touched. Best-effort, like the other sweeps.
+func sweepExpiredUnconfirmedSubscribers(ctx context.Context, jctx *jobdef.JobContext) {
+ before := time.Now().UTC().Add(-models.SubscriberConfirmTTL)
+
+ count, err := jctx.DBService.DeleteExpiredUnconfirmedSubscribers(ctx, before)
+ if err != nil {
+ jctx.Logger.WarnContext(ctx, "Failed to delete expired unconfirmed status subscribers", "error", err)
+
+ return
+ }
+
+ if count > 0 {
+ jctx.Logger.InfoContext(ctx, "Deleted expired unconfirmed status subscribers", "count", count)
+ }
+}
+
// attachmentOrphanGrace is how long an attachment must have existed before the
// sweep will consider it. It is a RACE GUARD, not a retention policy: an
// attachment row is written moments after its incident, and on the agent path
diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go
index fd834ecd0..f3dee1b08 100644
--- a/server/internal/middleware/auth.go
+++ b/server/internal/middleware/auth.go
@@ -122,8 +122,13 @@ func (m *AuthMiddleware) RequireAuth(next httpx.HandlerFunc) httpx.HandlerFunc {
// reasoning as the demo guard, reusing its route-pattern mechanism:
// the token acts as the target everywhere except the
// credential-changing surface.
- if denied := impersonationDenial(claims, user, httpx.RoutePattern(req), req.Method); denied != "" {
- return m.WriteError(writer, http.StatusForbidden, base.ErrorCodeImpersonationForbidden, denied)
+ //
+ // A personal access token is bound to the org it was created in
+ // (spec 2026-10-28-01). Same choke point and same route-pattern
+ // mechanism: it may do anything in its org but open a session,
+ // approve a device grant or change how the account signs in.
+ if code, denied := credentialDenial(claims, user, httpx.RoutePattern(req), req.Method); denied != "" {
+ return m.WriteError(writer, http.StatusForbidden, code, denied)
}
// Add claims and user to context
@@ -136,12 +141,7 @@ func (m *AuthMiddleware) RequireAuth(next httpx.HandlerFunc) httpx.HandlerFunc {
// not read the same in a security trail — so the token form is
// recorded as such while ActorUID still names the owning user.
ctx = audit.WithUser(ctx, user.UID, actorTypeForToken(token))
- // Under an impersonation token the audit actor is the TARGET (every
- // permission check runs as them), and every row written must still
- // name the admin behind it.
- if claims.IsImpersonation() {
- ctx = audit.WithImpersonator(ctx, claims.ImpersonatedBy)
- }
+ ctx = withCredentialMarkers(ctx, claims)
setSentryIdentity(ctx, claims)
@@ -149,6 +149,40 @@ func (m *AuthMiddleware) RequireAuth(next httpx.HandlerFunc) httpx.HandlerFunc {
}
}
+// credentialDenial returns the error code and refusal message for a request the
+// credential's kind forbids (an impersonation token, a personal access token),
+// or an empty message when it may proceed.
+func credentialDenial(claims *auth.Claims, user *models.User, routePattern, method string) (base.ErrorCode, string) {
+ if denied := impersonationDenial(claims, user, routePattern, method); denied != "" {
+ return base.ErrorCodeImpersonationForbidden, denied
+ }
+
+ if claims.IsPAT() && !auth.IsPATAllowed(method, routePattern) {
+ return base.ErrorCodePATForbidden, auth.PATForbiddenMessage
+ }
+
+ return "", ""
+}
+
+// withCredentialMarkers records on the context what the services must know
+// about the credential beyond the user.
+func withCredentialMarkers(ctx context.Context, claims *auth.Claims) context.Context {
+ // Under an impersonation token the audit actor is the TARGET (every
+ // permission check runs as them), and every row written must still
+ // name the admin behind it.
+ if claims.IsImpersonation() {
+ ctx = audit.WithImpersonator(ctx, claims.ImpersonatedBy)
+ }
+
+ // Lets Service.startSession refuse to mint a session from a PAT request,
+ // whatever the route (spec 2026-10-28-01).
+ if claims.IsPAT() {
+ ctx = auth.WithPATCredential(ctx)
+ }
+
+ return ctx
+}
+
// impersonationDenial returns the refusal message for a request an
// impersonation token may not make, or "" when it may proceed (including every
// request that is not an impersonation at all).
diff --git a/server/internal/middleware/mcp_credential_rest_test.go b/server/internal/middleware/mcp_credential_rest_test.go
index f6e15e054..39195f036 100644
--- a/server/internal/middleware/mcp_credential_rest_test.go
+++ b/server/internal/middleware/mcp_credential_rest_test.go
@@ -182,11 +182,19 @@ func TestMCPCredentialRefusedOnREST(t *testing.T) {
mcpCredCase{"legacy loopback CLI resolves its org", "cli-full", http.MethodGet, me, http.StatusOK},
)
- // Positive controls: a dashboard session and an unscoped PAT pass everywhere.
+ // Positive controls: a dashboard session and an unscoped PAT pass
+ // everywhere, except switch-org for the PAT: it is bound to its org
+ // (spec 2026-10-28-01).
for _, tok := range []string{"session", "pat"} {
for _, call := range controls {
name := tok + " " + call.method + " " + call.path
- tests = append(tests, mcpCredCase{name, tok, call.method, call.path, http.StatusOK})
+ want := http.StatusOK
+
+ if tok == "pat" && call.path == switchOrg {
+ want = http.StatusForbidden
+ }
+
+ tests = append(tests, mcpCredCase{name, tok, call.method, call.path, want})
}
}
@@ -200,6 +208,10 @@ func TestMCPCredentialRefusedOnREST(t *testing.T) {
if tt.want == http.StatusUnauthorized {
require.Equal(t, string(base.ErrorCodeInvalidToken), errCode)
}
+
+ if tt.want == http.StatusForbidden {
+ require.Equal(t, string(base.ErrorCodePATForbidden), errCode)
+ }
})
}
}
diff --git a/server/internal/notifications/slack_test.go b/server/internal/notifications/slack_test.go
index 153448078..befb989a7 100644
--- a/server/internal/notifications/slack_test.go
+++ b/server/internal/notifications/slack_test.go
@@ -2722,6 +2722,10 @@ func (m *mockDBService) ResubscribeSubscriber(_ context.Context, _, _, _ string)
return nil
}
+func (m *mockDBService) DeleteExpiredUnconfirmedSubscribers(_ context.Context, _ time.Time) (int64, error) {
+ return 0, nil
+}
+
func (m *mockDBService) UpdateSubscriberDelivery(
_ context.Context, _ string, _ int, _ *time.Time,
) error {
diff --git a/server/pkg/cli/auth.go b/server/pkg/cli/auth.go
index afa609dbe..bdf1caeae 100644
--- a/server/pkg/cli/auth.go
+++ b/server/pkg/cli/auth.go
@@ -358,6 +358,20 @@ func authSwitchOrgAction(ctx context.Context, cmd *cli.Command) error {
return cliCtx.HandleAuthError(err)
}
+ // A personal access token is bound to its org: the server refuses
+ // switch-org with PAT_FORBIDDEN (spec 2026-10-28-01). Say why and what to
+ // do instead of printing a bare 403.
+ if cliCtx.APIHelper.AuthMethod(ctx) == authMethodPAT {
+ bound := cliCtx.GetOrg()
+ if me, meErr := apiClient.Me(ctx); meErr == nil {
+ if slug := orgSlugFromMe(me); slug != "" {
+ bound = slug
+ }
+ }
+
+ return cliCtx.HandleError("Cannot switch organization", &patBoundError{boundOrg: bound, targetOrg: org})
+ }
+
// Switch org
resp, err := apiClient.SwitchOrgWithResponse(ctx, client.SwitchOrgJSONRequestBody{
Org: org,
@@ -389,6 +403,17 @@ func authSwitchOrgAction(ctx context.Context, cmd *cli.Command) error {
return nil
}
+// patBoundError is returned by `sp auth switch-org` on a PAT login: it
+// explains why the switch is refused and what to do instead.
+type patBoundError struct {
+ boundOrg, targetOrg string
+}
+
+func (e *patBoundError) Error() string {
+ return fmt.Sprintf("this token is bound to org %s; create a token in org %s "+
+ "or log in with `sp auth login`", e.boundOrg, e.targetOrg)
+}
+
// promptForInput prompts the user for input.
func promptForInput(prompt string) (string, error) {
fmt.Print(prompt) //nolint:forbidigo // Interactive prompt requires direct stdin
diff --git a/server/pkg/cli/auth_switch_org_pat_test.go b/server/pkg/cli/auth_switch_org_pat_test.go
new file mode 100644
index 000000000..5b30da011
--- /dev/null
+++ b/server/pkg/cli/auth_switch_org_pat_test.go
@@ -0,0 +1,101 @@
+package cli
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/urfave/cli/v3"
+)
+
+// TestAuthSwitchOrgWithPATPrintsGuidance: on a PAT login, `sp auth switch-org`
+// does not call the server's switch-org (which answers 403 PAT_FORBIDDEN, spec
+// 2026-10-28-01) and tells the user what to do instead.
+func TestAuthSwitchOrgWithPATPrintsGuidance(t *testing.T) {
+ var switchCalls atomic.Int32
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+
+ switch r.URL.Path {
+ case "/api/v1/auth/me":
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "user": map[string]any{"uid": smokeUID, "email": "alice@acme.com"},
+ "organization": map[string]any{"uid": smokeUID, "slug": "acme", "name": "Acme"},
+ })
+ case "/api/v1/auth/switch-org":
+ switchCalls.Add(1)
+ w.WriteHeader(http.StatusForbidden)
+ _ = json.NewEncoder(w).Encode(map[string]any{"code": "PAT_FORBIDDEN"})
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ t.Cleanup(srv.Close)
+
+ home := t.TempDir()
+ t.Chdir(t.TempDir())
+ t.Setenv("HOME", home)
+
+ cfgDir := filepath.Join(home, ".config", "solidping")
+ if err := os.MkdirAll(cfgDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := `{"url":"` + srv.URL + `","org":"acme","auth":{"pat":"pat_test"}}`
+ if err := os.WriteFile(filepath.Join(cfgDir, "settings.json"), []byte(cfg), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ outPath := filepath.Join(t.TempDir(), "stdout")
+
+ out, err := os.Create(outPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ origOut := os.Stdout
+ os.Stdout = out
+
+ root := &cli.Command{
+ Name: "sp", Flags: GetGlobalFlags(), Commands: GetCommands(),
+ ExitErrHandler: func(context.Context, *cli.Command, error) {},
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ runErr := root.Run(ctx, []string{"sp", "auth", "switch-org", "globex"})
+
+ cancel()
+
+ os.Stdout = origOut
+ _ = out.Close()
+
+ if runErr == nil {
+ t.Fatal("switch-org with a PAT must fail")
+ }
+
+ printed, err := os.ReadFile(outPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for _, want := range []string{
+ "this token is bound to org acme",
+ "create a token in org globex",
+ "sp auth login",
+ } {
+ if !strings.Contains(string(printed), want) {
+ t.Errorf("output %q does not contain %q", printed, want)
+ }
+ }
+
+ if n := switchCalls.Load(); n != 0 {
+ t.Errorf("switch-org was called %d times with a PAT", n)
+ }
+}
diff --git a/specs/done/2026/10/2026-10-28-01-pat-cannot-mint-sessions-or-sign-in-factors.md b/specs/done/2026/10/2026-10-28-01-pat-cannot-mint-sessions-or-sign-in-factors.md
new file mode 100644
index 000000000..38034c940
--- /dev/null
+++ b/specs/done/2026/10/2026-10-28-01-pat-cannot-mint-sessions-or-sign-in-factors.md
@@ -0,0 +1,79 @@
+---
+model: opus
+effort: high
+---
+
+# An org-bound PAT can mint a session in the owner's other orgs and add sign-in factors
+
+## Problem
+
+Found by the security scan of 2026-10-28 (finding X1).
+
+**Severity: high.** Anyone holding a single unscoped personal access token can trigger it. The leak might be a CI secret, a token pasted in a script, or a device-flow token phished for one org (the V1 scenario). The result is a session in every org the owner belongs to, plus sign-in factors that outlive the token.
+
+A PAT is bound to the org it was created in, and the server enforces that binding. `ValidatePATToken` puts that org in `claims.OrgSlug` (`server/internal/handlers/auth/service.go:1659-1674`). `AuthorizeOrgAccess` refuses any other org (`server/internal/handlers/auth/orgaccess.go:63-65`). The device-flow consent page has the user pick the org the token is for. The `/auth/*` routes behind plain `RequireAuth` (`server/internal/app/server.go:833-869`) do not respect that binding:
+
+1. **`POST /auth/switch-org` turns the PAT into a full session in any org of its owner.**
+ - The handler passes only `claims.UserUID` (`server/internal/handlers/auth/handler.go:509-533`).
+ - `SwitchOrg` checks membership in the target org and mints an access token plus a refresh token there (`service.go:2152-2200`).
+ - Reproduced on a local test-mode server: a PAT created in org `test3` (role `user`) got 403 on `test2` routes. `switch-org` to `test2` then returned an access and refresh token with role `admin` in `test2`, and that token read `test2` checks (200).
+ - The refresh token keeps the session alive after the PAT is revoked.
+2. **`POST /auth/device/consent` mints a PAT for any org of the owner.**
+ - `RespondToDeviceConsent` takes the org from the request body (`server/internal/handlers/auth/device_service.go:272-288`).
+ - `CreatePAT` checks only membership (`service.go:2033-2043`), never the caller's own `claims.OrgSlug`.
+ - So the holder opens a device request, approves it with the PAT, and receives a PAT for another org.
+3. **`POST /auth/passkeys/register/begin` and `/finish` add a passkey to the account.**
+ - Neither step needs anything beyond `RequireAuth` (`server/internal/handlers/auth/passkey_handler.go:64-103`, `passkey_service.go:192-233`).
+ - The attacker controls their client, so a script can complete the WebAuthn ceremony with a software key.
+ - Passkey sign-in counts as MFA and skips TOTP (`passkey_service.go:408`). It signs into every org of the account.
+ - It survives PAT revocation and password reset, and no email tells the owner that a passkey was added.
+4. **Other account-security routes accept a PAT too:**
+ - `POST /auth/change-password` sets a password with no current password when the account has none, i.e. an SSO-only user (`service.go:3290-3299`).
+ - `POST /auth/2fa/setup` and `/confirm` enable 2FA on an account without it, with the holder's own secret.
+ - `DELETE /auth/2fa`: since #693 every call spends the shared login 2FA budget, wrong code or not (`service.go:4870-4878`, key shared with `Verify2FA` at `:4769`). A holder who sends 5 wrong codes every 15 minutes keeps the owner's TOTP and recovery-code sign-in at 429 indefinitely. That stops the owner from signing in to revoke the stolen token, which is the remediation the V1 approval email asks for.
+
+Precedent: impersonation tokens are already denied exactly this class of routes by `impersonationForbiddenRoutes` (`server/internal/handlers/auth/impersonation_guard.go:49-91`). `startSession` also refuses to mint any session from an impersonated request (`service.go:926-934`). A PAT gets neither guard. The MCP-credential guard (`middleware/auth.go:77-86`, spec 2026-10-16-12) states the same rule for MCP tokens: they "must not mint a PAT or a session".
+
+Not in scope: a PAT minting another PAT in **its own** org (`POST /orgs/:org/tokens`), with or without an expiry. The dashboard offers "never expires", and the new token is bound to the same org with the same role.
+
+## Proposal
+
+1. Mark PAT-derived claims so that guards can recognise them. One option is an unexported field or a `TokenKind` set in `ValidatePATToken` (`service.go:1659`). Reusing the `pat_` prefix check from `middleware/auth.go:215` at the guard is acceptable too.
+2. Add a PAT denylist next to the impersonation one, and enforce it in `RequireAuth` (`server/internal/middleware/auth.go:55-130`) with a 403 and a dedicated code (e.g. `PAT_FORBIDDEN`). Start from `impersonationForbiddenRoutes` and keep at least:
+ - `POST /auth/switch-org`, `POST /orgs` (org creation mints an owner session), `POST /auth/accept-invite`;
+ - `POST /auth/change-password`, `PATCH /auth/me` when it changes the sign-in email;
+ - `POST /auth/2fa/setup`, `POST /auth/2fa/confirm`, `DELETE /auth/2fa`;
+ - `POST /auth/passkeys/register/begin`, `POST /auth/passkeys/register/finish`;
+ - `POST /auth/device/consent`;
+ - the Discord identity link and the Telegram/Discord paging links. A bound Discord identity signs in (same reasoning as the impersonation list).
+3. Defence in depth at the choke point: make `startSession` (`service.go:918`) refuse when the request's credential is a PAT, the way it refuses impersonation. A session-minting route added later then cannot reopen this.
+4. CLI (`server/pkg/cli/auth.go:342-390`): `sp auth switch-org` on a PAT login must print a clear message instead of a bare 403. The message should say "this token is bound to org X; create a token in the target org or log in with `sp auth login`".
+5. Send an email to the account owner when a passkey is registered. Reuse the security-notice layout of `device-token-created.html`.
+6. Docs: `web/docs/docs/cli.md` and the API tokens page say a PAT is bound to one org and cannot change the account's sign-in methods.
+
+## Tests
+
+- `server/internal/handlers/auth` (both engines), table-driven over the denylist. With a PAT created in org A, for a user who is admin of orgs A and B:
+ - **Regression (fails before the fix):**
+ - `switch-org` to B returns 403 and mints no refresh token (row count unchanged);
+ - device consent with `org: B` mints no PAT;
+ - passkey `register/begin` returns 403;
+ - `DELETE /auth/2fa` returns 403 and does not touch the 2FA attempt counter.
+ - **Positive controls:**
+ - the same calls made with a session JWT from a password login still succeed;
+ - the PAT still reads org A routes;
+ - the PAT can still create a PAT in org A.
+- A route-table test, like the impersonation and MCP ones, proving that every denylist entry names a real route.
+- `startSession` unit test: called in a PAT request context, it returns the new error.
+- CLI test: `switch-org` with a PAT config prints the guidance message.
+- Email: registering a passkey enqueues the notice for the owner; registering with a session still works.
+
+## Open questions
+
+- Should a PAT be allowed to call `switch-org` at all, for the CLI convenience? **Recommended: no.** A token is bound to one org on purpose: the device consent page asks the user which org it is for. The CLI asks for a new token instead.
+- Should a session's `DELETE /auth/2fa` keep sharing the login budget (spec 2026-10-27-02)? **Recommended: yes.** Once PATs are refused there, only a session can spend it, and a session holder can already do worse. A separate counter key would reopen the parallel-budget question.
+
+## Resolved open questions
+
+- A PAT must not call `switch-org` at all; the CLI asks for a new token. (autopilot: recommended answer)
+- A session's `DELETE /auth/2fa` keeps sharing the login budget. (autopilot: recommended answer)
diff --git a/specs/done/2026/10/2026-10-28-02-status-subscribe-throttle-and-keep-confirmed.md b/specs/done/2026/10/2026-10-28-02-status-subscribe-throttle-and-keep-confirmed.md
new file mode 100644
index 000000000..74bbafa27
--- /dev/null
+++ b/specs/done/2026/10/2026-10-28-02-status-subscribe-throttle-and-keep-confirmed.md
@@ -0,0 +1,61 @@
+---
+model: opus
+effort: medium
+---
+
+# Public status-page subscribe sends unthrottled confirm emails and un-confirms existing subscribers
+
+## Problem
+
+Found by the security scan of 2026-10-28 (finding X2).
+
+**Severity: medium.** Anyone can trigger it, with no account. It works against any public status page, including one the attacker created in their own org.
+
+`POST /api/v1/orgs/:org/status-pages/:statusPageUid/subscribers` is public (`server/internal/app/server.go:2259`). The comment above it says the global per-IP limit applies, and that "double opt-in is the primary anti-abuse control". That limit is 1800 requests per minute by default (`server/internal/config/config.go:1899`). Double opt-in does not hold for three reasons:
+
+1. **The confirm email is sent on every request, with no per-recipient or per-page budget.**
+ - The handler sends it synchronously after each successful `Subscribe` (`server/internal/handlers/statussubscribers/handler.go:73-95`).
+ - Re-subscribing the same address takes the `FindAnySubscriber` → `ResubscribeSubscriber` path, which rotates the tokens and returns success, so the email goes out again (`service.go:288-313`).
+ - Nothing in `internal/email` throttles either.
+ - Result: any third-party address can be flooded with confirm emails sent from the instance's own mail domain, at up to the per-IP rate, from several IPs. On SaaS that burns the sender reputation every customer depends on.
+ - The password-reset flow guards against the same thing with per-user and per-IP counters (`server/internal/handlers/auth/service.go:3075-3088`). E2 (spec 2026-10-16-13) treated a race on that cap as a bug.
+2. **Re-subscribing silently cancels a confirmed subscription.**
+ - `ResubscribeSubscriber` sets `confirmed_at = NULL` and replaces the unsubscribe token on any existing row, confirmed ones included (`server/internal/db/sqlite/status_page_subscriber.go:150-163`, same in `db/postgres`).
+ - The fan-out reads only `confirmed_at IS NOT NULL` rows (`sqlite/status_page_subscriber.go:198-207`).
+ - So an anonymous caller who knows or guesses a subscriber's address (`ops@acme.com`) can stop that subscriber's incident notifications. The subscriber only gets an unexpected "confirm your subscription" email, and the unsubscribe links in their earlier emails stop working.
+3. **Unconfirmed rows never expire.** Each distinct address inserts a row that no job removes (`server/internal/jobs/jobtypes/job_state_cleanup.go` does not touch this table), and the confirm token has no expiry. The table grows without bound from anonymous traffic.
+
+## Proposal
+
+1. **Keep a confirmed subscription confirmed.** In `upsertSubscriber` (`server/internal/handlers/statussubscribers/service.go:288`), when the existing row is live (`deleted_at IS NULL`) and confirmed:
+ - return success without changing the row and without sending an email;
+ - the response stays the same `202 pending_confirmation`, so the endpoint does not reveal whether an address is subscribed.
+
+ Revive and re-confirm only soft-deleted or unconfirmed rows.
+2. **Budget the confirm email** with `BumpGlobalCounter` (the primitive the auth counters use, `service.go:3069-3088`):
+ - per recipient address, across all pages: e.g. 3 per hour;
+ - per page: e.g. 100 per hour;
+ - per client IP, resolved with `base.ExtractTrustedClientIP`, never the spoofable `ExtractRemoteAddr` (known M10).
+
+ Over budget, keep answering 202 but send nothing, so the endpoint does not become an oracle either.
+3. **Expire unconfirmed rows.** Add an expiry for the confirm token (e.g. 7 days, checked in the confirm lookup). Have `StateCleanupJob` hard-delete unconfirmed rows older than that.
+4. Update the route comment at `server.go:2250-2258` to describe the real controls.
+
+## Tests
+
+- `server/internal/handlers/statussubscribers` (both engines):
+ - **Regression (fails before the fix):**
+ - subscribe and confirm an address, then subscribe it again through the public endpoint. The row stays confirmed, the unsubscribe token is unchanged, and no email is sent.
+ - 10 subscribe calls for the same new address send at most the per-recipient budget of emails. Every call still answers 202.
+ - **Positive controls:**
+ - a first subscribe sends exactly one confirm email;
+ - a soft-deleted (unsubscribed) address can subscribe again and receives a new confirm email.
+ - An unconfirmed row older than the expiry is removed by the cleanup job. Its confirm link is then refused. A confirmed row of the same age is kept.
+
+## Open questions
+
+- Budgets: **recommended** 3 per recipient per hour, 100 per page per hour, 20 per IP per hour. Make them config values with those defaults.
+
+## Resolved open questions
+
+- Budgets: 3 per recipient per hour, 100 per page per hour, 20 per IP per hour, as config values with those defaults. (autopilot: recommended answer)
diff --git a/specs/done/2026/10/2026-10-28-03-invite-lookup-without-org-scan.md b/specs/done/2026/10/2026-10-28-03-invite-lookup-without-org-scan.md
new file mode 100644
index 000000000..fbf969822
--- /dev/null
+++ b/specs/done/2026/10/2026-10-28-03-invite-lookup-without-org-scan.md
@@ -0,0 +1,44 @@
+---
+model: opus
+effort: medium
+---
+
+# Each anonymous invite-token lookup scans every organization
+
+## Problem
+
+Found by the security scan of 2026-10-28 (finding X3).
+
+**Severity: medium on SaaS, low self-hosted.** Anyone can trigger it, with no account. The cost grows with the number of organizations, and on SaaS any user can add organizations.
+
+- `GET /api/v1/auth/invite/:token` is public (`server/internal/app/server.go:808`). `POST /auth/accept-invite` also runs without authentication when no credential is presented (`:812`).
+- Both go through `findInvite` (`server/internal/handlers/auth/service.go:3893-3932`, called at `:3949` and `:3977`). For each request it:
+ - loads every organization, all columns, with no limit (`ListOrganizations`, `server/internal/db/postgres/postgres.go:525-535`);
+ - then issues two `GetStateEntry` queries per organization: the hashed key, then the legacy plaintext key.
+- An unknown token always walks the whole list. One request costs about 2 × N + 1 queries, plus materializing N organization rows. The only bound is the global per-IP limiter (1800 per minute by default, `server/internal/config/config.go:1899`).
+- With 1000 organizations, a single IP can drive about 3.6 million database queries per minute. Several IPs multiply that. Every tenant shares that database, so the whole instance degrades.
+- The legacy branch (`service.go:3914-3928`) is marked `TODO(remove after 2026-10-10)`. Invites live at most a week, so every pre-hash invite has expired by now.
+
+## Proposal
+
+1. Resolve the invite with one lookup. Store a global index entry at invite creation (`CreateInvitation`, `service.go:3743`): `invite:` with a nil organization, its value holding the org UID. `findInvite` then reads that entry, then the org-scoped entry it points to.
+ - An org-scoped entry with no index entry, i.e. an invite created before this change, can be found through the old loop for one more week. Drop that fallback in a follow-up, or accept that such invites must be re-sent. See Open questions.
+2. Remove the legacy plaintext-key branch, whose removal date has passed.
+3. Revoking an invitation deletes its index entry too.
+
+## Tests
+
+- `server/internal/handlers/auth` (both engines):
+ - **Regression (fails before the fix):** with 50 organizations, `GetInviteInfo` on an unknown token runs a constant number of queries, independent of the organization count. Count them with a bun query hook. Before the fix it runs about 2 × 50.
+ - **Positive controls:**
+ - a valid invite created through `CreateInvitation` is found by `GetInviteInfo` and accepted by `AcceptInvite`;
+ - a revoked invite is not found.
+ - A token equal to a stored hash is still refused. This is the property the legacy-branch comment protected; keep the test after the branch is gone.
+
+## Open questions
+
+- Invites created before the upgrade (no index entry): keep the old loop as a fallback for 7 days, or ask admins to re-send? **Recommended: no fallback.** Invites are cheap to re-send, and the fallback is the DoS. Note it in the changelog.
+
+## Resolved open questions
+
+- No fallback for invites created before the upgrade; admins re-send them. Note it in the changelog. (autopilot: recommended answer)
diff --git a/specs/done/2026/10/2026-10-28-04-snmp-community-secret-field.md b/specs/done/2026/10/2026-10-28-04-snmp-community-secret-field.md
new file mode 100644
index 000000000..9d45ea872
--- /dev/null
+++ b/specs/done/2026/10/2026-10-28-04-snmp-community-secret-field.md
@@ -0,0 +1,43 @@
+---
+model: sonnet
+effort: low
+---
+
+# SNMP community string is stored and returned as public check config
+
+## Problem
+
+Found by the security scan of 2026-10-28 (finding X4).
+
+**Severity: low.** Any member of the org can read it, viewers included. Anyone who receives an export of the org's checks can read it too.
+
+- In SNMP v1/v2c, the community string is the protocol's only credential. A read-write community also lets its holder change the device.
+- The SNMP checker declares only the v3 passwords as secret (`server/internal/checkers/checksnmp/config/secret_fields.go:5-7`, `return []string{"authPassword", "privPassword"}`). `community` (`config/config.go:40`) is treated as ordinary config.
+- The value therefore:
+ - is stored in the public `config` column, not the encrypted secret store (`applyEncryption` splits on `SecretFields()`);
+ - comes back in clear on `GET /orgs/:org/checks` and `/checks/:uid` for every member, and through the MCP check tools. `redactSecretConfig` strips only declared secret keys (`server/internal/handlers/checks/service.go:4380-4405`);
+ - is kept in exports and in check version snapshots. `stripSecretKeysForExport` (`service.go:4353-4366`) and `hiddenExportConfigKeys` / `checkversion.HiddenConfigKeys` (`diff.go:526-530`) use the same declared set.
+- SNMP is the only outlier. Every other checker declares its credential fields, e.g. `password` for rdp, vnc, ftp and the databases, `secretHeaders` and `basicAuth` for http, `secretMetadata` for grpc, `saslPassword` for kafka.
+
+## Proposal
+
+1. Add `"community"` to `SNMPConfig.SecretFields()` (`server/internal/checkers/checksnmp/config/secret_fields.go`).
+2. Make sure existing rows move the value out of the public column. The credentials migration (`server/internal/credmigrate`) should re-split rows whose type gained a secret field, and the read path already redacts declared keys.
+3. Dash0 SNMP form (`web/dash0/src/components/checks/form/types/infra.tsx`): render `community` as a secret input, like the v3 passwords. An unchanged edit must keep the stored value.
+4. The default `public` community applied when the field is empty (`checksnmp/checker.go:128-133`) stays as it is. It is not stored.
+
+## Tests
+
+- `checksnmp/config`: `SecretFields()` contains `community` (update the existing table test).
+- `server/internal/handlers/checks` (both engines):
+ - **Regression (fails before the fix):** create an SNMP v2c check with community `s3cr3t-acme`.
+ - `GET` as a viewer returns no `community` value, and `configPrivateKeys` lists it.
+ - The export does not contain `s3cr3t-acme`.
+ - The public `config` column of the row does not contain it.
+ - **Positive control:** the check still runs with the stored community. An edit that leaves the field untouched keeps it.
+- Migration/credmigrate: a row written before the change, with `community` in the public column, ends up with the value in the secret store and out of `config`.
+
+## To verify
+
+- Whether `credmigrate` re-splits existing rows when a checker's `SecretFields()` grows, or only runs once. If only once, add a step for SNMP rows.
+- Whether past check version snapshots (`checkversion`) already stored the community. If so, scrub it there too.
diff --git a/web/dash0/src/components/checks/form/types/infra.tsx b/web/dash0/src/components/checks/form/types/infra.tsx
index 4620e0cd6..463ab7a89 100644
--- a/web/dash0/src/components/checks/form/types/infra.tsx
+++ b/web/dash0/src/components/checks/form/types/infra.tsx
@@ -67,6 +67,7 @@ function SnmpFields({
errors,
}: CheckTypeFieldsProps) {
const { t } = useTranslation("checks");
+ const { configPrivateKeys } = useCheckFormFields();
return (
<>
@@ -129,8 +130,9 @@ function SnmpFields({
onChange({ ...state, community: e.target.value })}
data-testid="check-community-input"
diff --git a/web/docs/docs/cli.md b/web/docs/docs/cli.md
index 77b863376..93375ae17 100644
--- a/web/docs/docs/cli.md
+++ b/web/docs/docs/cli.md
@@ -58,10 +58,19 @@ sp auth login # device flow: approve a one-time code in any bro
sp auth login --with-password # classic email + password
sp auth login --token pat_... # save a Personal Access Token created in the dashboard
sp auth me # show the current user
-sp auth switch-org # change active organization
+sp auth switch-org # change active organization (session logins only)
sp auth logout
```
+A Personal Access Token, including the one the device flow mints, is bound to
+the organization it was created in. It cannot switch organization or change how
+your account signs in (password, email, 2FA, passkeys): `sp auth switch-org` on
+a token login says so. Run `sp auth login` again and pick the other
+organization on the consent page, use a token created in that organization, or
+log in with `sp auth login --with-password` to get a session that can switch.
+See
+[API tokens are bound to one organization](configuration/security.md#api-tokens-are-bound-to-one-organization).
+
`sp auth login` uses the OAuth 2.0 Device Authorization Grant (RFC 8628): it
prints a short one-time code and a verification URL, tries to open your browser
at that URL, and waits while you approve the login in any browser where you are
diff --git a/web/docs/docs/configuration/security.md b/web/docs/docs/configuration/security.md
index 49e8efac3..1df68623a 100644
--- a/web/docs/docs/configuration/security.md
+++ b/web/docs/docs/configuration/security.md
@@ -90,6 +90,37 @@ If no master key is configured, encryption is disabled and credentials are store
Token hashing has no such fallback and no configuration: it is not keyed off the master key, and it never stores a token in plaintext.
+## API Tokens Are Bound to One Organization
+
+An API token (`pat_…`) belongs to the organization it was created in, with your role there. It reads and writes that organization only: a request for another organization answers `403`, even when you are a member of it.
+
+A token is meant for a script, a CI job or the CLI, so it cannot change how your account signs in or open a session. These requests answer `403 PAT_FORBIDDEN`:
+
+- switching organization, creating an organization or accepting an invitation (each would open a session);
+- approving a device sign-in (it would mint a token for another organization);
+- changing your password or your sign-in email, setting up or removing 2FA, adding or removing a passkey;
+- linking a Discord account, or a Discord or Telegram chat for paging.
+
+To do any of that, sign in to the dashboard, or with `sp auth login`. A token can still create another token in its own organization.
+
+Adding a passkey sends you an email, with a link to your account security page: a passkey signs in to every organization of the account and survives a password reset, so one you did not add must not go unnoticed.
+
+## Status-Page Subscriptions Are Budgeted
+
+Anyone can subscribe an email address to a public status page, with no account. The address then receives a confirm link (double opt-in). To keep that form from flooding a third party's mailbox from your mail domain, the confirm email has three hourly budgets:
+
+| Variable | Default | Counts |
+|---|---|---|
+| `SP_STATUS_SUBSCRIBERS_CONFIRM_PER_RECIPIENT_PER_HOUR` | `3` | emails to one address, across every status page |
+| `SP_STATUS_SUBSCRIBERS_CONFIRM_PER_PAGE_PER_HOUR` | `100` | emails sent by one status page |
+| `SP_STATUS_SUBSCRIBERS_CONFIRM_PER_IP_PER_HOUR` | `20` | emails one client IP can trigger |
+
+`0` turns a budget off. The client IP follows `SP_SERVER_RATE_LIMITING_TRUSTED_PROXIES`, like the rate limiter.
+
+Over budget, the form still answers `202` but sends nothing. Subscribing an address that is already confirmed changes nothing and sends nothing either, with the same `202`. So the form tells nobody whether an address is subscribed, and nobody can cancel someone else's subscription through it.
+
+A confirm link expires after 7 days. The cleanup job then deletes the unconfirmed subscription.
+
## Super-Admin Impersonation
A super admin can sign in as another user to see exactly what they see: **Server → Users**, then the eye icon on the user's row. It is meant for support and debugging, and it is built so it cannot become an account takeover: