carlos / platform Public

console: pin the member-view invite-token secrecy on /team

TestTeamPage now signs in as a non-owner member with a live pending
invite present and asserts the token never appears, alongside the
absence of the invite/administration forms — closing the gap where a
future edit could hoist PendingInvites out of the owner-only branch
and the suite would stay green.

Also: trim three stale comments left behind by the members/invites
move to /team (handleAccountHome's doc comment, the Trash-load
comment, and the Members/Invites page-struct field comments), and
move the /team route registration off the "mux prefers this literal"
comment it doesn't belong next to.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 228f5160935d54c65c54afdef8e232d21189007b parent d8d983d
2 files changed, +33 −8
  • internal/console/server.go +7 −8
  • internal/console/server_test.go +26 −0
diff --git a/internal/console/server.go b/internal/console/server.go
index 8611e758c..9b9cc91ef 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -252,10 +252,10 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// Account-scoped and explicit: /a/<sqid>/apps/<app> rather than
// account-by-session, so a link a teammate opens lands where you meant.
s.mux.HandleFunc("GET /a/{sqid}/{$}", s.handleAccountHome)
+ s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam)
// Go 1.22's mux prefers this literal over apps/{app} below — exactly why
// "new" must be a reserved app name (claimApp): an app so named could
// never be viewed.
- s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam)
s.mux.HandleFunc("GET /a/{sqid}/apps/new", s.handleNewAppForm)
s.mux.HandleFunc("POST /a/{sqid}/apps/new", s.handleNewApp)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp)
@@ -344,8 +344,8 @@ type page struct {
Base string // "/a/<sqid>" — what every form on the page posts under
Nav string // "/a/<name-or-sqid>" — what LINKS use; forms keep posting to Base
Accounts []Account // the switcher: every account the viewer belongs to
- Members []Account // account home: memberships (Name carries the address)
- Invites []Invite // account home, owners only: pending invitations
+ Members []Account // team page: memberships (Name carries the address)
+ Invites []Invite // team page, owners only: pending invitations
// The Trash (ownership.go): the account's deleted-but-restorable apps on
// /a/{sqid}/trash, and the count the account home links with. Zero hides
@@ -1123,8 +1123,8 @@ func (s *Server) handleFleetMetrics(w http.ResponseWriter, r *http.Request) {
// ---- app pages --------------------------------------------------------------
-// handleAccountHome is the account's app list, its switcher, its members and
-// its pending invitations — the page /​ lands you on.
+// handleAccountHome is the account's app list and switcher — the page /
+// lands you on. Members and pending invitations live on /team (handleTeam).
func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
addr, raw, acct, role, ok := s.account(w, r)
if !ok {
@@ -1152,9 +1152,8 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
p.Apps = append(p.Apps, summarize(v))
}
}
- // The Trash link, and only when there is something in it. Best-effort like
- // the members and invites reads below: a failed count costs a link, never
- // the page.
+ // The Trash link, and only when there is something in it. Best-effort: a
+ // failed count costs a link, never the page.
if trashed, err := s.store.TrashedAppsIn(acct.ID); err != nil {
log.Printf("console: trashed apps: %v", err)
} else {
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 67334d91e..b4ff5c4b4 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -515,6 +515,10 @@ func TestTeamPage(t *testing.T) {
if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
t.Fatal(err)
}
+ tok, err := fx.st.CreateInvite(OpsAccountID, "sam@keymail.dev", "paul@keymail.dev", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
c := signInPaul(t, fx)
status, _, body := get(t, c, fx.u("/team"))
if status != 200 {
@@ -536,6 +540,28 @@ func TestTeamPage(t *testing.T) {
}
}
assertNonMember404(t, fx, "/team")
+
+ // A non-owner member sees the roster and nothing an owner alone should
+ // hold: no invite form, no member-administration forms, and — the one
+ // that matters — not the pending invite's TOKEN. That token is a bearer
+ // capability (whoever holds it can accept the invite as its addressee),
+ // so PendingInvites must stay behind the role==RoleOwner branch, not just
+ // behind membership.
+ fx.setUser("kim@keymail.dev")
+ member := newBrowser(t)
+ signUp(t, fx, member, "code-kim-team")
+ mStatus, _, mBody := get(t, member, fx.u("/team"))
+ if mStatus != 200 {
+ t.Fatalf("member GET /team = %d", mStatus)
+ }
+ if strings.Contains(mBody, tok) {
+ t.Fatal("member view leaked a pending invite's token")
+ }
+ for _, gone := range []string{"Invite a teammate", "members/remove", "members/transfer"} {
+ if strings.Contains(mBody, gone) {
+ t.Fatalf("member view of /team still shows %q", gone)
+ }
+ }
}
func TestAppNamedNewIsRefused(t *testing.T) {