tenseleyflow/shithub / 09c93d2

Browse files

Gate private collaboration expansion

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
09c93d295d9ec1276084d9dce16f546ee919a994
Parents
88200be
Tree
178d41c

17 changed files

StatusFile+-
M internal/entitlements/private_collaboration.go 19 1
M internal/entitlements/private_collaboration_test.go 2 1
M internal/orgs/invitations.go 24 0
M internal/orgs/members.go 19 0
A internal/orgs/private_collaboration_test.go 102 0
M internal/orgs/teams.go 15 0
M internal/repos/create.go 10 0
M internal/repos/create_test.go 43 0
M internal/repos/lifecycle/lifecycle_test.go 46 0
M internal/repos/lifecycle/visibility.go 10 0
M internal/web/handlers/api/collaborators.go 11 0
M internal/web/handlers/api/repos.go 7 0
M internal/web/handlers/orgs/orgs.go 23 0
M internal/web/handlers/orgs/teams.go 18 3
M internal/web/handlers/repo/lifecycle.go 3 0
M internal/web/handlers/repo/repo.go 3 0
M internal/web/templates/orgs/people.html 1 0
internal/entitlements/private_collaboration.gomodified
@@ -40,6 +40,24 @@ type PrivateCollaborationCheck struct {
4040
 	Reason       Reason
4141
 }
4242
 
43
+type PrivateCollaborationLimitError struct {
44
+	Check PrivateCollaborationCheck
45
+}
46
+
47
+func (e *PrivateCollaborationLimitError) Error() string {
48
+	if e == nil {
49
+		return ErrPrivateCollaborationLimitExceeded.Error()
50
+	}
51
+	if msg := e.Check.Message(); msg != "" {
52
+		return msg
53
+	}
54
+	return ErrPrivateCollaborationLimitExceeded.Error()
55
+}
56
+
57
+func (e *PrivateCollaborationLimitError) Unwrap() error {
58
+	return ErrPrivateCollaborationLimitExceeded
59
+}
60
+
4361
 func PrivateCollaborationUsageForOrg(ctx context.Context, deps Deps, orgID int64) (PrivateCollaborationUsage, error) {
4462
 	usage, _, err := privateCollaborationUsageWithIDs(ctx, deps, orgID)
4563
 	return usage, err
@@ -198,7 +216,7 @@ func (c PrivateCollaborationCheck) Err() error {
198216
 	if c.Allowed {
199217
 		return nil
200218
 	}
201
-	return ErrPrivateCollaborationLimitExceeded
219
+	return &PrivateCollaborationLimitError{Check: c}
202220
 }
203221
 
204222
 func (c PrivateCollaborationCheck) Message() string {
internal/entitlements/private_collaboration_test.gomodified
@@ -4,6 +4,7 @@ package entitlements_test
44
 
55
 import (
66
 	"context"
7
+	"errors"
78
 	"strings"
89
 	"testing"
910
 	"time"
@@ -88,7 +89,7 @@ func TestPrivateCollaborationExpansionEnforcesFreeLimitAndTeamUnlimited(t *testi
8889
 	if err != nil {
8990
 		t.Fatalf("blocked expansion: %v", err)
9091
 	}
91
-	if check.Allowed || check.WouldUse != 4 || check.Err() != entitlements.ErrPrivateCollaborationLimitExceeded {
92
+	if check.Allowed || check.WouldUse != 4 || !errors.Is(check.Err(), entitlements.ErrPrivateCollaborationLimitExceeded) {
9293
 		t.Fatalf("three-user free expansion check = %+v, want blocked", check)
9394
 	}
9495
 	if !strings.Contains(check.Message(), "up to 3 private collaborators") {
internal/orgs/invitations.gomodified
@@ -14,6 +14,7 @@ import (
1414
 
1515
 	"github.com/tenseleyFlow/shithub/internal/auth/email"
1616
 	"github.com/tenseleyFlow/shithub/internal/auth/token"
17
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
1718
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
1819
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
1920
 )
@@ -96,6 +97,20 @@ func Invite(ctx context.Context, deps Deps, p InviteParams) (InviteResult, error
9697
 	} else if !errors.Is(err, pgx.ErrNoRows) {
9798
 		return InviteResult{}, err
9899
 	}
100
+	if role == orgsdb.OrgRoleOwner {
101
+		var check entitlements.PrivateCollaborationCheck
102
+		if targetUserID.Valid {
103
+			check, err = entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, p.OrgID, targetUserID.Int64)
104
+		} else {
105
+			check, err = entitlements.CheckPrivateInvitationSlot(ctx, entitlements.Deps{Pool: deps.Pool}, p.OrgID)
106
+		}
107
+		if err != nil {
108
+			return InviteResult{}, err
109
+		}
110
+		if err := check.Err(); err != nil {
111
+			return InviteResult{}, err
112
+		}
113
+	}
99114
 
100115
 	tokEnc, tokHash, err := token.New()
101116
 	if err != nil {
@@ -186,6 +201,15 @@ func AcceptInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation,
186201
 			return ErrUnauthorizedAcceptor
187202
 		}
188203
 	}
204
+	if inv.Role == orgsdb.OrgRoleOwner {
205
+		check, err := entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, inv.OrgID, acceptorUserID)
206
+		if err != nil {
207
+			return err
208
+		}
209
+		if err := check.Err(); err != nil {
210
+			return err
211
+		}
212
+	}
189213
 
190214
 	tx, err := deps.Pool.Begin(ctx)
191215
 	if err != nil {
internal/orgs/members.gomodified
@@ -10,6 +10,7 @@ import (
1010
 	"github.com/jackc/pgx/v5"
1111
 	"github.com/jackc/pgx/v5/pgtype"
1212
 
13
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
1314
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
1415
 )
1516
 
@@ -26,6 +27,15 @@ func AddMember(ctx context.Context, deps Deps, orgID, userID, invitedByUserID in
2627
 	if err != nil {
2728
 		return err
2829
 	}
30
+	if r == orgsdb.OrgRoleOwner {
31
+		check, err := entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, orgID, userID)
32
+		if err != nil {
33
+			return err
34
+		}
35
+		if err := check.Err(); err != nil {
36
+			return err
37
+		}
38
+	}
2939
 	tx, err := deps.Pool.Begin(ctx)
3040
 	if err != nil {
3141
 		return err
@@ -83,6 +93,15 @@ func ChangeRole(ctx context.Context, deps Deps, orgID, userID int64, role string
8393
 			return ErrLastOwner
8494
 		}
8595
 	}
96
+	if current.Role != orgsdb.OrgRoleOwner && r == orgsdb.OrgRoleOwner {
97
+		check, err := entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, orgID, userID)
98
+		if err != nil {
99
+			return err
100
+		}
101
+		if err := check.Err(); err != nil {
102
+			return err
103
+		}
104
+	}
86105
 	return q.ChangeOrgMemberRole(ctx, deps.Pool, orgsdb.ChangeOrgMemberRoleParams{
87106
 		OrgID: orgID, UserID: userID, Role: r,
88107
 	})
internal/orgs/private_collaboration_test.goadded
@@ -0,0 +1,102 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs_test
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"testing"
9
+
10
+	"github.com/jackc/pgx/v5/pgtype"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
13
+	"github.com/tenseleyFlow/shithub/internal/orgs"
14
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
15
+)
16
+
17
+func TestOwnerExpansionRespectsPrivateCollaborationLimit(t *testing.T) {
18
+	pool, deps, alice := setup(t)
19
+	ctx := context.Background()
20
+	org, err := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice})
21
+	if err != nil {
22
+		t.Fatalf("create org: %v", err)
23
+	}
24
+	repo := mustOrgRepo(t, pool, org.ID, "secret", "private")
25
+	insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "bob"))
26
+	insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "carol"))
27
+
28
+	dave := mustUser(t, pool, "dave")
29
+	if err := orgs.AddMember(ctx, deps, org.ID, dave, alice, "owner"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
30
+		t.Fatalf("AddMember owner err=%v, want private collaboration limit", err)
31
+	}
32
+	if err := orgs.AddMember(ctx, deps, org.ID, dave, alice, "member"); err != nil {
33
+		t.Fatalf("plain member add should not expand private collaboration: %v", err)
34
+	}
35
+	if err := orgs.ChangeRole(ctx, deps, org.ID, dave, "owner"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
36
+		t.Fatalf("ChangeRole owner err=%v, want private collaboration limit", err)
37
+	}
38
+}
39
+
40
+func TestTeamExpansionRespectsPrivateCollaborationLimit(t *testing.T) {
41
+	pool, deps, alice := setup(t)
42
+	ctx := context.Background()
43
+	org, err := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice})
44
+	if err != nil {
45
+		t.Fatalf("create org: %v", err)
46
+	}
47
+	repo := mustOrgRepo(t, pool, org.ID, "secret", "private")
48
+	insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "bob"))
49
+	insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "carol"))
50
+
51
+	team, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{OrgID: org.ID, Slug: "security", CreatedByUserID: alice})
52
+	if err != nil {
53
+		t.Fatalf("create team: %v", err)
54
+	}
55
+	if err := orgs.GrantTeamRepoAccess(ctx, deps, team.ID, repo.ID, alice, "read"); err != nil {
56
+		t.Fatalf("empty-team private grant should not expand private collaboration: %v", err)
57
+	}
58
+	dave := mustUser(t, pool, "dave")
59
+	if err := orgs.AddTeamMember(ctx, deps, team.ID, dave, alice, "member"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
60
+		t.Fatalf("AddTeamMember err=%v, want private collaboration limit", err)
61
+	}
62
+
63
+	team2, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{OrgID: org.ID, Slug: "ops", CreatedByUserID: alice})
64
+	if err != nil {
65
+		t.Fatalf("create second team: %v", err)
66
+	}
67
+	insertTeamMemberRaw(t, pool, team2.ID, dave)
68
+	if err := orgs.GrantTeamRepoAccess(ctx, deps, team2.ID, repo.ID, alice, "read"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
69
+		t.Fatalf("GrantTeamRepoAccess err=%v, want private collaboration limit", err)
70
+	}
71
+	if err := orgs.RemoveTeamMember(ctx, deps, team2.ID, dave); err != nil {
72
+		t.Fatalf("cleanup remove team member should remain allowed: %v", err)
73
+	}
74
+}
75
+
76
+func mustOrgRepo(t *testing.T, db reposdb.DBTX, orgID int64, name, visibility string) reposdb.Repo {
77
+	t.Helper()
78
+	repo, err := reposdb.New().CreateRepo(context.Background(), db, reposdb.CreateRepoParams{
79
+		OwnerOrgID:    pgtype.Int8{Int64: orgID, Valid: true},
80
+		Name:          name,
81
+		Visibility:    reposdb.RepoVisibility(visibility),
82
+		DefaultBranch: "trunk",
83
+	})
84
+	if err != nil {
85
+		t.Fatalf("create org repo %s: %v", name, err)
86
+	}
87
+	return repo
88
+}
89
+
90
+func insertDirectCollab(t *testing.T, db reposdb.DBTX, repoID, userID int64) {
91
+	t.Helper()
92
+	if _, err := db.Exec(context.Background(), `INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')`, repoID, userID); err != nil {
93
+		t.Fatalf("insert direct collaborator: %v", err)
94
+	}
95
+}
96
+
97
+func insertTeamMemberRaw(t *testing.T, db reposdb.DBTX, teamID, userID int64) {
98
+	t.Helper()
99
+	if _, err := db.Exec(context.Background(), `INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, 'member')`, teamID, userID); err != nil {
100
+		t.Fatalf("insert team member: %v", err)
101
+	}
102
+}
internal/orgs/teams.gomodified
@@ -13,6 +13,7 @@ import (
1313
 	"github.com/jackc/pgx/v5/pgconn"
1414
 	"github.com/jackc/pgx/v5/pgtype"
1515
 
16
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
1617
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
1718
 )
1819
 
@@ -110,6 +111,13 @@ func AddTeamMember(ctx context.Context, deps Deps, teamID, userID, addedByUserID
110111
 	if err != nil {
111112
 		return err
112113
 	}
114
+	check, err := entitlements.CheckTeamMemberPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, teamID, userID)
115
+	if err != nil {
116
+		return err
117
+	}
118
+	if err := check.Err(); err != nil {
119
+		return err
120
+	}
113121
 	return orgsdb.New().AddTeamMember(ctx, deps.Pool, orgsdb.AddTeamMemberParams{
114122
 		TeamID:        teamID,
115123
 		UserID:        userID,
@@ -134,6 +142,13 @@ func GrantTeamRepoAccess(ctx context.Context, deps Deps, teamID, repoID, addedBy
134142
 	if err != nil {
135143
 		return err
136144
 	}
145
+	check, err := entitlements.CheckTeamPrivateRepoGrant(ctx, entitlements.Deps{Pool: deps.Pool}, teamID, repoID)
146
+	if err != nil {
147
+		return err
148
+	}
149
+	if err := check.Err(); err != nil {
150
+		return err
151
+	}
137152
 	return orgsdb.New().GrantTeamRepoAccess(ctx, deps.Pool, orgsdb.GrantTeamRepoAccessParams{
138153
 		TeamID:        teamID,
139154
 		RepoID:        repoID,
internal/repos/create.gomodified
@@ -18,6 +18,7 @@ import (
1818
 
1919
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
2020
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
21
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
2122
 	"github.com/tenseleyFlow/shithub/internal/git/hooks"
2223
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
2324
 	"github.com/tenseleyFlow/shithub/internal/issues"
@@ -147,6 +148,15 @@ func Create(ctx context.Context, deps Deps, p Params) (Result, error) {
147148
 	default:
148149
 		return Result{}, errors.New("repos: owner is XOR — set OwnerUserID OR OwnerOrgID, not both")
149150
 	}
151
+	if p.OwnerOrgID != 0 && p.Visibility == "private" {
152
+		check, err := entitlements.CheckPrivateRepositoryCreation(ctx, entitlements.Deps{Pool: deps.Pool}, p.OwnerOrgID)
153
+		if err != nil {
154
+			return Result{}, err
155
+		}
156
+		if err := check.Err(); err != nil {
157
+			return Result{}, err
158
+		}
159
+	}
150160
 
151161
 	// Rate-limit per actor (NOT per owner) so a user can't bypass the
152162
 	// per-account cap by spreading creates across orgs they manage.
internal/repos/create_test.gomodified
@@ -20,6 +20,7 @@ import (
2020
 
2121
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
2222
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
23
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
2324
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
2425
 	"github.com/tenseleyFlow/shithub/internal/orgs"
2526
 	"github.com/tenseleyFlow/shithub/internal/repos"
@@ -81,6 +82,19 @@ func setupCreateEnv(t *testing.T) (*pgxpool.Pool, repos.Deps, int64, string, str
8182
 	return pool, deps, user.ID, user.Username, root
8283
 }
8384
 
85
+func mustCreateRepoUser(t *testing.T, db usersdb.DBTX, username string) usersdb.User {
86
+	t.Helper()
87
+	user, err := usersdb.New().CreateUser(context.Background(), db, usersdb.CreateUserParams{
88
+		Username:     username,
89
+		DisplayName:  username,
90
+		PasswordHash: fixtureHash,
91
+	})
92
+	if err != nil {
93
+		t.Fatalf("CreateUser %s: %v", username, err)
94
+	}
95
+	return user
96
+}
97
+
8498
 func TestCreate_EmptyRepo(t *testing.T) {
8599
 	t.Parallel()
86100
 	_, deps, uid, uname, root := setupCreateEnv(t)
@@ -116,6 +130,35 @@ func TestCreate_EmptyRepo(t *testing.T) {
116130
 	}
117131
 }
118132
 
133
+func TestCreate_PrivateOrgRepoRespectsCollaborationLimit(t *testing.T) {
134
+	t.Parallel()
135
+	pool, deps, uid, _, _ := setupCreateEnv(t)
136
+	ctx := context.Background()
137
+	org, err := orgs.Create(ctx, orgs.Deps{Pool: pool}, orgs.CreateParams{
138
+		Slug:            "acme",
139
+		CreatedByUserID: uid,
140
+	})
141
+	if err != nil {
142
+		t.Fatalf("create org: %v", err)
143
+	}
144
+	for _, name := range []string{"owner2", "owner3", "owner4"} {
145
+		user := mustCreateRepoUser(t, pool, name)
146
+		if _, err := pool.Exec(ctx, `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`, org.ID, user.ID); err != nil {
147
+			t.Fatalf("insert owner: %v", err)
148
+		}
149
+	}
150
+	_, err = repos.Create(ctx, deps, repos.Params{
151
+		OwnerOrgID:  org.ID,
152
+		OwnerSlug:   string(org.Slug),
153
+		ActorUserID: uid,
154
+		Name:        "secret",
155
+		Visibility:  "private",
156
+	})
157
+	if !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
158
+		t.Fatalf("Create private org repo err=%v, want private collaboration limit", err)
159
+	}
160
+}
161
+
119162
 func TestCreate_WithReadmeLicenseGitignore(t *testing.T) {
120163
 	t.Parallel()
121164
 	_, deps, uid, uname, _ := setupCreateEnv(t)
internal/repos/lifecycle/lifecycle_test.gomodified
@@ -14,7 +14,9 @@ import (
1414
 
1515
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
1616
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
17
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
1718
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
19
+	"github.com/tenseleyFlow/shithub/internal/orgs"
1820
 	"github.com/tenseleyFlow/shithub/internal/repos"
1921
 	"github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
2022
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
@@ -87,6 +89,19 @@ func setup(t *testing.T) *env {
8789
 	}
8890
 }
8991
 
92
+func mustLifecycleUser(t *testing.T, db usersdb.DBTX, username string) usersdb.User {
93
+	t.Helper()
94
+	user, err := usersdb.New().CreateUser(context.Background(), db, usersdb.CreateUserParams{
95
+		Username:     username,
96
+		DisplayName:  username,
97
+		PasswordHash: fixtureHash,
98
+	})
99
+	if err != nil {
100
+		t.Fatalf("CreateUser %s: %v", username, err)
101
+	}
102
+	return user
103
+}
104
+
90105
 func TestRename_HappyPath(t *testing.T) {
91106
 	t.Parallel()
92107
 	env := setup(t)
@@ -193,6 +208,37 @@ func TestSetVisibility(t *testing.T) {
193208
 	}
194209
 }
195210
 
211
+func TestSetVisibilityRespectsPrivateCollaborationLimit(t *testing.T) {
212
+	t.Parallel()
213
+	env := setup(t)
214
+	ctx := context.Background()
215
+	org, err := orgs.Create(ctx, orgs.Deps{Pool: env.deps.Pool}, orgs.CreateParams{
216
+		Slug:            "acme",
217
+		CreatedByUserID: env.alice.ID,
218
+	})
219
+	if err != nil {
220
+		t.Fatalf("create org: %v", err)
221
+	}
222
+	repo, err := reposdb.New().CreateRepo(ctx, env.deps.Pool, reposdb.CreateRepoParams{
223
+		OwnerOrgID:    pgtype.Int8{Int64: org.ID, Valid: true},
224
+		Name:          "soon-private",
225
+		DefaultBranch: "trunk",
226
+		Visibility:    reposdb.RepoVisibilityPublic,
227
+	})
228
+	if err != nil {
229
+		t.Fatalf("create org repo: %v", err)
230
+	}
231
+	for _, userID := range []int64{env.bob.ID, mustLifecycleUser(t, env.deps.Pool, "carol").ID, mustLifecycleUser(t, env.deps.Pool, "dave").ID} {
232
+		if _, err := env.deps.Pool.Exec(ctx, `INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')`, repo.ID, userID); err != nil {
233
+			t.Fatalf("insert collaborator: %v", err)
234
+		}
235
+	}
236
+	err = lifecycle.SetVisibility(ctx, env.deps, env.alice.ID, repo.ID, "private")
237
+	if !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
238
+		t.Fatalf("SetVisibility err=%v, want private collaboration limit", err)
239
+	}
240
+}
241
+
196242
 func TestSoftDeleteAndRestore(t *testing.T) {
197243
 	t.Parallel()
198244
 	env := setup(t)
internal/repos/lifecycle/visibility.gomodified
@@ -8,6 +8,7 @@ import (
88
 	"fmt"
99
 
1010
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
11
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
1112
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
1213
 )
1314
 
@@ -35,6 +36,15 @@ func SetVisibility(ctx context.Context, deps Deps, actorUserID, repoID int64, ne
3536
 	if string(repo.Visibility) == newVisibility {
3637
 		return nil // idempotent no-op
3738
 	}
39
+	if repo.OwnerOrgID.Valid && newVisibility == "private" {
40
+		check, err := entitlements.CheckRepoPrivateVisibility(ctx, entitlements.Deps{Pool: deps.Pool}, repo.OwnerOrgID.Int64, repo.ID)
41
+		if err != nil {
42
+			return err
43
+		}
44
+		if err := check.Err(); err != nil {
45
+			return err
46
+		}
47
+	}
3848
 
3949
 	if err := rq.SetRepoVisibility(ctx, deps.Pool, reposdb.SetRepoVisibilityParams{
4050
 		ID:         repoID,
internal/web/handlers/api/collaborators.gomodified
@@ -15,6 +15,7 @@ import (
1515
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
1616
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
1717
 	policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
18
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
1819
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
1920
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
2021
 )
@@ -166,6 +167,16 @@ func (h *Handlers) collaboratorPut(w http.ResponseWriter, r *http.Request) {
166167
 		writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
167168
 		return
168169
 	}
170
+	check, err := entitlements.CheckDirectPrivateCollaborator(r.Context(), entitlements.Deps{Pool: h.d.Pool}, repo.ID, user.ID)
171
+	if err != nil {
172
+		h.d.Logger.ErrorContext(r.Context(), "api: private collaborator entitlement check", "error", err)
173
+		writeAPIError(w, http.StatusInternalServerError, "collaborator entitlement check failed")
174
+		return
175
+	}
176
+	if err := check.Err(); err != nil {
177
+		writeAPIError(w, http.StatusPaymentRequired, err.Error())
178
+		return
179
+	}
169180
 	if err := policydb.New().UpsertCollabRole(r.Context(), h.d.Pool, policydb.UpsertCollabRoleParams{
170181
 		RepoID: repo.ID,
171182
 		UserID: user.ID,
internal/web/handlers/api/repos.gomodified
@@ -15,6 +15,7 @@ import (
1515
 
1616
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
1717
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
18
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
1819
 	"github.com/tenseleyFlow/shithub/internal/orgs"
1920
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
2021
 	"github.com/tenseleyFlow/shithub/internal/repos"
@@ -423,6 +424,8 @@ func writeRepoCreateError(w http.ResponseWriter, err error) {
423424
 		writeAPIError(w, http.StatusConflict, "name taken for owner")
424425
 	case errors.Is(err, repos.ErrNoVerifiedEmail):
425426
 		writeAPIError(w, http.StatusUnprocessableEntity, "actor has no verified primary email")
427
+	case errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded):
428
+		writeAPIError(w, http.StatusPaymentRequired, err.Error())
426429
 	default:
427430
 		writeAPIError(w, http.StatusInternalServerError, "create failed")
428431
 	}
@@ -509,6 +512,10 @@ func (h *Handlers) repoPatch(w http.ResponseWriter, r *http.Request) {
509512
 			ldeps := lifecycle.Deps{Pool: h.d.Pool, RepoFS: h.d.RepoFS, Audit: h.d.Audit, Logger: h.d.Logger}
510513
 			if err := lifecycle.SetVisibility(r.Context(), ldeps, auth.UserID, repo.ID, newVis); err != nil {
511514
 				h.d.Logger.ErrorContext(r.Context(), "api: set visibility", "error", err)
515
+				if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
516
+					writeAPIError(w, http.StatusPaymentRequired, err.Error())
517
+					return
518
+				}
512519
 				writeAPIError(w, http.StatusInternalServerError, "visibility update failed")
513520
 				return
514521
 			}
internal/web/handlers/orgs/orgs.gomodified
@@ -42,6 +42,7 @@ import (
4242
 	authemail "github.com/tenseleyFlow/shithub/internal/auth/email"
4343
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
4444
 	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
45
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
4546
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
4647
 	"github.com/tenseleyFlow/shithub/internal/orgs"
4748
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
@@ -412,11 +413,21 @@ func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) {
412413
 		"HasQuery":        query != "",
413414
 		"IsOwner":         isOwner,
414415
 		"CanManagePeople": isOwner,
416
+		"Notice":          peopleNoticeMessage(r.URL.Query().Get("notice")),
415417
 	}); err != nil {
416418
 		h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/people", "error", err)
417419
 	}
418420
 }
419421
 
422
+func peopleNoticeMessage(code string) string {
423
+	switch code {
424
+	case "private-collab-upgrade":
425
+		return "Free organizations can have up to 3 private collaborators. Upgrade to Team to add more private collaborators."
426
+	default:
427
+		return ""
428
+	}
429
+}
430
+
420431
 func filterOrgMembers(members []orgsdb.ListOrgMembersRow, query string) []orgsdb.ListOrgMembersRow {
421432
 	query = strings.ToLower(strings.TrimSpace(query))
422433
 	if query == "" {
@@ -478,6 +489,10 @@ func (h *Handlers) invite(w http.ResponseWriter, r *http.Request) {
478489
 	if _, err := orgs.Invite(r.Context(), h.deps(), p); err != nil {
479490
 		h.d.Logger.WarnContext(r.Context(), "orgs: invite failed",
480491
 			"org", org.Slug, "target", target, "error", err)
492
+		if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
493
+			http.Redirect(w, r, "/"+org.Slug+"/people?notice=private-collab-upgrade", http.StatusSeeOther)
494
+			return
495
+		}
481496
 	}
482497
 	http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
483498
 }
@@ -530,6 +545,10 @@ func (h *Handlers) memberMutate(w http.ResponseWriter, r *http.Request, action f
530545
 	if err := action(org.ID, uid); err != nil {
531546
 		h.d.Logger.WarnContext(r.Context(), "orgs: member mutation",
532547
 			"org", org.Slug, "user_id", uid, "error", err)
548
+		if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
549
+			http.Redirect(w, r, "/"+org.Slug+"/people?notice=private-collab-upgrade", http.StatusSeeOther)
550
+			return
551
+		}
533552
 	}
534553
 	http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
535554
 }
@@ -591,6 +610,10 @@ func (h *Handlers) invitationAction(w http.ResponseWriter, r *http.Request, acce
591610
 		if err := orgs.AcceptInvitation(r.Context(), h.deps(), inv, viewer.ID); err != nil {
592611
 			h.d.Logger.WarnContext(r.Context(), "orgs: accept invitation",
593612
 				"id", inv.ID, "error", err)
613
+			if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
614
+				h.d.Render.HTTPError(w, r, http.StatusPaymentRequired, "")
615
+				return
616
+			}
594617
 			h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
595618
 			return
596619
 		}
internal/web/handlers/orgs/teams.gomodified
@@ -4,6 +4,7 @@ package orgs
44
 
55
 import (
66
 	"context"
7
+	"errors"
78
 	"net/http"
89
 	"net/url"
910
 	"strconv"
@@ -191,6 +192,8 @@ func teamsNoticeMessage(code string) string {
191192
 		return "Secret teams are read-only until Team billing is brought back into good standing."
192193
 	case "secret-teams-enterprise":
193194
 		return "Secret teams are unavailable for Enterprise preview organizations. Contact sales to enable them."
195
+	case "private-collab-upgrade":
196
+		return "Free organizations can have up to 3 private collaborators. Upgrade to Team to add more private collaborators."
194197
 	default:
195198
 		return ""
196199
 	}
@@ -335,7 +338,13 @@ func (h *Handlers) teamMemberAddRemove(w http.ResponseWriter, r *http.Request) {
335338
 			return
336339
 		}
337340
 		role := r.PostFormValue("role")
338
-		_ = orgs.AddTeamMember(r.Context(), h.deps(), team.ID, uid, viewer.ID, role)
341
+		if err := orgs.AddTeamMember(r.Context(), h.deps(), team.ID, uid, viewer.ID, role); err != nil {
342
+			h.d.Logger.WarnContext(r.Context(), "teams: add member", "org_id", org.ID, "team_id", team.ID, "user_id", uid, "error", err)
343
+			if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
344
+				http.Redirect(w, r, h.teamPath(org, team)+"?notice=private-collab-upgrade", http.StatusSeeOther)
345
+				return
346
+			}
347
+		}
339348
 	}
340349
 	http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
341350
 }
@@ -381,8 +390,14 @@ func (h *Handlers) teamRepoGrant(w http.ResponseWriter, r *http.Request) {
381390
 			http.Redirect(w, r, h.teamPath(org, team)+"?notice="+noticeCode, http.StatusSeeOther)
382391
 			return
383392
 		}
384
-		_ = orgs.GrantTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID, viewer.ID,
385
-			r.PostFormValue("role"))
393
+		if err := orgs.GrantTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID, viewer.ID,
394
+			r.PostFormValue("role")); err != nil {
395
+			h.d.Logger.WarnContext(r.Context(), "teams: grant repo", "org_id", org.ID, "team_id", team.ID, "repo_id", repoID, "error", err)
396
+			if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
397
+				http.Redirect(w, r, h.teamPath(org, team)+"?notice=private-collab-upgrade", http.StatusSeeOther)
398
+				return
399
+			}
400
+		}
386401
 	}
387402
 	http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
388403
 }
internal/web/handlers/repo/lifecycle.gomodified
@@ -12,6 +12,7 @@ import (
1212
 	"github.com/jackc/pgx/v5/pgtype"
1313
 
1414
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
15
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
1516
 	"github.com/tenseleyFlow/shithub/internal/orgs"
1617
 	"github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
1718
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
@@ -406,6 +407,8 @@ func (h *Handlers) lifecycleError(w http.ResponseWriter, r *http.Request, err er
406407
 		http.Error(w, "transfer no longer pending", http.StatusConflict)
407408
 	case errors.Is(err, lifecycle.ErrPastGrace):
408409
 		http.Error(w, "soft-delete grace expired", http.StatusGone)
410
+	case errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded):
411
+		http.Error(w, err.Error(), http.StatusPaymentRequired)
409412
 	default:
410413
 		h.d.Logger.WarnContext(r.Context(), "lifecycle: unexpected error", "error", err)
411414
 		http.Error(w, "internal error", http.StatusInternalServerError)
internal/web/handlers/repo/repo.gomodified
@@ -24,6 +24,7 @@ import (
2424
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
2525
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
2626
 	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
27
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
2728
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
2829
 	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
2930
 	"github.com/tenseleyFlow/shithub/internal/orgs"
@@ -559,6 +560,8 @@ func friendlyCreateError(err error) string {
559560
 		return "Unknown license selection."
560561
 	case errors.Is(err, repos.ErrUnknownGitignore):
561562
 		return "Unknown .gitignore selection."
563
+	case errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded):
564
+		return err.Error()
562565
 	}
563566
 	if t, ok := isThrottled(err); ok {
564567
 		return "You're creating repositories too quickly. Try again in " + t + "."
internal/web/templates/orgs/people.htmlmodified
@@ -28,6 +28,7 @@
2828
     </aside>
2929
 
3030
     <div class="shithub-org-people-main">
31
+      {{ with .Notice }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
3132
       <div class="shithub-org-people-toolbar">
3233
         <form method="GET" action="/{{ .Org.Slug }}/people" class="shithub-org-people-search" role="search">
3334
           {{ octicon "search" }}